Commit Graph
18 Commits
Author SHA1 Message Date
patrickandClaude Sonnet 5 4b03ca6c3f feat(PROJ-14): frontend for Synapse room overview
/system-admin/rooms — table (name/alias, members, TalkTime assignment,
created date, delete), search, pagination, delete confirmation dialog
that warns extra hard when the room is still bound to a live team.
SystemAdminRoute gates on user.isSystemAdmin (nav-level only, the
backend routes are the real enforcement).

Also corrected PROJ-13's status to Deployed — it was stuck at
"In Review" despite being live-verified end-to-end already.

Verified: /system-admin/rooms serves the SPA shell (200).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-12 21:10:44 +02:00
patrickandClaude Sonnet 5 0f15432045 feat(PROJ-13): add SYSTEM_ADMIN platform-wide role
users.isSystemAdmin (boolean, default false), DB-only to grant (no UI
in MVP scope, per spec). requesterIsSystemAdmin()/requireSystemAdmin()
in authz.ts — checked fresh from the DB on every request, not cached
in the JWT, so revoking takes effect immediately. GET /api/auth/me
returns the flag; DashboardPage shows a "System-Admin" nav button when
set (convenience only, not the access control — PROJ-14/15/16/18's
own routes enforce it).

/system-admin route doesn't exist yet (comes with PROJ-14) — the nav
button currently bounces back to / via the catch-all route, no stub
page added.

Verified end-to-end on talkhub: /api/auth/me flips true immediately
after a direct DB update, no re-login required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-12 20:59:04 +02:00
patrickandClaude Sonnet 5 0359121919 feat: rename TalkTime Dashboard/Login to Talkhub in UI
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-12 20:51:58 +02:00
patrickandClaude Sonnet 5 b2d9bf5f15 feat: admin can set a member's password directly, no email required
PUT /api/members/:memberId/password — admin-only, hashes and sets the
password immediately instead of going through the email reset-link
flow. Useful in this deployment where SMTP isn't configured yet, and
more generally whenever the member needs to log in right away.

Frontend: "Passwort direkt setzen" dialog in the members list dropdown,
alongside the existing "Passwort-Mail senden" (renamed for clarity now
that there are two options).

Verified end-to-end: new password logs in, old password is rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-12 00:16:19 +02:00
patrickandClaude Sonnet 5 f9451881c4 fix: invitation accept sent empty password string for existing users
AcceptInvitationPage's form always initializes password to "" via
react-hook-form defaultValues, even when the invited email already has
an account and the password field is hidden (requiresPassword=false).
The empty string was sent as-is instead of omitted, tripping the
backend's password.min(8).optional() — which only treats an absent key
as optional, not an empty string — and surfacing Zod's raw untranslated
message ("Too small: expected string to have >=8 characters") instead
of a friendly one.

Frontend: omit the password key entirely from the payload when
requiresPassword is false. Backend: defense in depth — treat an empty
string the same as omitted via a transform, and give the min(8) check
an actual German message for if this path is ever hit another way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-12 00:10:45 +02:00
patrickandClaude Sonnet 5 ad6efc4afa 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
2026-09-12 00:05:49 +02:00
patrickandClaude Sonnet 5 5c2e09b36d feat: admin-triggered password reset for members
Org admins can now trigger a password reset for a member from the
members list dropdown, without ever seeing or setting the password
themselves — POST /api/members/:memberId/reset-password sends the
same reset-link email the self-service /forgot-password flow uses.

Extracted the shared token-generation + email logic into
lib/password-reset.ts so both flows use one implementation. Returns
409 member_has_no_account for members who never accepted their
invitation (no linked login account to reset).

Verified end-to-end on talkhub: admin trigger sends the mail (SMTP
not configured in this test env, confirmed via the mail service's
own log fallback), non-admin gets 403.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 23:52:17 +02:00
patrickandClaude Sonnet 5 dc1ba9ae27 fix(PROJ-11): BUG-23 (real fix, second attempt) and BUG-25 — QA round 4 gate
BUG-25: DashboardPage header ran ~200px past the viewport at 375px,
"Abmelden" unreachable without horizontal scroll. flex-col/flex-wrap
so it stacks on narrow screens instead of overflowing.

BUG-23: the previous fix (logout_all admin endpoint) never worked —
that route doesn't exist on Synapse (404, swallowed by try/catch).
A second attempt (list + bulk-delete devices) also did nothing,
because loginAsUser()'s admin puppet-login issues a deviceless token
that never appears in the devices list. Live-tested both failures via
account/whoami with the "revoked" token still returning 200.

