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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user