Add PocketBase login, first-sign-in password changes, account settings, and user activation, reactivation and deletion with self-removal guards. Protect application pages and APIs and keep private pages out of PWA caches. Resolve plant locations from site assignments and improve map controls, profit chart views, navigation and mobile table layouts. Validation: production build and TypeScript passed; 28 unit tests passed. Backend access integration tests and mobile verification are documented.
80 lines
6.8 KiB
TypeScript
80 lines
6.8 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-[700px] 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">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">
|
|
<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>;
|
|
}
|