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:
@@ -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>,
|
||||
|
||||
@@ -61,8 +61,11 @@ export async function getDashboardLayout(ctx: UserSelfServiceContext) {
|
||||
select: { dashboardLayout: true, updatedAt: true },
|
||||
});
|
||||
|
||||
const normalized = user?.dashboardLayout
|
||||
? normalizeDashboardLayout(user.dashboardLayout)
|
||||
: null;
|
||||
return {
|
||||
layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null,
|
||||
layout: normalized?.widgets.length ? normalized : null,
|
||||
updatedAt: user?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
countActiveUsers,
|
||||
CreateUserInputSchema,
|
||||
createUser,
|
||||
deactivateUser,
|
||||
reactivateUser,
|
||||
deleteUser,
|
||||
disableTotp,
|
||||
getEffectiveUserPermissions,
|
||||
LinkUserResourceInputSchema,
|
||||
@@ -116,6 +119,18 @@ export const userRouter = createTRPCRouter({
|
||||
.input(VerifyAndEnableTotpInputSchema)
|
||||
.mutation(({ ctx, input }) => verifyAndEnableTotpSelfService(ctx, input)),
|
||||
|
||||
deactivate: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
.mutation(({ ctx, input }) => deactivateUser(ctx, input)),
|
||||
|
||||
reactivate: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
.mutation(({ ctx, input }) => reactivateUser(ctx, input)),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
.mutation(({ ctx, input }) => deleteUser(ctx, input)),
|
||||
|
||||
/** Admin override: disable TOTP for a specific user. */
|
||||
disableTotp: adminProcedure
|
||||
.input(UserIdInputSchema)
|
||||
|
||||
Reference in New Issue
Block a user