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
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-11 18:42:47 +02:00
1 parent 1c013b301c
commit bc4d74995f
13 files changed
+1905 -2

No files matched your search

@@ -0,0 +1,12 @@
CREATE TABLE "password_reset_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"expires_at" timestamp NOT NULL,
"used_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "password_reset_tokens_token_unique" UNIQUE("token")
);
--> statement-breakpoint
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1789131255612,
"tag": "0000_nebulous_prism",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1789144826183,
"tag": "0001_rare_mulholland_black",
"breakpoints": true
}
]
}
+15
View File
@@ -17,6 +17,21 @@ export const users = pgTable("users", {
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const passwordResetTokens = pgTable(
"password_reset_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(),
usedAt: timestamp("used_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => [index("password_reset_tokens_user_id_idx").on(table.userId)],
);
export const orgRoleEnum = pgEnum("org_role", [
"ORG_ADMIN",
"ORG_MANAGER",
+77 -2
View File
@@ -1,15 +1,28 @@
import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { users, passwordResetTokens } from "../db/schema.js";
import { sendMail } from "../services/mail.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(),
});
const resetPasswordSchema = z.object({
password: z.string().min(8),
});
export async function authRoutes(app: FastifyInstance) {
app.post("/api/auth/register", {
config: { rateLimit: { max: 5, timeWindow: "1 minute" } },
@@ -57,4 +70,66 @@ export async function authRoutes(app: FastifyInstance) {
const { sub, email } = request.user;
return { id: sub, email };
});
app.post(
"/api/auth/forgot-password",
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (request, reply) => {
const parsed = forgotPasswordSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { email } = parsed.data;
// 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: `<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." });
},
);
app.post(
"/api/auth/reset-password/:token",
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (request, reply) => {
const { token } = request.params as { token: string };
const parsed = resetPasswordSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { password } = parsed.data;
const [resetToken] = await db
.select()
.from(passwordResetTokens)
.where(and(eq(passwordResetTokens.token, token), isNull(passwordResetTokens.usedAt)))
.limit(1);
if (!resetToken || resetToken.expiresAt < new Date()) {
return reply.code(410).send({ error: "Reset link is invalid or has expired" });
}
const passwordHash = await bcrypt.hash(password, 12);
await db.update(users).set({ passwordHash }).where(eq(users.id, resetToken.userId));
await db
.update(passwordResetTokens)
.set({ usedAt: new Date() })
.where(eq(passwordResetTokens.id, resetToken.id));
return reply.send({ message: "Password updated" });
},
);
}
+45
View File
@@ -98,4 +98,49 @@ export async function memberRoutes(app: FastifyInstance) {
return reply.send({ id: memberId, status });
},
);
app.delete("/api/members/:memberId", { preHandler: app.authenticate }, async (request, reply) => {
const { memberId } = request.params as { memberId: string };
const [member] = await db
.select({
organizationId: members.organizationId,
matrixUserId: members.matrixUserId,
firstName: members.firstName,
lastName: members.lastName,
})
.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" });
}
// Audit log first: targetId is unconstrained text, so the entry survives the delete below.
await logAudit({
organizationId: member.organizationId,
actorUserId: request.user.sub,
action: "member.deleted",
targetType: "member",
targetId: memberId,
metadata: { firstName: member.firstName, lastName: member.lastName },
});
if (member.matrixUserId) {
try {
await deactivateUser(member.matrixUserId, true);
} catch (err) {
console.warn(`[synapse] failed to erase matrix user for member ${memberId}:`, err);
}
}
await db.delete(members).where(eq(members.id, memberId));
return reply.code(204).send();
});
}
+4
View File
@@ -5,6 +5,8 @@ import { OrganizationProvider } from "@/lib/organization-context";
import { ProtectedRoute } from "@/routes/ProtectedRoute";
import { LoginPage } from "@/pages/LoginPage";
import { RegisterPage } from "@/pages/RegisterPage";
import { ForgotPasswordPage } from "@/pages/ForgotPasswordPage";
import { ResetPasswordPage } from "@/pages/ResetPasswordPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { MembersPage } from "@/pages/MembersPage";
import { DepartmentsPage } from "@/pages/DepartmentsPage";
@@ -21,6 +23,8 @@ function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
<Route path="/invite/:token" element={<AcceptInvitationPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<DashboardPage />} />
@@ -0,0 +1,91 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const forgotPasswordSchema = z.object({
email: z.string().email("Bitte gültige E-Mail eingeben"),
});
type ForgotPasswordValues = z.infer<typeof forgotPasswordSchema>;
export function ForgotPasswordForm() {
const [formError, setFormError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const form = useForm<ForgotPasswordValues>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email: "" },
});
async function onSubmit(values: ForgotPasswordValues) {
setFormError(null);
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const body = (await res.json()) as { error?: string };
throw new Error(body.error ?? `Fehler (${res.status})`);
}
setSent(true);
} catch (err) {
setFormError((err as Error).message);
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Passwort vergessen</CardTitle>
</CardHeader>
<CardContent>
{sent ? (
<p className="text-sm text-muted-foreground">
Falls die E-Mail-Adresse registriert ist, wurde ein Link zum Zurücksetzen verschickt.
</p>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" autoComplete="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Sende…" : "Link anfordern"}
</Button>
</form>
</Form>
)}
<p className="mt-4 text-sm text-muted-foreground">
<a className="underline" href="/login">
Zurück zum Login
</a>
</p>
</CardContent>
</Card>
);
}
@@ -85,6 +85,11 @@ export function LoginForm() {
</Button>
</form>
</Form>
<p className="mt-2 text-sm text-muted-foreground">
<a className="underline" href="/forgot-password">
Passwort vergessen?
</a>
</p>
<p className="mt-4 text-sm text-muted-foreground">
Noch kein Account?{" "}
<a className="underline" href="/register">
@@ -0,0 +1,86 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const resetPasswordSchema = z.object({
password: z.string().min(8, "Mindestens 8 Zeichen"),
});
type ResetPasswordValues = z.infer<typeof resetPasswordSchema>;
export function ResetPasswordForm() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const [formError, setFormError] = useState<string | null>(null);
const form = useForm<ResetPasswordValues>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: { password: "" },
});
async function onSubmit(values: ResetPasswordValues) {
setFormError(null);
try {
const res = await fetch(`/api/auth/reset-password/${token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const body = (await res.json()) as { error?: string };
throw new Error(
res.status === 410
? "Link ist ungültig oder abgelaufen. Bitte neuen Link anfordern."
: body.error ?? `Fehler (${res.status})`,
);
}
navigate("/login");
} catch (err) {
setFormError((err as Error).message);
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Neues Passwort setzen</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Neues Passwort</FormLabel>
<FormControl>
<Input type="password" autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Speichert…" : "Passwort speichern"}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,9 @@
import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
export function ForgotPasswordPage() {
return (
<main className="min-h-screen flex items-center justify-center p-6">
<ForgotPasswordForm />
</main>
);
}
+67
View File
@@ -16,6 +16,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { MoreHorizontal } from "lucide-react";
import { useAuth } from "@/lib/auth-context";
import { useOrganization } from "@/lib/organization-context";
@@ -38,6 +48,8 @@ export function MembersPage() {
const { activeOrganizationId } = useOrganization();
const [page, setPage] = useState(0);
const [statusError, setStatusError] = useState<string | null>(null);
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
const [deleting, setDeleting] = useState(false);
const { members, total, loading, error, refresh } = useMembers(token, activeOrganizationId, page);
@@ -61,6 +73,28 @@ export function MembersPage() {
}
}
async function onDeleteConfirmed() {
if (!memberToDelete) return;
setStatusError(null);
setDeleting(true);
try {
const res = await fetch(`/api/members/${memberToDelete.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok && res.status !== 204) {
const body = (await res.json()) as { error?: string };
throw new Error(body.error ?? `Fehler (${res.status})`);
}
setMemberToDelete(null);
await refresh();
} catch (err) {
setStatusError((err as Error).message);
} finally {
setDeleting(false);
}
}
return (
<main className="min-h-screen p-6 space-y-6">
<div className="flex items-center justify-between">
@@ -139,6 +173,12 @@ export function MembersPage() {
Austritt vermerken
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setMemberToDelete(member)}
>
Löschen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -171,6 +211,33 @@ export function MembersPage() {
</div>
</>
)}
<AlertDialog open={memberToDelete !== null} onOpenChange={(open) => !open && setMemberToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mitglied endgültig löschen?</AlertDialogTitle>
<AlertDialogDescription>
{memberToDelete && (
<>
{memberToDelete.firstName} {memberToDelete.lastName} wird unwiderruflich gelöscht,
inklusive Team-Zuordnungen und Rollen. Der Matrix-Account wird deaktiviert und
gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={onDeleteConfirmed}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Löscht…" : "Endgültig löschen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
export function ResetPasswordPage() {
return (
<main className="min-h-screen flex items-center justify-center p-6">
<ResetPasswordForm />
</main>
);
}