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,129 @@
"use client";
import { useState } from "react";
import { SystemRole } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
interface InviteUserModalProps {
open: boolean;
onClose: () => void;
}
const ROLE_OPTIONS: { value: SystemRole; label: string }[] = [
{ value: SystemRole.USER, label: "User" },
{ value: SystemRole.VIEWER, label: "Viewer" },
{ value: SystemRole.CONTROLLER, label: "Controller" },
{ value: SystemRole.MANAGER, label: "Manager" },
{ value: SystemRole.ADMIN, label: "Admin" },
];
export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<SystemRole>(SystemRole.USER);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const utils = trpc.useUtils();
const inviteMutation = trpc.invite.createInvite.useMutation({
onSuccess: async () => {
await utils.invite.listInvites.invalidate();
setSuccess(true);
setEmail("");
setRole(SystemRole.USER);
setTimeout(() => {
setSuccess(false);
onClose();
}, 1500);
},
onError: (err) => setError(err.message),
});
function handleClose() {
setEmail("");
setRole(SystemRole.USER);
setError(null);
setSuccess(false);
onClose();
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!email) { setError("Email is required."); return; }
await inviteMutation.mutateAsync({ email, role });
}
return (
<AnimatedModal open={open} onClose={handleClose} maxWidth="max-w-md">
<div className="px-6 py-5">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Invite User</h2>
{success ? (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Invitation sent successfully.
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as SystemRole)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
An invite link valid for 72 hours will be sent to this email address.
</p>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={handleClose}
className="rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={inviteMutation.isPending}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{inviteMutation.isPending ? "Sending..." : "Send Invite"}
</button>
</div>
</form>
)}
</div>
</AnimatedModal>
);
}