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
197 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
}
|