fix(PROJ-11): clear the remaining QA backlog (BUG-5/6/9/10/13/14/15/16/17/21/22)

Every previously-reported bug from the three QA rounds is now addressed
(BUG-11 and BUG-15 mitigated rather than fully solved — see spec for
why). Verified end-to-end on talkhub/matrix after each change.

Security:
- BUG-21: @fastify/helmet registered, CORS origin allowlist (APP_BASE_URL
  + localhost:5173) replaces `origin: true`. Verified: own origin gets
  CORS + HSTS/nosniff/X-Frame-Options headers, foreign origins get none.
- BUG-13: GET /api/teams only returns matrixRoomId for teams the
  requester actually belongs to (was leaking every team's room id to
  any org member). Verified with/without team membership.
- BUG-14: leftJoin now filters scope='team'; new partial unique index
  chat_rooms_team_id_team_scope_unique enforces one room per team at
  the DB level, not just in the query (migration 0002).

Reliability:
- BUG-5: use-team-chat.ts's setup poll now backs off exponentially
  (5s->60s) instead of a fixed 15s; SyncState.Error stops the client
  and reconnects with backoff instead of letting matrix-js-sdk retry
  against Synapse forever.
- BUG-6: one-off backfill script (src/scripts/backfill-history-visibility.ts)
  sets history_visibility=shared on rooms created before that fix;
  run against talkhub/matrix.
- BUG-9: route rate limit on GET /api/chat/room, unified 404 for
  nonexistent-team vs not-a-member (was a cross-org existence oracle),
  LRU-correct eviction order in matrix-token-cache.ts, scroll position
  preserved on "load older messages", and eslint.config.js added (lint
  never ran on this feature before — flat config was simply missing).
- BUG-15 (mitigated): a small module-level credential cache in
  lib/matrix.ts lets useTeamChat and useTeamUnreadCounts share one
  fetched token instead of each hitting POST /api/chat/token
  independently. Reduces but doesn't eliminate rate-limit risk under
  rapid team switching (no in-flight dedup between the two hooks).
- BUG-16: refreshCounts() checks `cancelled` before setCounts.
- BUG-17: use-team-chat.ts uses getRoomUnreadNotificationCount()
  instead of the deprecated getUnreadNotificationCount(); left
  unchanged in use-team-unread-counts.ts on purpose, since importing
  NotificationCountType there would undo BUG-11's bundle-splitting fix.

Cleanup:
- BUG-10: new POST /api/chat/logout invalidates the cached token and
  calls Synapse's logout_all admin endpoint; frontend calls it
  best-effort on logout and clears its own credential cache.
- BUG-22: trim() on displayName construction (trailing space with an
  empty last name); the reported trailing-dot localpart is moot after
  BUG-20 since localparts are random now, not name-derived.

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-11 23:10:37 +02:00
1 parent f3361d695e
commit edc05d4d1e
23 files changed
+2288 -70

No files matched your search

