From b2d9bf5f150260b92417cb20d9c2f7eff6101d71 Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 12 Sep 2026 00:16:19 +0200 Subject: [PATCH] feat: admin can set a member's password directly, no email required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /api/members/:memberId/password — admin-only, hashes and sets the password immediately instead of going through the email reset-link flow. Useful in this deployment where SMTP isn't configured yet, and more generally whenever the member needs to log in right away. Frontend: "Passwort direkt setzen" dialog in the members list dropdown, alongside the existing "Passwort-Mail senden" (renamed for clarity now that there are two options). Verified end-to-end: new password logs in, old password is rejected. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67 --- backend/src/routes/members.ts | 54 ++++++++++++++- features/PROJ-3-mitgliederverwaltung.md | 3 +- frontend/src/pages/MembersPage.tsx | 92 ++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts index 9365a42..5cdb033 100644 --- a/backend/src/routes/members.ts +++ b/backend/src/routes/members.ts @@ -1,8 +1,9 @@ import type { FastifyInstance } from "fastify"; +import bcrypt from "bcryptjs"; import { z } from "zod"; import { eq, count } from "drizzle-orm"; import { db } from "../db/index.js"; -import { members, memberStatusEnum } from "../db/schema.js"; +import { members, memberStatusEnum, users } from "../db/schema.js"; import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js"; import { deactivateUser } from "../services/synapse.js"; import { invalidateMatrixAccessToken } from "../services/matrix-token-cache.js"; @@ -19,6 +20,10 @@ const updateStatusSchema = z.object({ status: z.enum(memberStatusEnum.enumValues), }); +const setPasswordSchema = z.object({ + password: z.string().min(8, "Mindestens 8 Zeichen"), +}); + export async function memberRoutes(app: FastifyInstance) { app.get("/api/members", { preHandler: app.authenticate }, async (request, reply) => { const parsed = listQuerySchema.safeParse(request.query); @@ -149,6 +154,53 @@ export async function memberRoutes(app: FastifyInstance) { }, ); + // Direct admin-set password — bypasses email entirely. Useful when SMTP + // isn't configured (as in this deployment) or the admin needs the member + // able to log in immediately. The member should change it on next login; + // there's no forced-change flag for that yet. + app.put( + "/api/members/:memberId/password", + { preHandler: app.authenticate, config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + const { memberId } = request.params as { memberId: string }; + const parsed = setPasswordSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + + const [member] = await db + .select({ organizationId: members.organizationId, userId: members.userId }) + .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) { + return reply.code(409).send({ error: "member_has_no_account" }); + } + + const passwordHash = await bcrypt.hash(parsed.data.password, 12); + await db.update(users).set({ passwordHash }).where(eq(users.id, member.userId)); + + await logAudit({ + organizationId: member.organizationId, + actorUserId: request.user.sub, + action: "member.password_set_by_admin", + targetType: "member", + targetId: memberId, + }); + + return reply.send({ message: "Passwort gesetzt." }); + }, + ); + app.delete("/api/members/:memberId", { preHandler: app.authenticate }, async (request, reply) => { const { memberId } = request.params as { memberId: string }; diff --git a/features/PROJ-3-mitgliederverwaltung.md b/features/PROJ-3-mitgliederverwaltung.md index 267afdc..b032aed 100644 --- a/features/PROJ-3-mitgliederverwaltung.md +++ b/features/PROJ-3-mitgliederverwaltung.md @@ -11,7 +11,8 @@ 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 (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-Mail senden" im Dropdown-Menü der Mitgliederliste. +- **Nachtrag 2026-09-12 (Direktes Passwort-Setzen):** `PUT /api/members/:memberId/password` — Admin-only, setzt das Passwort direkt ohne E-Mail-Umweg (sinnvoll ohne konfiguriertes SMTP). Frontend: "Passwort direkt setzen" öffnet Dialog mit Passwort-Eingabe (min. 8 Zeichen). Live verifiziert: neues Passwort funktioniert beim Login, altes wird ungültig. Kein Zwang zum Ändern beim nächsten Login (kein `mustChangePassword`-Flag) — Admin sieht das Klartext-Passwort einmalig im eigenen Eingabefeld, TalkTime speichert nur den Hash. - **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 8960404..d16c63a 100644 --- a/frontend/src/pages/MembersPage.tsx +++ b/frontend/src/pages/MembersPage.tsx @@ -26,6 +26,16 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { MoreHorizontal } from "lucide-react"; import { useAuth } from "@/lib/auth-context"; import { useOrganization } from "@/lib/organization-context"; @@ -51,6 +61,10 @@ export function MembersPage() { const [actionMessage, setActionMessage] = useState(null); const [memberToDelete, setMemberToDelete] = useState(null); const [deleting, setDeleting] = useState(false); + const [memberForPasswordSet, setMemberForPasswordSet] = useState(null); + const [newPassword, setNewPassword] = useState(""); + const [settingPassword, setSettingPassword] = useState(false); + const [passwordSetError, setPasswordSetError] = useState(null); const { members, total, loading, error, refresh } = useMembers(token, activeOrganizationId, page); @@ -96,6 +110,39 @@ export function MembersPage() { } } + function openPasswordDialog(member: Member) { + setPasswordSetError(null); + setNewPassword(""); + setMemberForPasswordSet(member); + } + + async function onSetPasswordConfirmed() { + if (!memberForPasswordSet) return; + setPasswordSetError(null); + setSettingPassword(true); + try { + const res = await fetch(`/api/members/${memberForPasswordSet.id}/password`, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ password: newPassword }), + }); + 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})`, + ); + } + setMemberForPasswordSet(null); + setActionMessage(body.message ?? "Passwort gesetzt."); + } catch (err) { + setPasswordSetError((err as Error).message); + } finally { + setSettingPassword(false); + } + } + async function onDeleteConfirmed() { if (!memberToDelete) return; setStatusError(null); @@ -198,7 +245,10 @@ export function MembersPage() { )} onResetPassword(member.id)}> - Passwort zurücksetzen + Passwort-Mail senden + + openPasswordDialog(member)}> + Passwort direkt setzen + !open && setMemberForPasswordSet(null)} + > + + + Passwort direkt setzen + + {memberForPasswordSet && ( + <> + Neues Passwort für {memberForPasswordSet.firstName} {memberForPasswordSet.lastName}. + Das Mitglied sollte es beim nächsten Login selbst ändern. + + )} + + +
+ + setNewPassword(e.target.value)} + /> + {passwordSetError &&

{passwordSetError}

} +
+ + + + +
+
); }