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:
1 parent
f9451881c4
commit
b2d9bf5f15
3 files changed
+146
-3
No files matched your search
@@ -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 };
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
|
||||
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);
|
||||
|
||||
@@ -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() {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onResetPassword(member.id)}>
|
||||
Passwort zurücksetzen
|
||||
Passwort-Mail senden
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openPasswordDialog(member)}>
|
||||
Passwort direkt setzen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
@@ -265,6 +315,46 @@ export function MembersPage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user