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
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-11 23:52:17 +02:00
1 parent dc1ba9ae27
commit 5c2e09b36d
6 files changed
+100 -16

No files matched your search

+22
View File
@@ -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<void> {
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: `<p><a href="${resetLink}">Passwort zurücksetzen</a></p><p>Link gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.</p>`,
});
}
+2 -16
View File
@@ -1,20 +1,16 @@
import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import { users, passwordResetTokens } from "../db/schema.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({ const credentialsSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), 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({ const forgotPasswordSchema = z.object({
email: z.string().email(), 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. // 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); const [user] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1);
if (user) { if (user) {
const token = randomBytes(32).toString("hex"); await sendPasswordResetEmail(user.id, email);
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: `<p><a href="${resetLink}">Passwort zurücksetzen</a></p><p>Link gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.</p>`,
});
} }
return reply.send({ message: "Falls die E-Mail-Adresse registriert ist, wurde ein Link verschickt." }); return reply.send({ message: "Falls die E-Mail-Adresse registriert ist, wurde ein Link verschickt." });
+47
View File
@@ -7,6 +7,7 @@ import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../
import { deactivateUser } from "../services/synapse.js"; import { deactivateUser } from "../services/synapse.js";
import { invalidateMatrixAccessToken } from "../services/matrix-token-cache.js"; import { invalidateMatrixAccessToken } from "../services/matrix-token-cache.js";
import { logAudit } from "../lib/audit.js"; import { logAudit } from "../lib/audit.js";
import { sendPasswordResetEmail } from "../lib/password-reset.js";
const listQuerySchema = z.object({ const listQuerySchema = z.object({
organizationId: z.string().uuid(), 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) => { app.delete("/api/members/:memberId", { preHandler: app.authenticate }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }; const { memberId } = request.params as { memberId: string };
+1
View File
@@ -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/lib/auth-context.tsx` — Session-State im Frontend
- `frontend/src/routes/ProtectedRoute.tsx` — Redirect bei fehlender Session - `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 (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 ## Acceptance Criteria
- [x] Registrierung mit E-Mail + Passwort, Passwort gehasht (bcrypt, Cost 12) - [x] Registrierung mit E-Mail + Passwort, Passwort gehasht (bcrypt, Cost 12)
+1
View File
@@ -11,6 +11,7 @@ Mitglieder eines Vereins verwalten: Liste, Status-Aktionen (aktivieren/sperren/A
- `frontend/src/pages/MembersPage.tsx` - `frontend/src/pages/MembersPage.tsx`
- Mitglied-Status-Aktionen + Deaktivierungs-Sync (commit b38a5a4) - 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 (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"`. - **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 ## Acceptance Criteria
+27
View File
@@ -48,6 +48,7 @@ export function MembersPage() {
const { activeOrganizationId } = useOrganization(); const { activeOrganizationId } = useOrganization();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [statusError, setStatusError] = useState<string | null>(null); const [statusError, setStatusError] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null); const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
const [deleting, setDeleting] = useState(false); 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() { async function onDeleteConfirmed() {
if (!memberToDelete) return; if (!memberToDelete) return;
setStatusError(null); setStatusError(null);
@@ -109,6 +132,7 @@ export function MembersPage() {
)} )}
{error && <p className="text-sm text-destructive">Fehler beim Laden: {error}</p>} {error && <p className="text-sm text-destructive">Fehler beim Laden: {error}</p>}
{statusError && <p className="text-sm text-destructive">{statusError}</p>} {statusError && <p className="text-sm text-destructive">{statusError}</p>}
{actionMessage && <p className="text-sm text-muted-foreground">{actionMessage}</p>}
{activeOrganizationId && ( {activeOrganizationId && (
<> <>
@@ -173,6 +197,9 @@ export function MembersPage() {
Austritt vermerken Austritt vermerken
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onClick={() => onResetPassword(member.id)}>
Passwort zurücksetzen
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={() => setMemberToDelete(member)} onClick={() => setMemberToDelete(member)}