feat: user invite flow, deactivate/delete, favicon, dashboard loading fix, admin full-width

- Invite flow: admin can invite users by email with role selection; accept-invite page
  sets password and creates the account; 72-hour token expiry; E2E tests
- User deactivate/reactivate/delete: new tRPC procedures + UI buttons; deactivation
  revokes all active sessions immediately; delete cascades vacation/broadcast records;
  isActive field added via migration 20260402000000_user_isactive
- Auth: block login for inactive users with audit entry
- Favicon: SVG favicon + ICO/PNG fallbacks (16, 32, 180, 192, 512px); manifest updated
- Dashboard: GridLayout dynamic-import loading skeleton prevents blank dark area
  on first login before react-grid-layout chunk is cached
- Admin users: remove max-w-5xl constraint so table uses full page width
- Dev: docker container restart workflow documented in LEARNINGS.md; Prisma generate
  must run inside the container after schema changes (named node_modules volume)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-02 20:19:26 +02:00
parent dc5bbdc47d
commit 41eb722369
33 changed files with 6755 additions and 169 deletions
@@ -78,6 +78,7 @@ export async function listUsers(ctx: UserReadContext) {
lastActiveAt: true,
permissionOverrides: true,
totpEnabled: true,
isActive: true,
},
orderBy: { name: "asc" },
});
@@ -467,6 +468,120 @@ export async function getEffectiveUserPermissions(
};
}
export async function deactivateUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
if (ctx.dbUser!.id === input.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot deactivate your own account." });
}
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, isActive: true },
}),
"User",
);
if (!user.isActive) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already inactive." });
}
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: false } });
// Invalidate all existing sessions so the user is logged out immediately
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
void createAuditEntry({
db: ctx.db,
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "User deactivated",
});
return { success: true };
}
export async function reactivateUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, isActive: true },
}),
"User",
);
if (user.isActive) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already active." });
}
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true } });
void createAuditEntry({
db: ctx.db,
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "User reactivated",
});
return { success: true };
}
export async function deleteUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
if (ctx.dbUser!.id === input.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot delete your own account." });
}
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true },
}),
"User",
);
// These tables have required (non-nullable) FKs to User — must be removed first
await ctx.db.vacation.deleteMany({ where: { requestedById: input.userId } });
await ctx.db.notificationBroadcast.deleteMany({ where: { senderId: input.userId } });
await ctx.db.inviteToken.deleteMany({ where: { createdById: input.userId } });
// Unlink resource (nullable FK — belt-and-suspenders)
await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null } });
void createAuditEntry({
db: ctx.db,
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "DELETE",
...withAuditUser(ctx.dbUser?.id),
source: "ui",
summary: "User account permanently deleted",
});
// Delete user — Prisma cascade covers: Account, Session, ActiveSession,
// Notification, ReportTemplate, AssistantApproval, Comment.
// Nullable FKs (AuditLog.userId, etc.) become NULL via Prisma SET NULL default.
await ctx.db.user.delete({ where: { id: input.userId } });
return { success: true };
}
export async function disableTotp(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,