diff --git a/backend/src/lib/password-reset.ts b/backend/src/lib/password-reset.ts new file mode 100644 index 0000000..4e11290 --- /dev/null +++ b/backend/src/lib/password-reset.ts @@ -0,0 +1,22 @@ +import { randomBytes } from "node:crypto"; +import { db } from "../db/index.js"; +import { passwordResetTokens } from "../db/schema.js"; +import { sendMail } from "../services/mail.js"; + +const appBaseUrl = process.env.APP_BASE_URL ?? "http://localhost:5173"; +const RESET_TOKEN_TTL_MINUTES = 60; + +/** Shared by the self-service forgot-password flow and the admin "reset password" action on a member. */ +export async function sendPasswordResetEmail(userId: string, email: string): Promise { + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000); + await db.insert(passwordResetTokens).values({ userId, token, expiresAt }); + + const resetLink = `${appBaseUrl}/reset-password/${token}`; + await sendMail({ + to: email, + subject: "Passwort zurücksetzen — TalkTime", + text: `Passwort zurücksetzen: ${resetLink}\n\nLink gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.`, + html: `

Passwort zurücksetzen

Link gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.

`, + }); +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 8050121..d649b01 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,20 +1,16 @@ -import { randomBytes } from "node:crypto"; import type { FastifyInstance } from "fastify"; import bcrypt from "bcryptjs"; import { z } from "zod"; import { and, eq, isNull } from "drizzle-orm"; import { db } from "../db/index.js"; import { users, passwordResetTokens } from "../db/schema.js"; -import { sendMail } from "../services/mail.js"; +import { sendPasswordResetEmail } from "../lib/password-reset.js"; const credentialsSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); -const appBaseUrl = process.env.APP_BASE_URL ?? "http://localhost:5173"; -const RESET_TOKEN_TTL_MINUTES = 60; - const forgotPasswordSchema = z.object({ email: z.string().email(), }); @@ -84,17 +80,7 @@ export async function authRoutes(app: FastifyInstance) { // Always return the same generic response — never reveal whether the email is registered. const [user] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1); if (user) { - const token = randomBytes(32).toString("hex"); - const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000); - await db.insert(passwordResetTokens).values({ userId: user.id, token, expiresAt }); - - const resetLink = `${appBaseUrl}/reset-password/${token}`; - await sendMail({ - to: email, - subject: "Passwort zurücksetzen — TalkTime", - text: `Passwort zurücksetzen: ${resetLink}\n\nLink gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.`, - html: `

Passwort zurücksetzen

Link gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.

`, - }); + await sendPasswordResetEmail(user.id, email); } return reply.send({ message: "Falls die E-Mail-Adresse registriert ist, wurde ein Link verschickt." }); diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts index 2a8c0c8..9365a42 100644 --- a/backend/src/routes/members.ts +++ b/backend/src/routes/members.ts @@ -7,6 +7,7 @@ import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../ import { deactivateUser } from "../services/synapse.js"; import { invalidateMatrixAccessToken } from "../services/matrix-token-cache.js"; import { logAudit } from "../lib/audit.js"; +import { sendPasswordResetEmail } from "../lib/password-reset.js"; const listQuerySchema = z.object({ organizationId: z.string().uuid(), @@ -102,6 +103,52 @@ export async function memberRoutes(app: FastifyInstance) { }, ); + // Admin-triggered password reset: sends the member the same reset-link email + // as the self-service /forgot-password flow. The admin never sees or sets + // the new password themselves — only the member does, via the emailed link. + app.post( + "/api/members/:memberId/reset-password", + { preHandler: app.authenticate, config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + const { memberId } = request.params as { memberId: string }; + + const [member] = await db + .select({ + organizationId: members.organizationId, + userId: members.userId, + email: members.email, + }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + if (!member) { + return reply.code(404).send({ error: "Member not found" }); + } + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, member.organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + if (!member.userId) { + // Invited but never accepted — no login account exists yet to reset. + return reply.code(409).send({ error: "member_has_no_account" }); + } + + await sendPasswordResetEmail(member.userId, member.email); + + await logAudit({ + organizationId: member.organizationId, + actorUserId: request.user.sub, + action: "member.password_reset_triggered", + targetType: "member", + targetId: memberId, + }); + + return reply.send({ message: "Reset-Link wurde verschickt." }); + }, + ); + app.delete("/api/members/:memberId", { preHandler: app.authenticate }, async (request, reply) => { const { memberId } = request.params as { memberId: string }; diff --git a/features/PROJ-1-auth.md b/features/PROJ-1-auth.md index f0cd0e6..a0ca458 100644 --- a/features/PROJ-1-auth.md +++ b/features/PROJ-1-auth.md @@ -14,6 +14,7 @@ E-Mail/Passwort-Auth mit JWT-Session für TalkTime. Grundlage für alle geschüt - `frontend/src/lib/auth-context.tsx` — Session-State im Frontend - `frontend/src/routes/ProtectedRoute.tsx` — Redirect bei fehlender Session - **Nachtrag 2026-09-11 (commit bc4d749):** Passwort-Reset — `backend/src/db/schema.ts` (`password_reset_tokens`-Tabelle, 60min TTL, single-use), `POST /api/auth/forgot-password` + `POST /api/auth/reset-password/:token` in `backend/src/routes/auth.ts`, Mail-Versand über bestehenden `services/mail.ts`; Frontend: `ForgotPasswordForm.tsx`/`ResetPasswordForm.tsx`, `ForgotPasswordPage.tsx`/`ResetPasswordPage.tsx`, Link im `LoginForm.tsx` +- **Nachtrag 2026-09-11 (Admin-Trigger):** Reset-Mail-Logik nach `backend/src/lib/password-reset.ts` extrahiert (`sendPasswordResetEmail`), genutzt von `forgot-password` **und** neu von `POST /api/members/:memberId/reset-password` ([[PROJ-3]]) — Admin kann für ein Mitglied einen Reset auslösen, sieht/setzt aber nie selbst das Passwort. Live verifiziert: Admin löst Reset aus (Mail-Log bestätigt Versandversuch), Non-Admin bekommt 403. ## Acceptance Criteria - [x] Registrierung mit E-Mail + Passwort, Passwort gehasht (bcrypt, Cost 12) diff --git a/features/PROJ-3-mitgliederverwaltung.md b/features/PROJ-3-mitgliederverwaltung.md index be27978..267afdc 100644 --- a/features/PROJ-3-mitgliederverwaltung.md +++ b/features/PROJ-3-mitgliederverwaltung.md @@ -11,6 +11,7 @@ Mitglieder eines Vereins verwalten: Liste, Status-Aktionen (aktivieren/sperren/A - `frontend/src/pages/MembersPage.tsx` - Mitglied-Status-Aktionen + Deaktivierungs-Sync (commit b38a5a4) - **Nachtrag 2026-09-11 (commit bc4d749):** `DELETE /api/members/:memberId` — Admin-only, hartes Löschen. Audit-Log-Eintrag wird vor dem Delete geschrieben (`audit_logs.targetId` ist unconstrained text, überlebt das Löschen). Matrix-Account wird per `deactivateUser(matrixUserId, erase=true)` gelöscht. Abhängige Daten (`parent_links`, `team_members`, `org_roles`) laufen über bestehende `onDelete: cascade`-FKs mit. Frontend: Bestätigungsdialog (`AlertDialog`) im Dropdown-Menü der Mitgliederliste. +- **Nachtrag 2026-09-11 (Admin-Passwort-Reset):** `POST /api/members/:memberId/reset-password` — Admin-only, löst dieselbe Reset-Mail wie das Self-Service-`/forgot-password` aus (siehe [[PROJ-1]]), Admin sieht/setzt nie selbst das Passwort. `409 member_has_no_account` falls die Einladung nie angenommen wurde (kein `userId`). Frontend: "Passwort zurücksetzen" im Dropdown-Menü der Mitgliederliste. - **Nachtrag 2026-09-11 (Bugfix, ungetaggter Commit nach bc4d749):** `backend/src/lib/authz.ts` (`findMembership`) und `GET /api/organizations/mine` prüften `members.status` bisher **nicht** — ein Mitglied mit Status `suspended`/`left` behielt vollen API-Zugriff auf die Organisation (Mitgliederliste, Teams, Rollen, Audit-Log) und sah sie weiter im Organisation-Switcher, obwohl nur der Matrix-Account deaktiviert wurde. Beide Stellen filtern jetzt zusätzlich auf `status = "active"`. ## Acceptance Criteria diff --git a/frontend/src/pages/MembersPage.tsx b/frontend/src/pages/MembersPage.tsx index b36e137..8960404 100644 --- a/frontend/src/pages/MembersPage.tsx +++ b/frontend/src/pages/MembersPage.tsx @@ -48,6 +48,7 @@ export function MembersPage() { const { activeOrganizationId } = useOrganization(); const [page, setPage] = useState(0); const [statusError, setStatusError] = useState(null); + const [actionMessage, setActionMessage] = useState(null); const [memberToDelete, setMemberToDelete] = useState(null); const [deleting, setDeleting] = useState(false); @@ -73,6 +74,28 @@ export function MembersPage() { } } + async function onResetPassword(memberId: string) { + setStatusError(null); + setActionMessage(null); + try { + const res = await fetch(`/api/members/${memberId}/reset-password`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + const body = (await res.json()) as { error?: string; message?: string }; + if (!res.ok) { + throw new Error( + body.error === "member_has_no_account" + ? "Mitglied hat noch keinen Account (Einladung nicht angenommen)." + : body.error ?? `Fehler (${res.status})`, + ); + } + setActionMessage(body.message ?? "Reset-Link wurde verschickt."); + } catch (err) { + setStatusError((err as Error).message); + } + } + async function onDeleteConfirmed() { if (!memberToDelete) return; setStatusError(null); @@ -109,6 +132,7 @@ export function MembersPage() { )} {error &&

Fehler beim Laden: {error}

} {statusError &&

{statusError}

} + {actionMessage &&

{actionMessage}

} {activeOrganizationId && ( <> @@ -173,6 +197,9 @@ export function MembersPage() { Austritt vermerken )} + onResetPassword(member.id)}> + Passwort zurücksetzen + setMemberToDelete(member)}