Actual fix: revokeAccessToken() calls the plain Client-Server /logout
endpoint authenticated as the token being revoked, not as the backend's
admin token — the only mechanism that invalidates a deviceless token.
invalidateMatrixAccessToken() now returns the token it removed so the
route can pass it along. Verified: whoami returns 200 before logout,
401 M_UNKNOWN_TOKEN with the same token right after.

QA round 4 (first with real cross-browser access — Chromium/Firefox/
WebKit at 375/768/1440px) found no remaining Critical or functional
High findings once these two land: production-ready.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 23:38:33 +02:00
patrickandClaude Sonnet 5 edc05d4d1e 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
2026-09-11 23:10:37 +02:00
patrickandClaude Sonnet 5 121d80e707 fix(PROJ-11): address QA round-3 findings — BUG-18/19 fixed, BUG-11/12 improved
BUG-18 (Critical) — invitations.ts never joined the Matrix room for
members invited with a teamId (only teams.ts's add-member path did).
Standard onboarding flow left every invited team member stuck on
"Raum wird eingerichtet" forever, with no self-healing (reconciliation
only checks user provisioning, never room membership) and no repair
path (blocked by BUG-19). Now mirrors teams.ts: inline addUserToRoom
with an enqueueSyncJob("join_team_room") fallback.

BUG-19 (High) — no global Fastify error handler meant an uncaught
error (e.g. a duplicate-key insert) echoed back the raw Drizzle error,
including the full SQL statement and bound parameters, to the client.
Added app.setErrorHandler(): 5xx is logged server-side and replaced
with a generic message, 4xx keeps its already-safe original message.

BUG-12 (High) — use-team-unread-counts.ts had no SyncState.Error
handling, so an expired token meant matrix-js-sdk retried /sync
against Synapse forever with a dead token while badges silently
froze. Now stops the client and reconnects with a fresh token,
capped exponential backoff.

BUG-11 (High, partially mitigated) — deferred the app-level unread
client's start to requestIdleCallback instead of firing synchronously
on TeamsPage mount, taking it off the critical path. Does NOT fix the
underlying complaint (SDK still loads for anyone who visits the team
list, whether or not they open chat) — documented as an open
architecture question rather than declared fixed.

All four fixes verified end-to-end against the live talkhub/matrix
TLS deployment (invitation accept -> joined_rooms; duplicate insert ->
generic 500). Test data from QA round 3 and this verification pass
cleaned up (orgs, users, Matrix accounts erased, rooms purged).

Not addressed: BUG-20 (predictable Matrix IDs undermine the BUG-2
user_directory mitigation), BUG-5/6/7/9/10/13/14/15/16/17/21/22.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 22:51:42 +02:00
patrickandClaude Sonnet 5 8f6e05481b fix(PROJ-11): address QA BUG-2/3/4, partially mitigate BUG-1
BUG-4 (High, failed acceptance criterion) — per-tile unread badges:
new use-team-unread-counts.ts runs one app-level Matrix client (separate
from useTeamChat's detail-view client) that syncs every room GET /api/teams
now resolves (matrixRoomId added via leftJoin on chat_rooms) and reports
unread counts per team. TeamsPage tiles show the badge without the chat
tab being open. Side fix: createMatrixClient() now dynamic-imports
matrix-js-sdk instead of a static import, so the new always-mounted hook
doesn't drag the SDK (and its 7.8MB crypto WASM) back into TeamsPage's
main bundle — verified via build output (TeamDetail chunk 853KB -> 27KB,
SDK now in its own on-demand chunk).

BUG-3 (High) — kick-while-open: use-team-chat.ts now listens for
RoomEvent.MyMembership; leaving/removal flips status to a new "removed"
state instead of leaving history and the composer usable.

BUG-2 (High, partially mitigated) — minted tokens are unscoped Matrix
credentials by nature (Matrix has no token scoping). Disabled
user_directory search on Synapse (config + live matrix.perlbach24.de
instance), verified empty results instead of cross-club user listing.
Direct addressing by known Matrix ID is still possible — full tenant
isolation needs an explicit decision (one Synapse per club vs. an
isolation module on a shared homeserver), documented as an open item
in the spec rather than silently declared fixed.

BUG-1 (Critical, not fixed) — backend/.env.example is outside my
permission sandbox (can't read or write .env* files). Documented the
required block in the spec for manual addition; talkhub's real .env
already has the values.

Verified end-to-end on talkhub/matrix test LXCs. Not addressed in this
pass (lower priority per QA): BUG-5/6/7/8/9/10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 20:21:07 +02:00
patrickandClaude Sonnet 5 7fa658b4ff feat(PROJ-11): build team chat via direct browser-to-Synapse connection
Frontend: matrix-js-sdk client per opened team, /sync against Synapse
directly (no chat traffic proxied through the backend), timeline with
day-grouping and read receipts, history backfill, lazy-loaded chat tab
in TeamsPage's team detail view (keeps matrix-js-sdk out of the initial
bundle).

Backend: POST /api/chat/token mints/caches a short-lived per-member
Matrix access token via Synapse's admin puppet-login (1h TTL, in-memory
cache with 5min pre-expiry refresh, concurrent-request dedup, audited
on real mints only); GET /api/chat/room resolves a team's Matrix room.
Cached tokens are invalidated immediately on member suspend/leave.

Fixes found while wiring this up:
- createRoom() now explicitly sets history_visibility=shared (PROJ-9)
  instead of relying on the private_chat preset's implicit default.
- POST /api/organizations never provisioned a Matrix account for the
  org creator (only the invitation-accept path did) — they'd have
  seen "Chat wird eingerichtet" forever. Now enqueues the same
  provision_matrix_user job invitations use.

Open: SYNAPSE_PUBLIC_BASE_URL needs documenting in backend/.env.example
(blocked by local permission rules on that file — added manually on
the talkhub test host instead), per-tile unread counts need an
app-level Matrix client instead of one per opened team, and Synapse
needs TLS + public reachability before this works outside a LAN.

Verified end-to-end on the talkhub/matrix test LXCs: registered a
throwaway account, created org → department → season → team, joined
the team, minted a token, sent a message directly to Synapse, read it
back from room history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 19:09:24 +02:00
patrickandClaude Sonnet 5 bc4d74995f feat: add member hard-delete and password reset flow
- DELETE /api/members/:memberId — admin-only, erases the Matrix account,
  audit-logs before deleting (audit_logs.targetId is unconstrained text so
  the entry survives), relies on existing cascade FKs for team/role cleanup.
  Frontend: confirm dialog in the members table dropdown.
- POST /api/auth/forgot-password + /api/auth/reset-password/:token —
  token-based reset via the existing mail.ts SMTP client, generic response
  regardless of whether the email is registered (no enumeration leak),
  60min token TTL, single-use. Frontend: forgot/reset password pages,
  link added to the login form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 18:42:47 +02:00
patrickandClaude Sonnet 5 1c013b301c feat: generalize UI wording — Verein→Organisation, Mannschaft→Team
Makes the German UI labels domain-neutral (Verein/Firma both fit "Organisation"),
Mannschaft was pure sports vocabulary and is replaced with Team.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
2026-09-11 18:35:54 +02:00
patrickandClaude Sonnet 5 b5ac041c32 feat: add Docker Compose deployment for backend/frontend/postgres
Backend Dockerfile runs drizzle-kit migrate on start, frontend serves the
built Vite app via nginx and proxies /api to the backend container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBf3yfnnHzMQ5qhLV2285A
2026-09-11 15:28:01 +02:00
patrickandClaude Sonnet 5 b38a5a4a57 feat: member status actions, roles/audit-log UI, member deactivation sync
- PATCH /api/members/:id/status (suspend/reactivate/leave) with Matrix deactivation
- Reconciliation worker comparing local member status against Synapse state
- Roles page (assign/revoke) and audit log page on the frontend
- Dashboard tiles for departments/teams now link to their pages
- Reference bashclub/zamba-lxc-toolbox as a Proxmox-LXC autoinstaller option in Synapse docs

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBf3yfnnHzMQ5qhLV2285A
2026-09-11 15:13:23 +02:00
patrickandClaude Sonnet 5 5e6ee2c456 feat: build TalkTime MVP backend/frontend, rename from VereinsChat
- Generic org/team/member data model (renamed from club-specific naming)
- Auth, organizations, invitations with mail + Matrix provisioning
- Teams with automatic Matrix room creation, sync-job retry, reconciliation
- Departments, seasons, roles (RBAC with privilege-escalation guard), audit log
- Dashboard, members list, org switcher on the frontend
- Synapse setup docs (Docker + bare-metal) and generalized PRD/architecture docs

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBf3yfnnHzMQ5qhLV2285A
2026-09-11 15:07:47 +02:00
patrickandClaude Sonnet 5 4a19335a1e feat: rework starter kit for self-hosted deploy (Vite/React + Fastify/Drizzle/Postgres)
Replace Next.js/Supabase/Vercel stack with a self-hostable setup: Vite+React
frontend, Fastify+Drizzle+PostgreSQL backend with own JWT auth, deploy via
git pull + rsync + systemd (checkout/service separated, updates pulled from
Gitea). Adds a reference auth route (register/login/me) as the canonical
pattern for new routes. Updates all skills/rules/agents/docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMdBJfD9TT6XUU2NqGG1PN
2026-09-08 11:42:47 +02:00