diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 2226d80..b82b2e1 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -24,8 +24,15 @@ const roomQuerySchema = z.object({ /** * `refresh: true` is sent by the frontend's single automatic retry after Synapse * rejected a token — otherwise it would just get the same cached token back. + * + * BUG-27: `organizationId` is required, not optional. A user who belongs to more + * than one organization has a SEPARATE members row (and Matrix account) per + * org — without this, the old `rows.find(row => row.matrixUserId)` picked + * whichever membership happened to come back first from the DB, which could + * mint a token for the wrong organization's Matrix identity. */ const tokenBodySchema = z.object({ + organizationId: z.string().uuid(), refresh: z.boolean().optional(), }); @@ -42,20 +49,26 @@ export async function chatRoutes(app: FastifyInstance) { return reply.code(400).send({ error: parsedBody.error.issues[0].message }); } - const rows = await db + const [provisioned] = await db .select({ id: members.id, organizationId: members.organizationId, matrixUserId: members.matrixUserId, }) .from(members) - .where(and(eq(members.userId, request.user.sub), eq(members.status, "active"))); + .where( + and( + eq(members.userId, request.user.sub), + eq(members.status, "active"), + eq(members.organizationId, parsedBody.data.organizationId), + ), + ) + .limit(1); - const provisioned = rows.find((row) => row.matrixUserId); - if (rows.length === 0) { + if (!provisioned) { return reply.code(403).send({ error: "Forbidden" }); } - if (!provisioned?.matrixUserId) { + if (!provisioned.matrixUserId) { // Provisioning has not run yet — the UI shows "Chat wird eingerichtet" and polls. return reply.code(409).send({ error: "matrix_not_provisioned" }); } diff --git a/backend/src/routes/teams.ts b/backend/src/routes/teams.ts index e4281b3..1a12b44 100644 --- a/backend/src/routes/teams.ts +++ b/backend/src/routes/teams.ts @@ -68,10 +68,20 @@ export async function teamRoutes(app: FastifyInstance) { // BUG-13: matrixRoomId used to go out for every team in the org, regardless // of whether the requester actually belongs to it. Null it out for teams // they're not an active member of. + // BUG-27: this lookup used to ignore `organizationId`, so a user who is a + // member of more than one org could get the wrong org's membership row + // (whichever the DB happened to return first) — right team, wrong org's + // membership row, wrong badge visibility. Scoped to this request's org now. const [requesterMember] = await db .select({ id: members.id }) .from(members) - .where(and(eq(members.userId, request.user.sub), eq(members.status, "active"))) + .where( + and( + eq(members.userId, request.user.sub), + eq(members.status, "active"), + eq(members.organizationId, organizationId), + ), + ) .limit(1); const ownTeamIds = requesterMember ? new Set( diff --git a/features/PROJ-11-chat.md b/features/PROJ-11-chat.md index 5b1da0a..90bdf0e 100644 --- a/features/PROJ-11-chat.md +++ b/features/PROJ-11-chat.md @@ -942,3 +942,16 @@ Live verifiziert: Token liefert vor dem Logout `200` auf `/account/whoami`, nach Live verifiziert: erstes Hinzufügen `201`, zweites (Duplikat) `409 {"error":"Member is already on this team"}` statt `500`. **Lehre, konsistent mit BUG-23:** ein Fix, der nur nach Code-Lesen plausibel aussieht, ist erst durch einen echten Request gegen die Fehlerbehandlung verifiziert — die Fehler-Wrapping-Tiefe von ORM-Bibliotheken lässt sich am Typsystem nicht ablesen (`err.code` kompiliert anstandslos, obwohl es zur Laufzeit `undefined` ist). + +## BUG-26/27/28-Fix (2026-09-12) + +**BUG-27 (Low → strukturell behoben, live verifiziert).** `organizationId` ist jetzt Pflichtfeld in `POST /api/chat/token` (statt der alten `rows.find(row => row.matrixUserId)`-Rateauswahl bei Mehrfach-Mitgliedschaft) und wird in `GET /api/teams`s BUG-13-Fix-Query mit-gefiltert. Frontend: `fetchChatCredentials()`, `useTeamChat()`, `useTeamUnreadCounts()` nehmen jetzt alle `organizationId` entgegen (durchgereicht via `useOrganization()` in `TeamDetail.tsx`/`TeamsPage.tsx`); der Frontend-Credential-Cache in `lib/matrix.ts` ist jetzt pro Organisation geführt (`Map` statt Einzelwert), damit ein Org-Wechsel im `OrganizationSwitcher` nicht den Token der vorherigen Organisation weiterverwendet. Live verifiziert: Request ohne `organizationId` → `400`, mit → korrekt org-gescopt. + +**BUG-26 (Medium) — behoben.** `auth-context.tsx` persistiert das JWT jetzt in `localStorage` (`talktime.token`), stellt es beim Mount wieder her (validiert über `/api/auth/me`, verwirft es still bei Ungültigkeit) und exponiert einen neuen `initializing`-Zustand. `ProtectedRoute.tsx` wartet auf `initializing`, bevor es auf `/login` umleitet — vorher warf jeder Reload sofort raus, weil die Restore-Prüfung noch lief. Logout entfernt den Eintrag wieder. + +**BUG-28 (Low) — behoben.** Zwei Maßnahmen statt einer, da eine feste `vh`-Höhe das eigentliche Problem (Seitenkopf + Formular + Team-Kacheln + Card oberhalb der Chat-Box summieren sich über 100vh) nicht lösen kann: (1) `h-[60vh]`/`h-[65vh]` → `h-[60dvh]`/`h-[65dvh]` (dynamische statt statische Viewport-Höhe, hilft gegen mobile Browser-Chrome/Tastatur-Verschiebung), (2) `TeamChat.tsx` scrollt sich beim Mount selbst in den sichtbaren Bereich (`scrollIntoView`) — greift zuverlässig bei jedem Öffnen des Chat-Tabs, da Radix Tabs den Inhalt inaktiver Tabs nicht rendert (jeder Tab-Wechsel ist ein Neu-Mount). + +Alle drei Fixes: Backend-/Frontend-Typecheck und Production-Build grün. BUG-26/28 nicht browserseitig nachtestbar in dieser Umgebung (kein Browser) — Code-Logik + Build sind der verfügbare Beleg, BUG-27 wurde per Live-Request verifiziert. + +### Verbleibend +Keiner der 28 in QA gefundenen Bugs ist mehr unbearbeitet. Bewusst akzeptiert bleiben BUG-11 (SDK-Ladezeitpunkt für Team-Listen-Besucher) und der BUG-2/20-Multi-Tenant-Restrisiko (Empfehlung: ein Synapse pro Verein, bis eine Isolationslösung für geteilte Homeserver ansteht). Offen für einen manuellen Test: iOS-Safari mit eingeblendeter Tastatur. diff --git a/frontend/src/components/chat/TeamChat.tsx b/frontend/src/components/chat/TeamChat.tsx index 42536ab..2b0e019 100644 --- a/frontend/src/components/chat/TeamChat.tsx +++ b/frontend/src/components/chat/TeamChat.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import type { useTeamChat } from "@/hooks/use-team-chat"; @@ -14,6 +14,7 @@ interface TeamChatProps { export function TeamChat({ chat, teamName }: TeamChatProps) { const { status, markRead, messages } = chat; const lastId = messages[messages.length - 1]?.id; + const containerRef = useRef(null); useEffect(() => { if (status === "ready" && lastId) { @@ -21,6 +22,15 @@ export function TeamChat({ chat, teamName }: TeamChatProps) { } }, [status, lastId, markRead]); + // BUG-28: page header, the "Team anlegen" card and the team-tile grid all + // sit above this component, so a fixed vh-fraction for the chat box was + // never guaranteed to leave the composer on screen — measured off-screen in + // every tested viewport, not just small ones. Radix Tabs only mounts the + // active tab's content, so this fires exactly once per "Chat" tab click. + useEffect(() => { + containerRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + if (status === "pending") { const setupText = chat.pendingReason === "room_not_provisioned" @@ -65,7 +75,10 @@ export function TeamChat({ chat, teamName }: TeamChatProps) { return (
diff --git a/frontend/src/components/chat/TeamDetail.tsx b/frontend/src/components/chat/TeamDetail.tsx index 4279052..b981a3a 100644 --- a/frontend/src/components/chat/TeamDetail.tsx +++ b/frontend/src/components/chat/TeamDetail.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAuth } from "@/lib/auth-context"; +import { useOrganization } from "@/lib/organization-context"; import { useTeamChat } from "@/hooks/use-team-chat"; import { TeamChat } from "./TeamChat"; @@ -22,7 +23,8 @@ export function TeamDetail({ onClose, }: TeamDetailProps) { const { token } = useAuth(); - const chat = useTeamChat(token, teamId); + const { activeOrganizationId } = useOrganization(); + const chat = useTeamChat(token, activeOrganizationId, teamId); return ( diff --git a/frontend/src/hooks/use-team-chat.ts b/frontend/src/hooks/use-team-chat.ts index fb1f8f2..826ae5b 100644 --- a/frontend/src/hooks/use-team-chat.ts +++ b/frontend/src/hooks/use-team-chat.ts @@ -77,7 +77,11 @@ function toMessages(room: Room, ownUserId: string): ChatMessage[] { }); } -export function useTeamChat(authToken: string | null, teamId: string | null): UseTeamChatResult { +export function useTeamChat( + authToken: string | null, + organizationId: string | null, + teamId: string | null, +): UseTeamChatResult { const [status, setStatus] = useState("loading"); const [pendingReason, setPendingReason] = useState(null); const [error, setError] = useState(null); @@ -103,7 +107,7 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us }, []); useEffect(() => { - if (!authToken || !teamId) { + if (!authToken || !organizationId || !teamId) { setStatus("loading"); return; } @@ -131,7 +135,7 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us setPendingReason(null); try { const roomId = await fetchTeamRoomId(authToken!, teamId!); - const creds = await fetchChatCredentials(authToken!); + const creds = await fetchChatCredentials(authToken!, organizationId!); if (cancelled) return; client = await createMatrixClient(creds); @@ -208,7 +212,7 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us if (pollTimer) clearTimeout(pollTimer); teardownClient(); }; - }, [authToken, teamId, attempt, refresh]); + }, [authToken, organizationId, teamId, attempt, refresh]); const sendMessage = useCallback( async (body: string) => { @@ -222,7 +226,7 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us } catch (err) { // Expired token: one automatic retry with fresh credentials, then surface the error. try { - const creds = await fetchChatCredentials(authToken!, { refresh: true }); + const creds = await fetchChatCredentials(authToken!, organizationId!, { refresh: true }); client.setAccessToken(creds.accessToken); await client.sendTextMessage(room.roomId, body.trim()); } catch { @@ -234,7 +238,7 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us refresh(); } }, - [authToken, refresh], + [authToken, organizationId, refresh], ); const loadMore = useCallback(async () => { diff --git a/frontend/src/hooks/use-team-unread-counts.ts b/frontend/src/hooks/use-team-unread-counts.ts index ff6639f..d361833 100644 --- a/frontend/src/hooks/use-team-unread-counts.ts +++ b/frontend/src/hooks/use-team-unread-counts.ts @@ -32,6 +32,7 @@ interface TeamRoom { */ export function useTeamUnreadCounts( authToken: string | null, + organizationId: string | null, teams: TeamRoom[], ): Record { const [counts, setCounts] = useState>({}); @@ -51,7 +52,7 @@ export function useTeamUnreadCounts( }, [teams]); useEffect(() => { - if (!authToken || !roomIds) { + if (!authToken || !organizationId || !roomIds) { setCounts({}); return; } @@ -103,7 +104,7 @@ export function useTeamUnreadCounts( async function connect(forceRefresh = false) { try { - const creds = await fetchChatCredentials(authToken!, { refresh: forceRefresh }); + const creds = await fetchChatCredentials(authToken!, organizationId!, { refresh: forceRefresh }); if (cancelled) return; client = await createMatrixClient(creds); if (cancelled) { @@ -168,7 +169,7 @@ export function useTeamUnreadCounts( } teardownClient(); }; - }, [authToken, roomIds]); + }, [authToken, organizationId, roomIds]); return counts; } diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index e3fb914..7d3f635 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { clearCachedChatCredentials } from "@/lib/matrix"; interface AuthUser { @@ -9,11 +9,20 @@ interface AuthUser { interface AuthContextValue { token: string | null; user: AuthUser | null; + /** BUG-26: true only while restoring a persisted session on first load — lets + * ProtectedRoute wait instead of redirecting to /login before the restore + * attempt has even finished. */ + initializing: boolean; login: (email: string, password: string) => Promise; setToken: (token: string) => Promise; logout: () => void; } +// BUG-26: the JWT used to live only in useState, so every page reload (or +// opening a deep link) threw the user back to /login — no way to bookmark or +// share a team's chat, and refreshing mid-chat ended the session outright. +const STORAGE_KEY = "talktime.token"; + const AuthContext = createContext(null); async function parseError(res: Response): Promise { @@ -28,6 +37,7 @@ async function parseError(res: Response): Promise { export function AuthProvider({ children }: { children: ReactNode }) { const [token, setTokenState] = useState(null); const [user, setUser] = useState(null); + const [initializing, setInitializing] = useState(true); const applyToken = useCallback(async (newToken: string) => { const meRes = await fetch("/api/auth/me", { @@ -38,10 +48,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const me = (await meRes.json()) as AuthUser; + localStorage.setItem(STORAGE_KEY, newToken); setTokenState(newToken); setUser(me); }, []); + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + setInitializing(false); + return; + } + applyToken(stored) + .catch(() => { + // Stored token is expired/invalid — drop it silently, same as a fresh visit. + localStorage.removeItem(STORAGE_KEY); + }) + .finally(() => setInitializing(false)); + // Intentionally only on mount — applyToken is stable (empty dep array). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const login = useCallback( async (email: string, password: string) => { const res = await fetch("/api/auth/login", { @@ -69,13 +96,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { }).catch(() => undefined); } clearCachedChatCredentials(); + localStorage.removeItem(STORAGE_KEY); setTokenState(null); setUser(null); }, [token]); const value = useMemo( - () => ({ token, user, login, setToken: applyToken, logout }), - [token, user, login, applyToken, logout], + () => ({ token, user, initializing, login, setToken: applyToken, logout }), + [token, user, initializing, login, applyToken, logout], ); return {children}; diff --git a/frontend/src/lib/matrix.ts b/frontend/src/lib/matrix.ts index ccb6fff..0b0dbff 100644 --- a/frontend/src/lib/matrix.ts +++ b/frontend/src/lib/matrix.ts @@ -30,16 +30,23 @@ async function readError(res: Response): Promise { // POST /api/chat/token's 10/min limit just from a user clicking through a few // teams. A tiny module-level cache lets every caller in this tab share one // fetch instead of each hitting the backend on its own. -let cachedCredentials: { creds: ChatCredentials; expiresAt: number } | null = null; +// +// BUG-27: keyed by organizationId — a user in more than one org has a +// separate Matrix identity per org, so a cache shared across orgs would +// silently hand out the wrong org's token after switching in the +// OrganizationSwitcher. +const credentialCache = new Map(); const CREDENTIAL_REFRESH_MARGIN_MS = 60_000; /** Asks the TalkTime backend for a short-lived Matrix access token for the logged-in user. */ export async function fetchChatCredentials( token: string, + organizationId: string, options: { refresh?: boolean } = {}, ): Promise { - if (!options.refresh && cachedCredentials && cachedCredentials.expiresAt - Date.now() > CREDENTIAL_REFRESH_MARGIN_MS) { - return cachedCredentials.creds; + const cached = credentialCache.get(organizationId); + if (!options.refresh && cached && cached.expiresAt - Date.now() > CREDENTIAL_REFRESH_MARGIN_MS) { + return cached.creds; } const res = await fetch("/api/chat/token", { @@ -50,7 +57,7 @@ export async function fetchChatCredentials( }, // `refresh` forces the backend to drop its cached token instead of handing // back the one Synapse just rejected. - body: JSON.stringify({ refresh: options.refresh === true }), + body: JSON.stringify({ organizationId, refresh: options.refresh === true }), }); if (res.status === 409) { throw new ChatSetupPendingError("matrix_not_provisioned"); @@ -59,13 +66,13 @@ export async function fetchChatCredentials( throw new Error(await readError(res)); } const creds = (await res.json()) as ChatCredentials; - cachedCredentials = { creds, expiresAt: Date.now() + creds.expiresInMs }; + credentialCache.set(organizationId, { creds, expiresAt: Date.now() + creds.expiresInMs }); return creds; } /** BUG-10: drop the shared credential cache on logout so a stale token isn't reused. */ export function clearCachedChatCredentials(): void { - cachedCredentials = null; + credentialCache.clear(); } /** Resolves the Matrix room id of a team. */ diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx index a2092c3..f1884e3 100644 --- a/frontend/src/pages/TeamsPage.tsx +++ b/frontend/src/pages/TeamsPage.tsx @@ -61,7 +61,7 @@ export function TeamsPage() { orgQuery ? `/api/teams?${orgQuery}` : null, "teams", ); - const unreadCounts = useTeamUnreadCounts(token, teams); + const unreadCounts = useTeamUnreadCounts(token, activeOrganizationId, teams); async function onCreate() { if (!activeOrganizationId || !name.trim() || !departmentId || !seasonId) return; diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx index 7d796e2..d630d32 100644 --- a/frontend/src/routes/ProtectedRoute.tsx +++ b/frontend/src/routes/ProtectedRoute.tsx @@ -2,7 +2,12 @@ import { Navigate, Outlet } from "react-router-dom"; import { useAuth } from "@/lib/auth-context"; export function ProtectedRoute() { - const { token } = useAuth(); + const { token, initializing } = useAuth(); + // BUG-26: without this, a reload always redirected to /login — the stored + // token restore in AuthProvider hadn't resolved yet on the very first render. + if (initializing) { + return null; + } if (!token) { return ; }