diff --git a/web/app/actions.ts b/web/app/actions.ts index eb14538..4b5da62 100644 --- a/web/app/actions.ts +++ b/web/app/actions.ts @@ -25,3 +25,23 @@ export async function updateUser(data: UserUpdate): Promise { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } + +export async function deleteAccount(username: string): Promise { + try { + await fetchApi("/delete-acc-data", { method: "POST", body: { username } }); + revalidatePath("/"); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function deleteUser(f_username: string): Promise { + try { + await fetchApi("/delete-user-data", { method: "POST", body: { f_username } }); + revalidatePath("/users"); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/web/components/accounts-table.tsx b/web/components/accounts-table.tsx index 1fa886d..c8b08a2 100644 --- a/web/components/accounts-table.tsx +++ b/web/components/accounts-table.tsx @@ -3,11 +3,11 @@ import { useMemo, useOptimistic, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import type { Acc } from "@/lib/types"; -import { updateAccount } from "@/app/actions"; +import { deleteAccount, updateAccount } from "@/app/actions"; import EditableCell from "./editable-cell"; +import ConfirmDialog from "./confirm-dialog"; type Props = { initial: Acc[]; prefixPattern: string }; - type SortDir = "asc" | "desc"; type OptimisticPatch = { username: string; field: keyof Acc; value: string }; @@ -26,25 +26,49 @@ function sortAccounts(rows: Acc[], dir: SortDir, prefix: string): Acc[] { function StatusBadge({ status }: { status: string }) { const map: Record = { "": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" }, - wait: { bg: "bg-amber-100", fg: "text-amber-800", label: "wait" }, - done: { bg: "bg-emerald-100", fg: "text-emerald-800", label: "done" }, + wait: { bg: "bg-amber-100", fg: "text-amber-700", label: "wait" }, + done: { bg: "bg-emerald-100", fg: "text-emerald-700", label: "done" }, }; const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" }; return ( {v.label} ); } +function DeleteButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} + export default function AccountsTable({ initial, prefixPattern }: Props) { const router = useRouter(); const [sortDir, setSortDir] = useState("desc"); const [editingKey, setEditingKey] = useState(null); const [, startTransition] = useTransition(); const [refreshing, setRefreshing] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const [optimistic, applyOptimistic] = useOptimistic( initial, @@ -64,14 +88,10 @@ export default function AccountsTable({ initial, prefixPattern }: Props) { startTransition(async () => { applyOptimistic({ username, field, value }); const row = initial.find((r) => r.username === username); - if (!row) { - resolve({ ok: false, error: "row not found" }); - return; - } + if (!row) return resolve({ ok: false, error: "row not found" }); const next: Acc = { ...row, [field]: value }; const result = await updateAccount(next); - if (result.ok) resolve({ ok: true }); - else resolve({ ok: false, error: result.error }); + resolve(result.ok ? { ok: true } : { ok: false, error: result.error }); }); }); } @@ -84,14 +104,25 @@ export default function AccountsTable({ initial, prefixPattern }: Props) { }); } + async function confirmDelete() { + if (!deleteTarget) return; + setDeleting(true); + setDeleteError(null); + const result = await deleteAccount(deleteTarget); + setDeleting(false); + if (result.ok) { + setDeleteTarget(null); + } else { + setDeleteError(result.error); + } + } + if (initial.length === 0) { return ( -
-

- Accounts -

-
-

+

+ +
+

No accounts yet. The monitor will create some on the next run.

@@ -100,76 +131,39 @@ export default function AccountsTable({ initial, prefixPattern }: Props) { } return ( -
-
-
-

- // table -

-

- Accounts -

-
-
-
- - count - - - {optimistic.length} - -
- -
-
+
+ -
+ {/* Desktop / tablet table */} +
- - + - - - + + + + - + {sorted.map((row) => { const k = (f: string) => `${row.username}::${f}`; return ( - - + - - - + ); })} @@ -209,20 +212,29 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
+
- password - - status - - link - PasswordStatusLink
+
{row.username} + saveCell(row.username, "password", v)} /> +
+ saveCell(row.username, "link", v)} /> + { + setDeleteError(null); + setDeleteTarget(row.username); + }} + /> +
+ {/* Mobile cards */}
{sorted.map((row) => { const k = (f: string) => `${row.username}::${f}`; return ( -
-
- {row.username} - +
+
+ + {row.username} + +
+ + { + setDeleteError(null); + setDeleteTarget(row.username); + }} + /> +
-
-
- password -
-
+
+ setEditingKey(null)} onSave={(v) => saveCell(row.username, "password", v)} /> -
-
- status -
-
+ + setEditingKey(null)} onSave={(v) => saveCell(row.username, "status", v)} /> -
-
- link -
-
+ + setEditingKey(null)} onSave={(v) => saveCell(row.username, "link", v)} /> -
+
); })}
+ + { + if (!deleting) setDeleteTarget(null); + }} + onConfirm={confirmDelete} + title={`Delete ${deleteTarget ?? ""}?`} + message={ + <> +

+ Permanently remove{" "} + + {deleteTarget} + {" "} + from the accounts table. This action cannot be undone. +

+ {deleteError && ( +

+ {deleteError} +

+ )} + + } + confirmLabel="Delete" + destructive + pending={deleting} + /> +
+ ); +} + +function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +function CardRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function PageHead({ + count, + onRefresh, + refreshing, +}: { + count: number; + onRefresh: () => void; + refreshing: boolean; +}) { + return ( +
+
+

+ Table +

+

+ Accounts + + {count} + +

+
+
); } diff --git a/web/components/confirm-dialog.tsx b/web/components/confirm-dialog.tsx new file mode 100644 index 0000000..688a936 --- /dev/null +++ b/web/components/confirm-dialog.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type Props = { + open: boolean; + onCancel: () => void; + onConfirm: () => void; + title: string; + message: React.ReactNode; + confirmLabel?: string; + destructive?: boolean; + pending?: boolean; +}; + +/** + * Centered modal confirmation dialog. Uses the native element + * so we get Esc-to-close, focus trapping, and the ::backdrop pseudo for + * the scrim — no a11y tax we'd pay rolling our own. Backdrop click + * cancels. + */ +export default function ConfirmDialog({ + open, + onCancel, + onConfirm, + title, + message, + confirmLabel = "Confirm", + destructive = false, + pending = false, +}: Props) { + const ref = useRef(null); + + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; + if (open && !dialog.open) { + dialog.showModal(); + } else if (!open && dialog.open) { + dialog.close(); + } + }, [open]); + + return ( + { + // Click on the dialog background (not the inner form) cancels. + if (e.target === ref.current) onCancel(); + }} + className="m-auto w-[min(92vw,440px)] rounded-2xl bg-white p-0 ring-1 ring-zinc-200/60 backdrop:bg-zinc-900/50 backdrop:backdrop-blur-sm" + > +
{ + e.preventDefault(); + onConfirm(); + }} + className="flex flex-col gap-4 p-6" + > +

+ {title} +

+
{message}
+
+ + +
+
+
+ ); +} diff --git a/web/components/editable-cell.tsx b/web/components/editable-cell.tsx index 2eaa810..2193d02 100644 --- a/web/components/editable-cell.tsx +++ b/web/components/editable-cell.tsx @@ -25,13 +25,10 @@ export default function EditableCell({ const [isPending, startTransition] = useTransition(); const inputRef = useRef(null); - // Keep draft in sync if the underlying value changes from outside - // (auto-refresh, server revalidation) while we are NOT actively editing. useEffect(() => { if (!editing) setDraft(value); }, [value, editing]); - // Auto-clear an error after 3 seconds. useEffect(() => { if (!error) return; const id = setTimeout(() => setError(null), 3000); @@ -56,13 +53,12 @@ export default function EditableCell({ } function commit() { - const next = draft; - if (next === value) { + if (draft === value) { cancel(); return; } startTransition(async () => { - const result = await onSave(next); + const result = await onSave(draft); if (result.ok) { setEditing(false); setError(null); @@ -79,14 +75,14 @@ export default function EditableCell({ type="button" onClick={begin} aria-label={label ? `Edit ${label}` : undefined} - className="group relative -mx-1 -my-0.5 flex w-full min-w-0 items-start gap-2 rounded-sm px-1 py-0.5 text-left font-mono text-sm text-zinc-900 hover:bg-yellow-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400" + className="group flex w-full min-w-0 items-start gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900" > {value || } @@ -95,8 +91,8 @@ export default function EditableCell({ } return ( -
-
+
+
{error && ( -

+

{error}

)} diff --git a/web/components/users-table.tsx b/web/components/users-table.tsx index 920bfef..6d50556 100644 --- a/web/components/users-table.tsx +++ b/web/components/users-table.tsx @@ -3,14 +3,13 @@ import { useMemo, useOptimistic, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import type { User } from "@/lib/types"; -import { updateUser } from "@/app/actions"; +import { deleteUser, updateUser } from "@/app/actions"; import EditableCell from "./editable-cell"; +import ConfirmDialog from "./confirm-dialog"; type Props = { initial: User[]; prefixPattern: string }; - type SortDir = "asc" | "desc"; type SortKey = "f_username" | "last_update_time"; - type OptimisticPatch = { f_username: string; field: keyof Pick; @@ -45,7 +44,6 @@ function formatTime(t: string | null) { const d = new Date(t); if (Number.isNaN(d.getTime())) return t; return d.toLocaleString(undefined, { - year: "numeric", month: "short", day: "2-digit", hour: "2-digit", @@ -53,6 +51,27 @@ function formatTime(t: string | null) { }); } +function DeleteButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} + export default function UsersTable({ initial, prefixPattern }: Props) { const router = useRouter(); const [sortKey, setSortKey] = useState("last_update_time"); @@ -60,6 +79,9 @@ export default function UsersTable({ initial, prefixPattern }: Props) { const [editingKey, setEditingKey] = useState(null); const [, startTransition] = useTransition(); const [refreshing, setRefreshing] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const [optimistic, applyOptimistic] = useOptimistic( initial, @@ -83,10 +105,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) { startTransition(async () => { applyOptimistic({ f_username, field, value }); const row = initial.find((r) => r.f_username === f_username); - if (!row) { - resolve({ ok: false, error: "row not found" }); - return; - } + if (!row) return resolve({ ok: false, error: "row not found" }); const next = { f_username: row.f_username, f_password: row.f_password, @@ -95,8 +114,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) { [field]: value, }; const result = await updateUser(next); - if (result.ok) resolve({ ok: true }); - else resolve({ ok: false, error: result.error }); + resolve(result.ok ? { ok: true } : { ok: false, error: result.error }); }); }); } @@ -110,36 +128,35 @@ export default function UsersTable({ initial, prefixPattern }: Props) { } function toggleSort(k: SortKey) { - if (sortKey === k) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { + if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { setSortKey(k); setSortDir("desc"); } } - if (initial.length === 0) { - return ( -
-

- Users -

-
-

No users yet.

-
-
- ); + async function confirmDelete() { + if (!deleteTarget) return; + setDeleting(true); + setDeleteError(null); + const result = await deleteUser(deleteTarget); + setDeleting(false); + if (result.ok) { + setDeleteTarget(null); + } else { + setDeleteError(result.error); + } } - function HeaderButton({ k, label }: { k: SortKey; label: string }) { + function HeaderTh({ k, label }: { k: SortKey; label: string }) { const active = sortKey === k; return ( + if (initial.length === 0) { + return ( +
+ +
+

No users yet.

+ ); + } -
+ return ( +
+ + +
- - + - - - - + - + {sorted.map((row) => { const k = (f: string) => `${row.f_username}::${f}`; return ( - - + - - - - + ); })} @@ -255,20 +262,26 @@ export default function UsersTable({ initial, prefixPattern }: Props) { {sorted.map((row) => { const k = (f: string) => `${row.f_username}::${f}`; return ( -
-
- +
+
+ {row.f_username} - - {formatTime(row.last_update_time)} - +
+ + {formatTime(row.last_update_time)} + + { + setDeleteError(null); + setDeleteTarget(row.f_username); + }} + /> +
-
-
- from pw -
-
+
+ setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "f_password", v)} /> -
-
- to user -
-
+ + setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "t_username", v)} /> -
-
- to pw -
-
+ + setEditingKey(null)} onSave={(v) => saveCell(row.f_username, "t_password", v)} /> -
+
); })}
+ + { + if (!deleting) setDeleteTarget(null); + }} + onConfirm={confirmDelete} + title={`Delete ${deleteTarget ?? ""}?`} + message={ + <> +

+ Permanently remove the user pairing for{" "} + + {deleteTarget} + {" "} + from the users table. This action cannot be undone and only + removes the pairing — the underlying account row stays. +

+ {deleteError && ( +

+ {deleteError} +

+ )} + + } + confirmLabel="Delete" + destructive + pending={deleting} + /> +
+ ); +} + +function MobileRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function PageHead({ + count, + onRefresh, + refreshing, +}: { + count: number; + onRefresh: () => void; + refreshing: boolean; +}) { + return ( +
+
+

Table

+

+ Users + {count} +

+
+
); }
- +
+ - from password + + From password - to username + + To username - to password + + To password - + +
+
{row.f_username} + saveCell(row.f_username, "f_password", v)} /> + saveCell(row.f_username, "t_username", v)} /> + saveCell(row.f_username, "t_password", v)} /> + {formatTime(row.last_update_time)} + { + setDeleteError(null); + setDeleteTarget(row.f_username); + }} + /> +