feat: admin-triggered password reset for members
Org admins can now trigger a password reset for a member from the members list dropdown, without ever seeing or setting the password themselves — POST /api/members/:memberId/reset-password sends the same reset-link email the self-service /forgot-password flow uses. Extracted the shared token-generation + email logic into lib/password-reset.ts so both flows use one implementation. Returns 409 member_has_no_account for members who never accepted their invitation (no linked login account to reset). Verified end-to-end on talkhub: admin trigger sends the mail (SMTP not configured in this test env, confirmed via the mail service's own log fallback), non-admin gets 403. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
1 parent
dc1ba9ae27
commit
5c2e09b36d
6 files changed
+100
-16
No files matched your search
@@ -0,0 +1,22 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { db } from "../db/index.js";
|
||||
import { passwordResetTokens } from "../db/schema.js";
|
||||
import { sendMail } from "../services/mail.js";
|
||||
|
||||
const appBaseUrl = process.env.APP_BASE_URL ?? "http://localhost:5173";
|
||||
const RESET_TOKEN_TTL_MINUTES = 60;
|
||||
|
||||
/** Shared by the self-service forgot-password flow and the admin "reset password" action on a member. */
|
||||
export async function sendPasswordResetEmail(userId: string, email: string): Promise<void> {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000);
|
||||
await db.insert(passwordResetTokens).values({ userId, 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>`,
|
||||
});
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { users, passwordResetTokens } from "../db/schema.js";
|
||||
import { sendMail } from "../services/mail.js";
|
||||
import { sendPasswordResetEmail } from "../lib/password-reset.js";
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
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(),
|
||||
});
|
||||
@@ -84,17 +80,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// 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>`,
|
||||
});
|
||||
await sendPasswordResetEmail(user.id, email);
|
||||
}
|
||||
|
||||
return reply.send({ message: "Falls die E-Mail-Adresse registriert ist, wurde ein Link verschickt." });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../
|
||||
import { deactivateUser } from "../services/synapse.js";
|
||||
import { invalidateMatrixAccessToken } from "../services/matrix-token-cache.js";
|
||||
import { logAudit } from "../lib/audit.js";
|
||||
import { sendPasswordResetEmail } from "../lib/password-reset.js";
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
organizationId: z.string().uuid(),
|
||||
@@ -102,6 +103,52 @@ export async function memberRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// Admin-triggered password reset: sends the member the same reset-link email
|
||||
// as the self-service /forgot-password flow. The admin never sees or sets
|
||||
// the new password themselves — only the member does, via the emailed link.
|
||||
app.post(
|
||||
"/api/members/:memberId/reset-password",
|
||||
{ preHandler: app.authenticate, config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (request, reply) => {
|
||||
const { memberId } = request.params as { memberId: string };
|
||||
|
||||
const [member] = await db
|
||||
.select({
|
||||
organizationId: members.organizationId,
|
||||
userId: members.userId,
|
||||
email: members.email,
|
||||
})
|
||||
.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) {
|
||||
// Invited but never accepted — no login account exists yet to reset.
|
||||
return reply.code(409).send({ error: "member_has_no_account" });
|
||||
}
|
||||
|
||||
await sendPasswordResetEmail(member.userId, member.email);
|
||||
|
||||
await logAudit({
|
||||
organizationId: member.organizationId,
|
||||
actorUserId: request.user.sub,
|
||||
action: "member.password_reset_triggered",
|
||||
targetType: "member",
|
||||
targetId: memberId,
|
||||
});
|
||||
|
||||
return reply.send({ message: "Reset-Link wurde verschickt." });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete("/api/members/:memberId", { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { memberId } = request.params as { memberId: string };
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ E-Mail/Passwort-Auth mit JWT-Session für TalkTime. Grundlage für alle geschüt
|
||||
- `frontend/src/lib/auth-context.tsx` — Session-State im Frontend
|
||||
- `frontend/src/routes/ProtectedRoute.tsx` — Redirect bei fehlender Session
|
||||
- **Nachtrag 2026-09-11 (commit bc4d749):** Passwort-Reset — `backend/src/db/schema.ts` (`password_reset_tokens`-Tabelle, 60min TTL, single-use), `POST /api/auth/forgot-password` + `POST /api/auth/reset-password/:token` in `backend/src/routes/auth.ts`, Mail-Versand über bestehenden `services/mail.ts`; Frontend: `ForgotPasswordForm.tsx`/`ResetPasswordForm.tsx`, `ForgotPasswordPage.tsx`/`ResetPasswordPage.tsx`, Link im `LoginForm.tsx`
|
||||
- **Nachtrag 2026-09-11 (Admin-Trigger):** Reset-Mail-Logik nach `backend/src/lib/password-reset.ts` extrahiert (`sendPasswordResetEmail`), genutzt von `forgot-password` **und** neu von `POST /api/members/:memberId/reset-password` ([[PROJ-3]]) — Admin kann für ein Mitglied einen Reset auslösen, sieht/setzt aber nie selbst das Passwort. Live verifiziert: Admin löst Reset aus (Mail-Log bestätigt Versandversuch), Non-Admin bekommt 403.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [x] Registrierung mit E-Mail + Passwort, Passwort gehasht (bcrypt, Cost 12)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 (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
|
||||
|
||||
@@ -48,6 +48,7 @@ export function MembersPage() {
|
||||
const { activeOrganizationId } = useOrganization();
|
||||
const [page, setPage] = useState(0);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [memberToDelete, setMemberToDelete] = useState<Member | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -73,6 +74,28 @@ export function MembersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetPassword(memberId: string) {
|
||||
setStatusError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const res = await fetch(`/api/members/${memberId}/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
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})`,
|
||||
);
|
||||
}
|
||||
setActionMessage(body.message ?? "Reset-Link wurde verschickt.");
|
||||
} catch (err) {
|
||||
setStatusError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteConfirmed() {
|
||||
if (!memberToDelete) return;
|
||||
setStatusError(null);
|
||||
@@ -109,6 +132,7 @@ export function MembersPage() {
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">Fehler beim Laden: {error}</p>}
|
||||
{statusError && <p className="text-sm text-destructive">{statusError}</p>}
|
||||
{actionMessage && <p className="text-sm text-muted-foreground">{actionMessage}</p>}
|
||||
|
||||
{activeOrganizationId && (
|
||||
<>
|
||||
@@ -173,6 +197,9 @@ export function MembersPage() {
|
||||
Austritt vermerken
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onResetPassword(member.id)}>
|
||||
Passwort zurücksetzen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setMemberToDelete(member)}
|
||||
|
||||
Reference in new issue
Block a user