feat: admin can set a member's password directly, no email required

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 00:16:19 +02:00
1 parent f9451881c4
commit b2d9bf5f15
3 files changed
+146 -3

No files matched your search

+53 -1
View File
@@ -1,8 +1,9 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import bcrypt from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
import { eq, count } from "drizzle-orm"; import { eq, count } from "drizzle-orm";
import { db } from "../db/index.js"; 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 { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js";
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";
@@ -19,6 +20,10 @@ const updateStatusSchema = z.object({
status: z.enum(memberStatusEnum.enumValues), status: z.enum(memberStatusEnum.enumValues),
}); });
const setPasswordSchema = z.object({
password: z.string().min(8, "Mindestens 8 Zeichen"),
});
export async function memberRoutes(app: FastifyInstance) { export async function memberRoutes(app: FastifyInstance) {
app.get("/api/members", { preHandler: app.authenticate }, async (request, reply) => { app.get("/api/members", { preHandler: app.authenticate }, async (request, reply) => {
const parsed = listQuerySchema.safeParse(request.query); 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) => { 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 };
+2 -1
View File
@@ -11,7 +11,8 @@ 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 (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"`. - **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
+91 -1
View File
@@ -26,6 +26,16 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } 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 { 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";
@@ -51,6 +61,10 @@ export function MembersPage() {
const [actionMessage, setActionMessage] = 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);
const [memberForPasswordSet, setMemberForPasswordSet] = useState<Member | null>(null);
const [newPassword, setNewPassword] = useState("");
const [settingPassword, setSettingPassword] = useState(false);
const [passwordSetError, setPasswordSetError] = useState<string | null>(null);
const { members, total, loading, error, refresh } = useMembers(token, activeOrganizationId, page); 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() { async function onDeleteConfirmed() {
if (!memberToDelete) return; if (!memberToDelete) return;
setStatusError(null); setStatusError(null);
@@ -198,7 +245,10 @@ export function MembersPage() {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onClick={() => onResetPassword(member.id)}> <DropdownMenuItem onClick={() => onResetPassword(member.id)}>
Passwort zurücksetzen Passwort-Mail senden
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openPasswordDialog(member)}>
Passwort direkt setzen
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
@@ -265,6 +315,46 @@ export function MembersPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<Dialog
open={memberForPasswordSet !== null}
onOpenChange={(open) => !open && setMemberForPasswordSet(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Passwort direkt setzen</DialogTitle>
<DialogDescription>
{memberForPasswordSet && (
<>
Neues Passwort für {memberForPasswordSet.firstName} {memberForPasswordSet.lastName}.
Das Mitglied sollte es beim nächsten Login selbst ändern.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
{passwordSetError && <p className="text-sm text-destructive">{passwordSetError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMemberForPasswordSet(null)} disabled={settingPassword}>
Abbrechen
</Button>
<Button
onClick={onSetPasswordConfirmed}
disabled={settingPassword || newPassword.length < 8}
>
{settingPassword ? "Speichert…" : "Passwort setzen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main> </main>
); );
} }