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:
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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user