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
@@ -24,8 +24,11 @@ describe("assistant user self-service dashboard layout tools", () => {
user: {
findUnique: vi.fn().mockResolvedValue({
dashboardLayout: {
version: 2,
gridCols: 12,
widgets: [
{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } },
// Valid widget type so normalization preserves it
{ id: "stat-1", type: "stat-cards", x: 0, y: 0, w: 12, h: 3 },
],
},
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
@@ -40,14 +43,26 @@ describe("assistant user self-service dashboard layout tools", () => {
where: { id: "user_1" },
select: { dashboardLayout: true, updatedAt: true },
});
expect(JSON.parse(result.content)).toEqual({
layout: {
version: 2,
gridCols: 12,
widgets: [],
const parsed = JSON.parse(result.content) as { layout: { widgets: unknown[] } | null };
expect(parsed.layout).not.toBeNull();
expect(parsed.layout?.widgets).toHaveLength(1);
});
it("returns null layout when stored widgets have no valid type (bug #27 guard)", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({
dashboardLayout: {
widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }],
},
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
}),
},
updatedAt: "2026-03-30T18:00:00.000Z",
});
};
const ctx = createToolContext(db, SystemRole.ADMIN);
const result = await executeTool("get_dashboard_layout", "{}", ctx);
// Unknown widget type → normalises to empty → client gets null to use default layout
expect(JSON.parse(result.content)).toMatchObject({ layout: null });
});
it("saves dashboard layout through the real user router path", async () => {
+205
View File
@@ -0,0 +1,205 @@
/**
* Unit tests for the invite tRPC router.
*
* Tests cover:
* - createInvite: token created, email sent
* - createInvite: duplicate active invite → CONFLICT
* - getInvite: valid token → returns email + role
* - getInvite: used token → BAD_REQUEST
* - getInvite: expired token → BAD_REQUEST
* - getInvite: unknown token → NOT_FOUND
* - acceptInvite: valid → user created, usedAt set
* - acceptInvite: used token → BAD_REQUEST
* - acceptInvite: expired token → BAD_REQUEST
* - revokeInvite: deletes the token
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TRPCError } from "@trpc/server";
import { SystemRole } from "@capakraken/db";
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("../lib/email.js", () => ({ sendEmail: vi.fn().mockResolvedValue(true) }));
vi.mock("@node-rs/argon2", () => ({ hash: vi.fn().mockResolvedValue("$argon2id$hashed") }));
// ── Helpers ──────────────────────────────────────────────────────────────────
const ADMIN_USER = { id: "admin_1" };
const FUTURE = new Date(Date.now() + 72 * 60 * 60 * 1000);
const PAST = new Date(Date.now() - 1000);
function makeInviteDb(overrides: Record<string, unknown> = {}) {
return {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
...overrides,
};
}
function makeCtx(inviteDb: ReturnType<typeof makeInviteDb> = makeInviteDb()) {
return {
db: {
inviteToken: inviteDb,
user: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({}) },
} as never,
dbUser: { id: ADMIN_USER.id, systemRole: "ADMIN", permissionOverrides: null },
session: {
user: { id: ADMIN_USER.id, email: "admin@example.com" },
expires: "2099-01-01T00:00:00.000Z",
},
};
}
// ── Import after mocks ───────────────────────────────────────────────────────
const { inviteRouter } = await import("../router/invite.js");
function caller(ctx: ReturnType<typeof makeCtx>) {
return inviteRouter.createCaller(ctx as never);
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("createInvite", () => {
beforeEach(() => vi.clearAllMocks());
it("creates a token and sends an email", async () => {
const { sendEmail } = await import("../lib/email.js");
const ctx = makeCtx();
await caller(ctx).createInvite({ email: "alice@example.com", role: SystemRole.USER });
expect(ctx.db.inviteToken.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ email: "alice@example.com", role: SystemRole.USER }),
}),
);
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "alice@example.com" }));
});
it("throws CONFLICT when an active invite already exists", async () => {
const inviteDb = makeInviteDb({
findFirst: vi.fn().mockResolvedValue({ id: "existing" }),
});
const ctx = makeCtx(inviteDb);
await expect(
caller(ctx).createInvite({ email: "alice@example.com", role: SystemRole.USER }),
).rejects.toThrow(TRPCError);
});
});
describe("getInvite", () => {
beforeEach(() => vi.clearAllMocks());
it("returns email and role for a valid unused token", async () => {
const inviteDb = makeInviteDb({
findUnique: vi.fn().mockResolvedValue({
email: "alice@example.com",
role: SystemRole.MANAGER,
expiresAt: FUTURE,
usedAt: null,
}),
});
const result = await caller(makeCtx(inviteDb)).getInvite({ token: "abc" });
expect(result).toEqual({ email: "alice@example.com", role: SystemRole.MANAGER });
});
it("throws NOT_FOUND for unknown token", async () => {
const ctx = makeCtx();
await expect(caller(ctx).getInvite({ token: "unknown" })).rejects.toThrow(TRPCError);
});
it("throws BAD_REQUEST for already-used token", async () => {
const inviteDb = makeInviteDb({
findUnique: vi.fn().mockResolvedValue({
email: "alice@example.com",
role: SystemRole.USER,
expiresAt: FUTURE,
usedAt: new Date(),
}),
});
await expect(caller(makeCtx(inviteDb)).getInvite({ token: "abc" })).rejects.toThrow(TRPCError);
});
it("throws BAD_REQUEST for expired token", async () => {
const inviteDb = makeInviteDb({
findUnique: vi.fn().mockResolvedValue({
email: "alice@example.com",
role: SystemRole.USER,
expiresAt: PAST,
usedAt: null,
}),
});
await expect(caller(makeCtx(inviteDb)).getInvite({ token: "abc" })).rejects.toThrow(TRPCError);
});
});
describe("acceptInvite", () => {
beforeEach(() => vi.clearAllMocks());
it("creates the user and marks token as used", async () => {
const inviteDb = makeInviteDb({
findUnique: vi.fn().mockResolvedValue({
id: "inv_1",
email: "alice@example.com",
role: SystemRole.USER,
expiresAt: FUTURE,
usedAt: null,
token: "validtoken",
}),
});
const ctx = makeCtx(inviteDb);
const result = await caller(ctx).acceptInvite({ token: "validtoken", password: "SecurePass1!" });
expect(ctx.db.user.create).toHaveBeenCalled();
expect(inviteDb.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { usedAt: expect.any(Date) } }),
);
expect(result).toEqual({ success: true });
});
it("throws BAD_REQUEST for used token", async () => {
const inviteDb = makeInviteDb({
findUnique: vi.fn().mockResolvedValue({
email: "alice@example.com",
role: SystemRole.USER,
expiresAt: FUTURE,
usedAt: new Date(),
token: "usedtoken",
}),
});
await expect(
caller(makeCtx(inviteDb)).acceptInvite({ token: "usedtoken", password: "SecurePass1!" }),
).rejects.toThrow(TRPCError);
});
it("throws BAD_REQUEST for expired token", async () => {
const inviteDb = makeInviteDb({
findUnique: vi.fn().mockResolvedValue({
email: "alice@example.com",
role: SystemRole.USER,
expiresAt: PAST,
usedAt: null,
token: "expiredtoken",
}),
});
await expect(
caller(makeCtx(inviteDb)).acceptInvite({ token: "expiredtoken", password: "SecurePass1!" }),
).rejects.toThrow(TRPCError);
});
});
describe("revokeInvite", () => {
beforeEach(() => vi.clearAllMocks());
it("deletes the invite token", async () => {
const inviteDb = makeInviteDb();
const ctx = makeCtx(inviteDb);
const result = await caller(ctx).revokeInvite({ id: "inv_1" });
expect(inviteDb.delete).toHaveBeenCalledWith({ where: { id: "inv_1" } });
expect(result).toEqual({ success: true });
});
});
@@ -134,7 +134,7 @@ describe("user-procedure-support", () => {
});
});
it("normalizes dashboard layouts before returning them", async () => {
it("returns null layout when stored widgets have no valid type (bug #27 guard)", async () => {
const findUnique = vi.fn().mockResolvedValue({
dashboardLayout: {
widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }],
@@ -146,8 +146,9 @@ describe("user-procedure-support", () => {
user: { findUnique },
}));
// Widgets with unknown types normalise to empty → return null so client uses default
expect(result).toEqual({
layout: { version: 2, gridCols: 12, widgets: [] },
layout: null,
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
});
});
@@ -760,7 +760,7 @@ describe("user profile and TOTP self-service", () => {
});
describe("user dashboard and favorites", () => {
it("falls back to the normalized default dashboard layout when stored data is invalid", async () => {
it("returns null layout when stored data has no valid widget types (bug #27 guard)", async () => {
const findUnique = vi.fn().mockResolvedValue({
dashboardLayout: {
widgets: [
@@ -781,12 +781,9 @@ describe("user dashboard and favorites", () => {
where: { id: "user_admin" },
select: { dashboardLayout: true, updatedAt: true },
});
// Widgets with unknown types are dropped → empty → client uses default layout
expect(result).toEqual({
layout: {
version: 2,
gridCols: 12,
widgets: [],
},
layout: null,
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
});
});
@@ -0,0 +1,70 @@
/**
* Unit tests for getDashboardLayout — bug #27 regression guard.
*
* Verifies that getDashboardLayout returns null (not an empty layout) when the
* stored dashboardLayout is an empty object or contains only unknown widget
* types. This allows the client to fall back to the default stat-cards layout.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("../lib/audit.js", () => ({ createAuditEntry: vi.fn() }));
vi.mock("../lib/system-settings-runtime.js", () => ({ resolveSystemSettingsRuntime: vi.fn() }));
const { getDashboardLayout } = await import(
"../router/user-self-service-procedure-support.js"
);
function makeCtx(dashboardLayout: unknown) {
return {
db: {
user: {
findUnique: vi.fn().mockResolvedValue(
dashboardLayout !== undefined ? { dashboardLayout, updatedAt: new Date() } : null,
),
},
} as never,
dbUser: { id: "user_1" },
session: null,
};
}
describe("getDashboardLayout — #27 regression", () => {
it("returns null when dashboardLayout is null (new user)", async () => {
const ctx = makeCtx(null);
const result = await getDashboardLayout(ctx as never);
expect(result.layout).toBeNull();
});
it("returns null when dashboardLayout is an empty object", async () => {
const ctx = makeCtx({});
const result = await getDashboardLayout(ctx as never);
expect(result.layout).toBeNull();
});
it("returns null when dashboardLayout has no valid widget types", async () => {
const ctx = makeCtx({
version: 1,
widgets: [{ type: "old-unknown-widget-type", id: "w1", x: 0, y: 0, w: 4, h: 3 }],
});
const result = await getDashboardLayout(ctx as never);
expect(result.layout).toBeNull();
});
it("returns null when dashboardLayout has an empty widgets array", async () => {
const ctx = makeCtx({ version: 2, gridCols: 12, widgets: [] });
const result = await getDashboardLayout(ctx as never);
expect(result.layout).toBeNull();
});
it("returns the layout when it contains at least one valid widget", async () => {
const ctx = makeCtx({
version: 2,
gridCols: 12,
widgets: [{ type: "stat-cards", id: "w1", x: 0, y: 0, w: 12, h: 3 }],
});
const result = await getDashboardLayout(ctx as never);
expect(result.layout).not.toBeNull();
expect(result.layout?.widgets).toHaveLength(1);
});
});