Files
talktime/backend/src/routes/members.ts
T
patrickandClaude Sonnet 5 5c2e09b36d 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
2026-09-11 23:52:17 +02:00

197 lines
6.7 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { eq, count } from "drizzle-orm";
import { db } from "../db/index.js";
import { members, memberStatusEnum } 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";
import { logAudit } from "../lib/audit.js";
import { sendPasswordResetEmail } from "../lib/password-reset.js";
const listQuerySchema = z.object({
organizationId: z.string().uuid(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
const updateStatusSchema = z.object({
status: z.enum(memberStatusEnum.enumValues),
});
export async function memberRoutes(app: FastifyInstance) {
app.get("/api/members", { preHandler: app.authenticate }, async (request, reply) => {
const parsed = listQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { organizationId, limit, offset } = parsed.data;
const isMember = await requesterIsOrganizationMember(request.user.sub, organizationId);
if (!isMember) {
return reply.code(403).send({ error: "Forbidden" });
}
const [rows, [{ value: total }]] = await Promise.all([
db
.select({
id: members.id,
firstName: members.firstName,
lastName: members.lastName,
email: members.email,
status: members.status,
createdAt: members.createdAt,
})
.from(members)
.where(eq(members.organizationId, organizationId))
.limit(limit)
.offset(offset)
.orderBy(members.lastName, members.firstName),
db.select({ value: count() }).from(members).where(eq(members.organizationId, organizationId)),
]);
return { members: rows, total, limit, offset };
});
app.patch(
"/api/members/:memberId/status",
{ preHandler: app.authenticate },
async (request, reply) => {
const { memberId } = request.params as { memberId: string };
const parsed = updateStatusSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { status } = parsed.data;
const [member] = await db
.select({ organizationId: members.organizationId, matrixUserId: members.matrixUserId })
.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" });
}
await db.update(members).set({ status }).where(eq(members.id, memberId));
await logAudit({
organizationId: member.organizationId,
actorUserId: request.user.sub,
action: "member.status_changed",
targetType: "member",
targetId: memberId,
metadata: { status },
});
if ((status === "suspended" || status === "left") && member.matrixUserId) {
// Drop any cached chat token so a suspended member cannot keep using it (PROJ-11).
invalidateMatrixAccessToken(member.matrixUserId);
try {
await deactivateUser(member.matrixUserId, false);
} catch (err) {
console.warn(`[synapse] failed to deactivate matrix user for member ${memberId}:`, err);
}
}
return reply.send({ id: memberId, status });
},
);
// 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 };
const [member] = await db
.select({
organizationId: members.organizationId,
matrixUserId: members.matrixUserId,
firstName: members.firstName,
lastName: members.lastName,
})
.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" });
}
// Audit log first: targetId is unconstrained text, so the entry survives the delete below.
await logAudit({
organizationId: member.organizationId,
actorUserId: request.user.sub,
action: "member.deleted",
targetType: "member",
targetId: memberId,
metadata: { firstName: member.firstName, lastName: member.lastName },
});
if (member.matrixUserId) {
try {
await deactivateUser(member.matrixUserId, true);
} catch (err) {
console.warn(`[synapse] failed to erase matrix user for member ${memberId}:`, err);
}
}
await db.delete(members).where(eq(members.id, memberId));
return reply.code(204).send();
});
}