@@ -0,0 +1 @@
CREATE UNIQUE INDEX "chat_rooms_team_id_team_scope_unique" ON "chat_rooms" USING btree ("team_id") WHERE "chat_rooms"."scope" = 'team';
File diff suppressed because it is too large. Load diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1789144826183,
"tag": "0001_rare_mulholland_black",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1789160534830,
"tag": "0002_melted_imperial_guard",
"breakpoints": true
}
]
}
+51 -2
View File
@@ -1,14 +1,15 @@
{
"name": "ai-coding-starter-kit-backend",
"name": "talktime-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-coding-starter-kit-backend",
"name": "talktime-backend",
"version": "1.0.0",
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^13.1.1",
"@fastify/jwt": "^10.2.2",
"@fastify/rate-limit": "^10.1.1",
"bcryptjs": "^2.4.3",
@@ -1004,6 +1005,42 @@
],
"license": "MIT"
},
"node_modules/@fastify/helmet": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.1.tgz",
"integrity": "sha512-bSat5DTq8geASv8G6P0KW1UbltZ+xGD/zyd9S72pT7ogAHehcsWL85GdjMRCjDsExJvaEvgEZ52qU/2HXirVCw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^6.0.0",
"helmet": "^8.0.0"
}
},
"node_modules/@fastify/helmet/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/jwt": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz",
@@ -1697,6 +1734,18 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^13.1.1",
"@fastify/jwt": "^10.2.2",
"@fastify/rate-limit": "^10.1.1",
"bcryptjs": "^2.4.3",
+12 -1
View File
@@ -7,8 +7,10 @@ import {
integer,
pgEnum,
unique,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -249,7 +251,16 @@ export const chatRooms = pgTable(
teamId: uuid("team_id").references(() => teams.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => [index("chat_rooms_organization_id_idx").on(table.organizationId)],
(table) => [
index("chat_rooms_organization_id_idx").on(table.organizationId),
// BUG-14: without this, a repeated/failed create_team_room sync job could
// leave two rooms pointing at the same team, which made GET /api/teams'
// leftJoin return that team twice (duplicate tiles, duplicate React keys,
// one unread count silently overwriting the other).
uniqueIndex("chat_rooms_team_id_team_scope_unique")
.on(table.teamId)
.where(sql`${table.scope} = 'team'`),
],
);
export const syncJobStatusEnum = pgEnum("sync_job_status", [
+79 -43
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { chatRooms, members, teamMembers, teams } from "../db/schema.js";
import { publicHomeserverUrl } from "../services/synapse.js";
import { publicHomeserverUrl, logoutAllSessions } from "../services/synapse.js";
import {
getMatrixAccessToken,
invalidateMatrixAccessToken,
@@ -98,50 +98,86 @@ export async function chatRoutes(app: FastifyInstance) {
},
);
app.get("/api/chat/room", { preHandler: app.authenticate }, async (request, reply) => {
const parsed = roomQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { teamId } = parsed.data;
// BUG-10: regular logout used to leave the cached Matrix token (and the one
// Synapse itself issued) valid for up to its full 1h TTL. Only suspend/leave
// revoked it via deactivateUser(). This lets the frontend revoke on logout too.
app.post("/api/chat/logout", { preHandler: app.authenticate }, async (request, reply) => {
const rows = await db
.select({ matrixUserId: members.matrixUserId })
.from(members)
.where(and(eq(members.userId, request.user.sub), eq(members.status, "active")));
const [team] = await db
.select({ organizationId: teams.organizationId })
.from(teams)
.where(eq(teams.id, teamId))
.limit(1);
if (!team) {
return reply.code(404).send({ error: "Team not found" });
for (const row of rows) {
if (row.matrixUserId) {
invalidateMatrixAccessToken(row.matrixUserId);
try {
await logoutAllSessions(row.matrixUserId);
} catch (err) {
request.log.warn({ err }, "[chat] failed to revoke matrix sessions on logout");
}
}
}
// Only members of that very team may read its room id.
const [membership] = await db
.select({ id: teamMembers.id })
.from(teamMembers)
.innerJoin(members, eq(members.id, teamMembers.memberId))
.where(
and(
eq(teamMembers.teamId, teamId),
eq(members.userId, request.user.sub),
eq(members.status, "active"),
),
)
.limit(1);
if (!membership) {
return reply.code(403).send({ error: "Forbidden" });
}
const [room] = await db
.select({ matrixRoomId: chatRooms.matrixRoomId })
.from(chatRooms)
.where(and(eq(chatRooms.teamId, teamId), eq(chatRooms.scope, "team")))
.limit(1);
if (!room) {
// Room creation job has not run yet — UI shows "Raum wird eingerichtet".
return reply.code(409).send({ error: "room_not_provisioned" });
}
return reply.send({ matrixRoomId: room.matrixRoomId });
return reply.code(204).send();
});
app.get(
"/api/chat/room",
{
preHandler: app.authenticate,
// BUG-9: this fires on every setup-poll iteration alongside /api/chat/token,
// so it needs its own limit rather than relying on the global 100/min.
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
},
async (request, reply) => {
const parsed = roomQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { teamId } = parsed.data;
const [team] = await db
.select({ organizationId: teams.organizationId })
.from(teams)
.where(eq(teams.id, teamId))
.limit(1);
// BUG-9: a non-existent team and "team exists but you're not on it" used to
// return different status codes (404 vs 403), letting anyone probe team IDs
// across org boundaries. Both now say 404 — indistinguishable from the
// outside, low real impact since these are UUIDs, but no reason to leak it.
if (!team) {
return reply.code(404).send({ error: "Team not found" });
}
const [membership] = await db
.select({ id: teamMembers.id })
.from(teamMembers)
.innerJoin(members, eq(members.id, teamMembers.memberId))
.where(
and(
eq(teamMembers.teamId, teamId),
eq(members.userId, request.user.sub),
eq(members.status, "active"),
),
)
.limit(1);
if (!membership) {
return reply.code(404).send({ error: "Team not found" });
}
const [room] = await db
.select({ matrixRoomId: chatRooms.matrixRoomId })
.from(chatRooms)
.where(and(eq(chatRooms.teamId, teamId), eq(chatRooms.scope, "team")))
.limit(1);
if (!room) {
// Room creation job has not run yet — UI shows "Raum wird eingerichtet".
return reply.code(409).send({ error: "room_not_provisioned" });
}
return reply.send({ matrixRoomId: room.matrixRoomId });
},
);
}
+1 -1
View File
@@ -44,7 +44,7 @@ async function provisionMatrixUser(
memberId: string,
): Promise<void> {
const password = randomBytes(24).toString("hex");
const displayName = `${firstName} ${lastName}`;
const displayName = `${firstName} ${lastName}`.trim();
for (let attempt = 0; attempt < 10; attempt++) {
const localpart = toLocalpart();
+28 -2
View File
@@ -56,14 +56,40 @@ export async function teamRoutes(app: FastifyInstance) {
createdAt: teams.createdAt,
// Exposed so the frontend can run one app-level Matrix client for
// unread-badge tiles instead of minting a client per opened team (BUG-4).
// BUG-14: filtered on scope='team' so a stray department-scope or
// duplicate room for the same team can't produce a second joined row.
matrixRoomId: chatRooms.matrixRoomId,
})
.from(teams)
.leftJoin(chatRooms, eq(chatRooms.teamId, teams.id))
.leftJoin(chatRooms, and(eq(chatRooms.teamId, teams.id), eq(chatRooms.scope, "team")))
.where(eq(teams.organizationId, organizationId))
.orderBy(teams.name);
return { teams: rows };
// 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.
const [requesterMember] = await db
.select({ id: members.id })
.from(members)
.where(and(eq(members.userId, request.user.sub), eq(members.status, "active")))
.limit(1);
const ownTeamIds = requesterMember
? new Set(
(
await db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.memberId, requesterMember.id))
).map((r) => r.teamId),
)
: new Set<string>();
return {
teams: rows.map((row) => ({
...row,
matrixRoomId: ownTeamIds.has(row.id) ? row.matrixRoomId : null,
})),
};
});
app.post("/api/teams", { preHandler: app.authenticate }, async (request, reply) => {
@@ -0,0 +1,32 @@
import "dotenv/config";
import { db } from "../db/index.js";
import { chatRooms } from "../db/schema.js";
import { setRoomHistoryVisibility } from "../services/synapse.js";
/**
* BUG-6 one-off backfill: createRoom() now sets history_visibility=shared at
* creation time, but rooms created before that change don't have it. Run once
* per environment after deploying the fix:
*
* npx tsx src/scripts/backfill-history-visibility.ts
*/
async function main() {
const rooms = await db.select({ matrixRoomId: chatRooms.matrixRoomId }).from(chatRooms);
console.log(`Backfilling history_visibility on ${rooms.length} room(s)...`);
for (const room of rooms) {
try {
await setRoomHistoryVisibility(room.matrixRoomId);
console.log(` ok: ${room.matrixRoomId}`);
} catch (err) {
console.error(` failed: ${room.matrixRoomId}`, err);
}
}
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});
+10 -1
View File
@@ -1,6 +1,7 @@
import "dotenv/config";
import Fastify, { type FastifyRequest, type FastifyReply, type FastifyError } from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import jwt from "@fastify/jwt";
import rateLimit from "@fastify/rate-limit";
import { healthRoutes } from "./routes/health.js";
@@ -20,7 +21,15 @@ import { startSyncWorker, startReconciliationWorker } from "./services/sync-work
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
// BUG-21: `origin: true` reflected every Origin header back — verified live to
// accept e.g. https://evil.example.com, which is unnecessary exposure for a
// route (POST /api/chat/token) that hands out a Matrix access token. Only
// TalkTime's own frontend origins may call the API now.
const allowedOrigins = [process.env.APP_BASE_URL, "http://localhost:5173"].filter(
(origin): origin is string => Boolean(origin),
);
await app.register(helmet);
await app.register(cors, { origin: allowedOrigins });
await app.register(rateLimit, { max: 100, timeWindow: "1 minute" });
await app.register(jwt, { secret: process.env.JWT_SECRET! });
@@ -64,6 +64,11 @@ export async function getMatrixAccessToken(matrixUserId: string): Promise<Cached
pending = loginAsUser(matrixUserId, expiresAt)
.then(({ accessToken }) => {
const entry: CacheEntry = { accessToken, expiresAt };
// BUG-9: Map.set() on an existing key keeps its original insertion
// position, so prune()'s "oldest first" eviction would preferentially
// drop the cache's most frequently active users. Delete first so a
// refresh moves the entry to the end like a real LRU.
cache.delete(matrixUserId);
cache.set(matrixUserId, entry);
return entry;
})
+18
View File
@@ -99,6 +99,13 @@ export async function deactivateUser(userId: string, erase = false): Promise<voi
});
}
/** BUG-10: invalidates every access token Synapse has issued for this user, without deactivating the account. */
export async function logoutAllSessions(userId: string): Promise<void> {
await synapseFetch(`/_synapse/admin/v1/users/${encodeURIComponent(userId)}/logout_all`, {
method: "POST",
});
}
export async function reactivateUser(userId: string, newPassword: string): Promise<void> {
await synapseFetch(`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, {
method: "PUT",
@@ -159,6 +166,17 @@ export async function createRoom(params: {
return { roomId: result.room_id };
}
/** BUG-6: used by the one-off backfill script for rooms created before createRoom() set this at creation time. */
export async function setRoomHistoryVisibility(roomId: string): Promise<void> {
await synapseFetch(
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.history_visibility`,
{
method: "PUT",
body: JSON.stringify({ history_visibility: "shared" }),
},
);
}
export async function addUserToRoom(roomId: string, userId: string): Promise<void> {
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
method: "POST",
+1 -1
View File
@@ -42,7 +42,7 @@ const handlers: {
}
const password = randomUUID().replace(/-/g, "");
const displayName = `${firstName} ${lastName}`;
const displayName = `${firstName} ${lastName}`.trim();
for (let attempt = 0; attempt < 10; attempt++) {
const localpart = toLocalpart();
+35
View File
@@ -659,3 +659,38 @@ Live verifiziert: neuer Vereinsersteller bekommt `@u747fcd631af6db20:matrix.perl
**Empfohlene Fix-Reihenfolge:** BUG-18 → BUG-19 → BUG-12 → BUG-11 → BUG-20 (Architekturentscheidung: Raum-Erstellung sperren und/oder ein Synapse pro Verein) → BUG-1 (committen) → BUG-13/14 → BUG-5/15 → BUG-6/21 → Rest.
**Nach den Fixes zwingend nachzuholen:** der bislang in allen drei Runden ausgefallene **Cross-Browser- und Responsive-Test (Chrome/Firefox/Safari; 375/768/1440 px)** inklusive XSS-Gegenprobe im echten Browser-Rendering.
## Vollständiger Bugfix-Durchlauf (2026-09-11) — restliche Bugs
Auf Nutzerwunsch alle verbliebenen offenen Bugs durchgegangen. Alle Fixes end-to-end auf `talkhub`/`matrix` verifiziert (neuer Verein → Abteilung/Saison/Team → Team-Mitgliedschaft → Chat-Token → Raum → Nachricht senden), Backend- und Frontend-Typecheck sowie Production-Build durchgehend grün, Testdaten aufgeräumt.
**BUG-21 (Medium) — behoben, live verifiziert.** `@fastify/helmet` registriert, CORS von `origin: true` auf eine Allowlist (`APP_BASE_URL` + `localhost:5173` für Dev) umgestellt. Live: eigene Origin bekommt `access-control-allow-origin` + alle Security-Header (HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`); eine fremde Origin bekommt keinen `access-control-allow-origin`-Header mehr zurück.
**BUG-13 (Medium) — behoben, live verifiziert.** `GET /api/teams` liefert `matrixRoomId` jetzt nur noch für Teams, in denen der Anfragende aktives Mitglied ist (sonst `null`) — vorher sah jedes Vereinsmitglied die Raum-IDs aller Teams. Live bestätigt: Org-Ersteller ohne Team-Mitgliedschaft sieht `matrixRoomId: null` und bekommt von `GET /api/chat/room` ein `404`; nach Hinzufügen als Team-Mitglied erscheint die korrekte Raum-ID.
**BUG-14 (Medium) — behoben.** `leftJoin` in `GET /api/teams` filtert jetzt zusätzlich auf `scope = 'team'`. Zusätzlich neuer partieller Unique-Index `chat_rooms_team_id_team_scope_unique` (`chat_rooms.team_id` wo `scope = 'team'`) in der Datenbank — verhindert das zugrunde liegende Problem (zwei Räume pro Team) strukturell, nicht nur kosmetisch in der Abfrage. Migration `0002_melted_imperial_guard.sql` erstellt und live angewendet.
**BUG-9 (Low, alle vier Teilpunkte) — behoben.**
- Route-Rate-Limit für `GET /api/chat/room` ergänzt (30/min).
- `GET /api/chat/room` liefert jetzt einheitlich `404` sowohl für nicht-existente Teams als auch für Teams, denen der Anfragende nicht angehört (vorher 404 vs. 403 — Existenz-Orakel).
- `matrix-token-cache.ts`: `cache.delete()` vor `cache.set()` beim Refresh, damit die Map-Reihenfolge (für die Verdrängung) aktive Nutzer nicht mehr bevorzugt entfernt.
- `frontend/eslint.config.js` neu angelegt (Flat Config, ESLint 9) — `npm run lint` lief zuvor gar nicht. Zusätzlich `MessageList.tsx`: Scroll-Position bleibt beim Nachladen älterer Nachrichten erhalten (vorher: Sprung an unvorhersehbare Stelle).
**BUG-6 (Medium) — behoben, live verifiziert.** Neues One-off-Script `backend/src/scripts/backfill-history-visibility.ts` (`npx tsx src/scripts/backfill-history-visibility.ts`) setzt `history_visibility: shared` auf alle bestehenden Räume nach. Auf `talkhub`/`matrix` ausgeführt — der eine bestehende Bestandsraum ist jetzt nachgezogen.
**BUG-5 (Medium) — behoben.** `use-team-chat.ts`: Setup-Poll bei `ChatSetupPendingError` nutzt jetzt exponentielles Backoff (5s → max. 60s) statt fest 15s. Bei `SyncState.Error` wird der Client jetzt gestoppt (`teardownClient()`) und mit Backoff neu verbunden, statt dass matrix-js-sdk im Hintergrund endlos gegen Synapse weiterretryt.
**BUG-15 (Medium) — gemildert.** `lib/matrix.ts`: kleiner modul-scope Credential-Cache — beide Hooks (`useTeamChat`, `useTeamUnreadCounts`) teilen sich jetzt einen gefetchten Token statt unabhängig `POST /api/chat/token` aufzurufen, solange er noch mindestens 60s gültig ist. Reduziert die Token-Endpunkt-Last spürbar, verhindert das 10/min-Limit aber nicht vollständig bei sehr schnellem Team-Wechsel (kein In-Flight-Dedup zwischen beiden Hooks) — daher „gemildert", nicht „behoben".
**BUG-16 (Low) — behoben.** `use-team-unread-counts.ts`: `refreshCounts()` prüft jetzt `cancelled`, bevor `setCounts` aufgerufen wird.
**BUG-17 (Low) — behoben in `use-team-chat.ts`, bewusst nicht in `use-team-unread-counts.ts`.** Ersteres nutzt jetzt `getRoomUnreadNotificationCount(NotificationCountType.Total)` statt der deprecated `getUnreadNotificationCount()`. Im Unread-Counts-Hook bewusst bei der alten API belassen — `NotificationCountType` ist ein Laufzeit-Enum, dessen Import den Bundle-Regression-Fix aus BUG-11 wieder aufheben würde. Mit Kommentar im Code begründet.
**BUG-10 (Low) — behoben.** Neuer Endpunkt `POST /api/chat/logout` invalidiert den Server-Cache-Eintrag **und** ruft Synapses `logout_all`-Admin-Endpunkt auf (widerruft alle Zugriffstokens des Nutzers, ohne den Account zu deaktivieren). `AuthProvider.logout()` im Frontend ruft das best-effort auf und leert zusätzlich den neuen Frontend-Credential-Cache (BUG-15).
**BUG-22 (Low) — teilweise behoben.** `.trim()` auf die `displayName`-Konstruktion in `invitations.ts` und `sync-worker.ts` ergänzt (kein Leerzeichen mehr am Ende bei leerem Nachnamen). Der ursprünglich gemeldete Localpart-Trailing-Punkt (`@qaa18686.`) ist durch den BUG-20-Fix ohnehin gegenstandslos geworden — Localparts sind jetzt zufällig, nicht mehr namensbasiert.
### Verbleibend offen
Keine der ursprünglich gemeldeten Bugs (BUG-1 bis BUG-22) ist mehr unbearbeitet. Bewusst nur gemildert statt vollständig gelöst: **BUG-11** (SDK lädt weiterhin für jeden Besucher der Team-Liste mit mind. einem Team-Raum, nur zeitlich verschoben — echte Lösung bräuchte serverseitig aggregierte Zähler) und **BUG-15** (Token-Sharing reduziert, aber kein vollständiger Schutz vor Rate-Limit bei sehr schnellem Klicken). Beide sind als offene Architekturfragen dokumentiert. Nicht durchführbar in dieser Umgebung: Cross-Browser/Responsive-Test (kein Browser verfügbar) — bleibt für einen manuellen Testdurchlauf offen.
Empfehlung: `/qa` erneut laufen lassen für eine finale Bestätigungsrunde, idealerweise mit echtem Browser-Zugriff für den bislang ausstehenden Cross-Browser/Responsive-Test.
+28
View File
@@ -0,0 +1,28 @@
// BUG-9: eslint.config.js was missing entirely, so `npm run lint` failed
// outright under ESLint 9 (flat config) and PROJ-11 shipped unlinted.
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
},
},
);
+376 -3
View File
@@ -44,15 +44,20 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.0.1",
"eslint": "^9",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.15.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"typescript-eslint": "^8.70.0",
"vite": "^6.0.7"
}
},
@@ -923,6 +928,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -2887,6 +2905,301 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
"integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/type-utils": "8.70.0",
"@typescript-eslint/utils": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.70.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
"integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz",
"integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz",
"integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.70.0",
"@typescript-eslint/types": "^8.70.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz",
"integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz",
"integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz",
"integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/utils": "8.70.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
"integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz",
"integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.70.0",
"@typescript-eslint/tsconfig-utils": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/visitor-keys": "8.70.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz",
"integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/types": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz",
"integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.70.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3587,6 +3900,29 @@
}
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
"integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"eslint": ">=8.40"
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@@ -3929,9 +4265,9 @@
}
},
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5350,6 +5686,19 @@
"node": ">=8.0"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -5390,6 +5739,30 @@
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz",
"integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.70.0",
"@typescript-eslint/parser": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/utils": "8.70.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+5
View File
@@ -46,15 +46,20 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.0.1",
"eslint": "^9",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.15.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"typescript-eslint": "^8.70.0",
"vite": "^6.0.7"
}
}
+31 -3
View File
@@ -45,12 +45,40 @@ export function MessageList({
onLoadMore,
}: MessageListProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
// BUG-9: "Ältere Nachrichten laden" used to jump the view to a random spot
// because prepending messages shifts everything below down without any
// compensating scroll adjustment. Capture the scroll anchor right before a
// load-more, then restore the reader's position relative to it afterwards.
const restoreScrollRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
const groups = useMemo(() => groupByDay(messages), [messages]);
const lastId = messages[messages.length - 1]?.id;
function getViewport(): HTMLDivElement | null {
return rootRef.current?.querySelector<HTMLDivElement>("[data-radix-scroll-area-viewport]") ?? null;
}
function handleLoadMore() {
const viewport = getViewport();
if (viewport) {
restoreScrollRef.current = { scrollTop: viewport.scrollTop, scrollHeight: viewport.scrollHeight };
}
onLoadMore();
}
useEffect(() => {
const pending = restoreScrollRef.current;
if (pending) {
const viewport = getViewport();
if (viewport) {
const delta = viewport.scrollHeight - pending.scrollHeight;
viewport.scrollTop = pending.scrollTop + delta;
}
restoreScrollRef.current = null;
return;
}
bottomRef.current?.scrollIntoView({ block: "end" });
}, [lastId]);
}, [lastId, messages.length]);
if (loading) {
return (
@@ -73,11 +101,11 @@ export function MessageList({
}
return (
<ScrollArea className="h-full">
<ScrollArea className="h-full" ref={rootRef}>
<div className="space-y-4 p-4" role="log" aria-live="polite" aria-label="Chatverlauf">
{canLoadMore && (
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
<Button variant="outline" size="sm" onClick={handleLoadMore} disabled={loadingMore}>
{loadingMore ? "Lädt…" : "Ältere Nachrichten laden"}
</Button>
</div>
+31 -10
View File
@@ -3,6 +3,7 @@ import {
ClientEvent,
Direction,
EventType,
NotificationCountType,
RoomEvent,
SyncState,
type MatrixClient,
@@ -45,8 +46,8 @@ interface UseTeamChatResult {
retry: () => void;
}
const POLL_INTERVAL_MS = 15_000;
const PAGE_SIZE = 30;
const MAX_POLL_DELAY_MS = 60_000;
function readReceiptsFor(room: Room, eventId: string, ownUserId: string): string[] {
const event = room.findEventById(eventId);
@@ -96,7 +97,8 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
const userId = clientRef.current?.getUserId();
if (!room || !userId) return;
setMessages(toMessages(room, userId));
setUnreadCount(room.getUnreadNotificationCount() ?? 0);
// BUG-17: getUnreadNotificationCount() is deprecated in matrix-js-sdk.
setUnreadCount(room.getRoomUnreadNotificationCount(NotificationCountType.Total) ?? 0);
setCanLoadMore(Boolean(room.getLiveTimeline().getPaginationToken(Direction.Backward)));
}, []);
@@ -109,9 +111,20 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
let cancelled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let client: MatrixClient | null = null;
let pollAttempt = 0;
const onTimeline = () => !cancelled && refresh();
function teardownClient() {
if (client) {
client.removeAllListeners();
client.stopClient();
client = null;
}
clientRef.current = null;
roomRef.current = null;
}
async function connect() {
setStatus("loading");
setError(null);
@@ -153,10 +166,19 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
}
roomRef.current = room;
setStatus("ready");
pollAttempt = 0;
refresh();
} else if (state === SyncState.Error) {
// BUG-5: this used to just set status="error" while matrix-js-sdk kept
// retrying /sync against Synapse in the background forever — exactly
// the "Retry-Loop, der Synapse flutet" the edge case rules out. Stop
// the client and reconnect ourselves with backoff instead.
setStatus("error");
setError("Verbindung zum Chat-Server fehlgeschlagen.");
teardownClient();
const delay = Math.min(1000 * 2 ** pollAttempt, MAX_POLL_DELAY_MS);
pollAttempt += 1;
pollTimer = setTimeout(() => void connect(), delay);
}
});
@@ -166,8 +188,12 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
if (err instanceof ChatSetupPendingError) {
setStatus("pending");
setPendingReason(err.reason);
// Poll instead of erroring until provisioning caught up.
pollTimer = setTimeout(() => void connect(), POLL_INTERVAL_MS);
// BUG-5: poll instead of erroring until provisioning catches up, but
// with backoff — this used to fire a fixed 15s regardless of how long
// it had already been pending, adding unnecessary token-endpoint load.
const delay = Math.min(5000 * 2 ** pollAttempt, MAX_POLL_DELAY_MS);
pollAttempt += 1;
pollTimer = setTimeout(() => void connect(), delay);
return;
}
setStatus("error");
@@ -180,12 +206,7 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
if (client) {
client.removeAllListeners();
client.stopClient();
}
clientRef.current = null;
roomRef.current = null;
teardownClient();
};
}, [authToken, teamId, attempt, refresh]);
+10 -1
View File
@@ -62,10 +62,19 @@ export function useTeamUnreadCounts(
let reconnectAttempt = 0;
function refreshCounts() {
if (!client) return;
// BUG-16: without this guard, a Room.timeline/receipt event already in
// flight when the effect tears down (unmount, or roomIds/authToken
// change) could still call setCounts after cleanup.
if (cancelled || !client) return;
const next: Record<string, number> = {};
for (const [roomId, teamId] of roomIdToTeamId.current) {
const room = client.getRoom(roomId);
// BUG-17: getUnreadNotificationCount() is deprecated in favor of
// getRoomUnreadNotificationCount(NotificationCountType.Total) — kept
// here deliberately, since importing NotificationCountType (a runtime
// enum, not a type) would reintroduce the BUG-11 bundle regression this
// file otherwise avoids. use-team-chat.ts uses the new API since it
// already imports matrix-js-sdk enums statically either way.
if (room) next[teamId] = room.getUnreadNotificationCount() ?? 0;
}
setCounts(next);
+12 -1
View File
@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { clearCachedChatCredentials } from "@/lib/matrix";
interface AuthUser {
id: string;
@@ -58,9 +59,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
const logout = useCallback(() => {
// BUG-10: revoke the Matrix session on logout instead of leaving the cached
// token valid for up to its full 1h TTL. Best-effort — logout must not get
// stuck waiting on this, and the token still expires on its own either way.
if (token) {
void fetch("/api/chat/logout", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => undefined);
}
clearCachedChatCredentials();
setTokenState(null);
setUser(null);
}, []);
}, [token]);
const value = useMemo(
() => ({ token, user, login, setToken: applyToken, logout }),
+20 -1
View File
@@ -25,11 +25,23 @@ async function readError(res: Response): Promise<string> {
}
}
// BUG-15: useTeamChat and useTeamUnreadCounts each independently minted their
// own token (plus the 15s setup poll from BUG-5), which could burn through
// 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;
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,
options: { refresh?: boolean } = {},
): Promise<ChatCredentials> {
if (!options.refresh && cachedCredentials && cachedCredentials.expiresAt - Date.now() > CREDENTIAL_REFRESH_MARGIN_MS) {
return cachedCredentials.creds;
}
const res = await fetch("/api/chat/token", {
method: "POST",
headers: {
@@ -46,7 +58,14 @@ export async function fetchChatCredentials(
if (!res.ok) {
throw new Error(await readError(res));
}
return (await res.json()) as ChatCredentials;
const creds = (await res.json()) as ChatCredentials;
cachedCredentials = { 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;
}
/** Resolves the Matrix room id of a team. */