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 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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user