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
@@ -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);
});
});