mega-plant/components/auth/UsersTable.tsx
David 29a035a4f2 Clock app: vehicle-QR diesel book-out with meter photo; user access levels
- Scanning a vehicle QR (its register code, e.g. TLB04) books diesel from the main
  tank; litres, optional odometer and a required meter photo (stored protected,
  viewable from the Booked out table)
- Settings: users are Full or Clock-app-only; clock-only users are confined to /clock
- Clock app: tapped tab highlights at once plus a loading screen; remove the
  "scanned on this device" list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:25:15 +02:00

89 lines
7.6 KiB
TypeScript

"use client";
import { useActionState, useEffect, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { LoaderCircle, Trash2, UserRound, Users } from "lucide-react";
import { manageUserAction, type FormState } from "@/app/actions/auth";
import type { ManagedUser } from "@/lib/auth-service";
import { ScrollFade } from "@/components/ScrollFade";
export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; currentUserId: string }) {
const [state, action, pending] = useActionState<FormState, FormData>(manageUserAction, {});
const [deleting, setDeleting] = useState<ManagedUser | null>(null);
const [deleteAttempted, setDeleteAttempted] = useState(false);
useEffect(() => { if (state.success) setDeleting(null); }, [state]);
const activeCount = users.filter((user) => user.active).length;
return <section className="mt-6 min-w-0 rounded-2xl border border-border bg-surface shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 p-5 sm:p-7">
<div className="flex items-start gap-3">
<Users className="mt-1 h-5 w-5 shrink-0 text-[#30638E] dark:text-indigo-ink" />
<div><h2 className="text-lg font-semibold">Users</h2><p className="mt-1 text-sm text-text-muted">{activeCount} active · {users.length - activeCount} inactive</p></div>
</div>
<p className="text-xs text-text-muted lg:hidden">Scroll sideways to see actions →</p>
</div>
{state.error && <p role="alert" className="mx-5 mb-4 rounded-lg bg-[#D1495B]/10 p-3 text-sm text-brick-ink sm:mx-7">{state.error}</p>}
{state.success && <p role="status" className="mx-5 mb-4 rounded-lg bg-[#00798C]/10 p-3 text-sm text-accent-ink sm:mx-7">{state.success}</p>}
<div className="relative">
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full min-w-[820px] text-left text-sm">
<caption className="sr-only">Mega Plant users and their access status</caption>
<thead className="border-y border-border bg-surface-muted text-xs uppercase tracking-wide text-text-muted">
<tr><th scope="col" className="sticky left-0 z-10 bg-surface-muted px-5 py-4 font-medium sm:px-7">User</th><th scope="col" className="px-4 py-4 font-medium">Email</th><th scope="col" className="px-4 py-4 font-medium">Access</th><th scope="col" className="px-4 py-4 font-medium">Status</th><th scope="col" className="px-5 py-4 font-medium sm:px-7">Actions</th></tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((user) => <tr key={user.id}>
<td className="sticky left-0 z-10 max-w-52 bg-surface px-5 py-4 sm:px-7">
<div className="flex items-center gap-2"><UserRound aria-hidden="true" className="h-4 w-4 shrink-0 text-text-muted" /><span className="break-words font-medium">{user.name || "Unnamed user"}</span></div>
{user.id === currentUserId && <span className="mt-1 block pl-6 text-xs text-text-muted">You</span>}
</td>
<td className="max-w-64 break-all px-4 py-4 text-text-muted">{user.email}</td>
<td className="px-4 py-4 whitespace-nowrap">
{user.id === currentUserId ? <span className="text-sm">Full</span> : <form action={action}>
<input type="hidden" name="userId" value={user.id} />
<select name="operation" defaultValue={user.access} disabled={pending} aria-label={`Access for ${user.name || user.email}`}
onChange={(e) => e.currentTarget.form?.requestSubmit()} className="min-h-11 rounded-lg border border-border bg-surface px-2 text-sm">
<option value="full">Full</option><option value="clock">Clock app only</option>
</select>
</form>}
</td>
<td className="px-4 py-4">
<span className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ${user.active ? "bg-[#00798C]/10 text-accent-ink" : "bg-gray-100 text-text-muted dark:bg-white/5"}`}><span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${user.active ? "bg-[#00798C]" : "bg-gray-400"}`} />{user.active ? "Active" : "Inactive"}</span>
{user.active && !user.passwordChanged && <p className="mt-1 text-xs text-text-muted">First sign-in pending</p>}
</td>
<td className="px-5 py-4 sm:px-7">
{user.id === currentUserId ? <span className="text-xs text-text-muted">Current account</span> : <div className="flex items-center gap-2 whitespace-nowrap">
<form action={action}>
<input type="hidden" name="userId" value={user.id} /><input type="hidden" name="operation" value={user.active ? "deactivate" : "activate"} />
<button disabled={pending} type="submit" aria-label={`${user.active ? "Make inactive" : "Reactivate"}: ${user.name || user.email}`} className="min-h-11 rounded-lg border border-border px-3 text-sm font-medium transition hover:bg-gray-100 disabled:opacity-50 dark:hover:bg-white/5">{user.active ? "Make inactive" : "Reactivate"}</button>
</form>
<button disabled={pending} type="button" onClick={() => { setDeleteAttempted(false); setDeleting(user); }} aria-label={`Delete user: ${user.name || user.email}`} className="flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm text-brick-ink transition hover:bg-[#D1495B]/10 disabled:opacity-50"><Trash2 className="h-4 w-4" />Delete</button>
</div>}
</td>
</tr>)}
</tbody>
</table>
</div>
<ScrollFade />
</div>
<p className="border-t border-border px-5 py-4 text-xs leading-relaxed text-text-muted sm:px-7">Making a user inactive keeps their account and blocks access to the app. Reactivate them here whenever they need access again.</p>
<Dialog.Root open={!!deleting} onOpenChange={(open) => { if (!open && !pending) setDeleting(null); }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-[1300] bg-black/40" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-[1301] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<Dialog.Title className="text-lg font-semibold">Delete this user?</Dialog.Title>
<Dialog.Description className="mt-3 break-words text-sm leading-relaxed text-text-muted">{deleting?.name || "This user"} ({deleting?.email}) will lose access and their account will be permanently removed. You can make them inactive instead if they may return.</Dialog.Description>
{deleteAttempted && !pending && state.error && <p role="alert" className="mt-3 text-sm text-brick-ink">{state.error}</p>}
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Dialog.Close asChild><button type="button" disabled={pending} className="min-h-11 rounded-lg border border-border px-4 text-sm disabled:opacity-50">Cancel</button></Dialog.Close>
<form action={action} onSubmit={() => setDeleteAttempted(true)}>
<input type="hidden" name="userId" value={deleting?.id || ""} /><input type="hidden" name="operation" value="delete" />
<button type="submit" disabled={pending} className="flex min-h-11 items-center gap-2 rounded-lg bg-[#D1495B] px-4 text-sm font-medium text-white disabled:opacity-50">{pending && <LoaderCircle className="h-4 w-4 animate-spin" />}{pending ? "Deleting…" : "Delete user"}</button>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</section>;
}