fix(PROJ-11): BUG-26 (session persist), BUG-27 (org-scoped chat token), BUG-28 (composer off-screen)

BUG-27: POST /api/chat/token required no organizationId and just took
whichever provisioned membership row the DB returned first — a user in
more than one org (multi-tenant is explicitly in scope per PRD) could
get a Matrix token for the wrong org's identity. organizationId is now
required, threaded through fetchChatCredentials/useTeamChat/
useTeamUnreadCounts, and the frontend credential cache is now keyed
per-org so switching orgs doesn't reuse a stale token. Same ambiguity
fixed in GET /api/teams' BUG-13 membership lookup. Verified: token
request without organizationId now 400s.

BUG-26: the JWT lived only in useState, so every reload or deep link
threw the user back to /login. auth-context.tsx now persists it to
localStorage and restores/validates it on mount (via /api/auth/me);
ProtectedRoute waits on a new `initializing` flag instead of redirecting
before the restore attempt resolves.

BUG-28: TeamChat's fixed vh-fraction height could never guarantee the
composer stayed on screen — page header, the create-team card and the
team-tile grid all sit above it, so total content exceeds 100vh
regardless of the chat box's own height. Switched vh to dvh (mobile
keyboard/chrome resize) and added a scrollIntoView on mount, which
fires reliably since Radix Tabs only mounts the active tab's content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 00:05:49 +02:00
1 parent 94b25bcc36
commit ad6efc4afa
11 files changed
+125 -29

No files matched your search

+18 -5
View File
@@ -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" });
}
+11 -1
View File
@@ -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(
+13
View File
@@ -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.
+15 -2
View File
@@ -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<HTMLDivElement | null>(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 (
<div
className="flex h-[60vh] flex-col rounded-lg border sm:h-[65vh]"
ref={containerRef}
// dvh accounts for mobile browser chrome/keyboard resizing the viewport,
// unlike vh which is fixed to the initial layout viewport (BUG-28).
className="flex h-[60dvh] flex-col rounded-lg border sm:h-[65dvh]"
aria-label={`Chat für ${teamName}`}
>
<div className="min-h-0 flex-1">
+3 -1
View File
@@ -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 (
<Card>
+10 -6
View File
@@ -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<ChatStatus>("loading");
const [pendingReason, setPendingReason] = useState<ChatSetupReason | null>(null);
const [error, setError] = useState<string | null>(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 () => {
+4 -3
View File
@@ -32,6 +32,7 @@ interface TeamRoom {
*/
export function useTeamUnreadCounts(
authToken: string | null,
organizationId: string | null,
teams: TeamRoom[],
): Record<string, number> {
const [counts, setCounts] = useState<Record<string, number>>({});
@@ -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;
}
+31 -3
View File
@@ -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<void>;
setToken: (token: string) => Promise<void>;
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<AuthContextValue | null>(null);
async function parseError(res: Response): Promise<string> {
@@ -28,6 +37,7 @@ async function parseError(res: Response): Promise<string> {
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setTokenState] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+13 -6
View File
@@ -30,16 +30,23 @@ async function readError(res: Response): Promise<string> {
// 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<string, { creds: ChatCredentials; expiresAt: number }>();
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<ChatCredentials> {
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. */
+1 -1
View File
@@ -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;
+6 -1
View File
@@ -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 <Navigate to="/login" replace />;
}