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,
|
"when": 1789131255612,
|
||||||
"tag": "0000_nebulous_prism",
|
"tag": "0000_nebulous_prism",
|
||||||
"breakpoints": true
|
"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(),
|
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", [
|
export const orgRoleEnum = pgEnum("org_role", [
|
||||||
"ORG_ADMIN",
|
"ORG_ADMIN",
|
||||||
"ORG_MANAGER",
|
"ORG_MANAGER",
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
|
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 { eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { db } from "../db/index.js";
|
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({
|
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({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPasswordSchema = z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
app.post("/api/auth/register", {
|
app.post("/api/auth/register", {
|
||||||
config: { rateLimit: { max: 5, timeWindow: "1 minute" } },
|
config: { rateLimit: { max: 5, timeWindow: "1 minute" } },
|
||||||
@@ -57,4 +70,66 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
const { sub, email } = request.user;
|
const { sub, email } = request.user;
|
||||||
return { id: sub, email };
|
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 });
|
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 { ProtectedRoute } from "@/routes/ProtectedRoute";
|
||||||
import { LoginPage } from "@/pages/LoginPage";
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
import { RegisterPage } from "@/pages/RegisterPage";
|
import { RegisterPage } from "@/pages/RegisterPage";
|
||||||
|
import { ForgotPasswordPage } from "@/pages/ForgotPasswordPage";
|
||||||
|
import { ResetPasswordPage } from "@/pages/ResetPasswordPage";
|
||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { MembersPage } from "@/pages/MembersPage";
|
import { MembersPage } from "@/pages/MembersPage";
|
||||||
import { DepartmentsPage } from "@/pages/DepartmentsPage";
|
import { DepartmentsPage } from "@/pages/DepartmentsPage";
|
||||||
@@ -21,6 +23,8 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<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 path="/invite/:token" element={<AcceptInvitationPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</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">
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
Noch kein Account?{" "}
|
Noch kein Account?{" "}
|
||||||
<a className="underline" href="/register">
|
<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,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 { MoreHorizontal } from "lucide-react";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { useOrganization } from "@/lib/organization-context";
|
import { useOrganization } from "@/lib/organization-context";
|
||||||
@@ -38,6 +48,8 @@ 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 [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const { members, total, loading, error, refresh } = useMembers(token, activeOrganizationId, page);
|
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 (
|
return (
|
||||||
<main className="min-h-screen p-6 space-y-6">
|
<main className="min-h-screen p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -139,6 +173,12 @@ export function MembersPage() {
|
|||||||
Austritt vermerken
|
Austritt vermerken
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setMemberToDelete(member)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -171,6 +211,33 @@ export function MembersPage() {
|
|||||||
</div>
|
</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>
|
</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