From c5111c73ed382ac79f600f1ae1c3687551013c91 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 23 Jun 2026 13:51:47 -0500 Subject: [PATCH] fix: make the service entry dashboard more user friendly Signed-off-by: Trezy Signed-off-by: Trezy --- .../settings/service-identity/page.tsx | 1094 +++++++++++++---- web/src/components/service-entry-sheet.tsx | 92 +- web/src/lib/format.ts | 32 + 3 files changed, 917 insertions(+), 301 deletions(-) diff --git a/web/src/app/dashboard/settings/service-identity/page.tsx b/web/src/app/dashboard/settings/service-identity/page.tsx index 24d4b76..b4bd413 100644 --- a/web/src/app/dashboard/settings/service-identity/page.tsx +++ b/web/src/app/dashboard/settings/service-identity/page.tsx @@ -1,10 +1,12 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Trash2 } from "lucide-react"; +import { AlertTriangle, HelpCircle, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { useCurrentUser } from "@/hooks/use-current-user"; +import { toastError } from "@/lib/format"; import { getServiceIdentity, getServiceEntries, @@ -32,15 +34,24 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { Table, TableBody, @@ -49,6 +60,16 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const SYNC_STORAGE_KEY = "happyview:service-identity:last-synced-at"; +const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone/.test(navigator.userAgent); +const MOD_KEY = IS_MAC ? "⌘" : "Ctrl+"; +const FRAGMENT_ID_RE = /^#?[a-zA-Z][a-zA-Z0-9_-]*$/; function formatMode(mode: string): string { switch (mode) { @@ -60,34 +81,90 @@ function formatMode(mode: string): string { } } +function HelpTip({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + + + + {children} + + + ); +} + +function getLastSyncedAt(): string | null { + try { + return localStorage.getItem(SYNC_STORAGE_KEY); + } catch { + return null; + } +} + +function setLastSyncedAt() { + try { + localStorage.setItem(SYNC_STORAGE_KEY, new Date().toISOString()); + } catch { + // localStorage unavailable + } +} + export default function ServiceIdentityPage() { const router = useRouter(); const { hasPermission } = useCurrentUser(); const canManage = hasPermission("settings:manage"); const [changingMode, setChangingMode] = useState(false); + const [loading, setLoading] = useState(true); - const [identity, setIdentity] = useState( - null, - ); + const [identity, setIdentity] = useState(null); const [entries, setEntries] = useState([]); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); const [fragmentId, setFragmentId] = useState(""); const [serviceType, setServiceType] = useState(""); const [adding, setAdding] = useState(false); + const [addSheetOpen, setAddSheetOpen] = useState(false); + const [filterQuery, setFilterQuery] = useState(""); const [selectedEntry, setSelectedEntry] = useState(null); - const [sheetOpen, setSheetOpen] = useState(false); + const [editSheetOpen, setEditSheetOpen] = useState(false); - // PLC sync state + // PLC sync state — did_plc uses a popover, attach_account uses a sheet + const [syncPopoverOpen, setSyncPopoverOpen] = useState(false); + const [syncSheetOpen, setSyncSheetOpen] = useState(false); const [syncing, setSyncing] = useState(false); - const [syncSuccess, setSyncSuccess] = useState(null); - const [syncError, setSyncError] = useState(null); const [requestingCode, setRequestingCode] = useState(false); const [codeRequested, setCodeRequested] = useState(false); const [plcToken, setPlcToken] = useState(""); const [submittingToken, setSubmittingToken] = useState(false); + const [sessionDirty, setSessionDirty] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [bulkDeleting, setBulkDeleting] = useState(false); + + const fragmentIdRef = useRef(null); + + const fragmentIdError = fragmentId.trim() && !FRAGMENT_ID_RE.test(fragmentId.trim()) + ? "Must start with a letter and contain only letters, numbers, hyphens, and underscores." + : null; + + const filteredEntries = useMemo(() => { + if (!filterQuery) return entries; + const q = filterQuery.toLowerCase(); + return entries.filter( + (e) => + e.fragment_id.toLowerCase().includes(q) || + e.service_type.toLowerCase().includes(q), + ); + }, [entries, filterQuery]); + + const needsSync = useMemo(() => { + if (sessionDirty) return true; + const lastSynced = getLastSyncedAt(); + if (!lastSynced || entries.length === 0) return false; + return entries.some((e) => e.updated_at > lastSynced); + }, [entries, sessionDirty]); const load = useCallback(async () => { try { @@ -98,7 +175,9 @@ export default function ServiceIdentityPage() { setIdentity(id); setEntries(ents); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to load service identity", e); + } finally { + setLoading(false); } }, []); @@ -106,367 +185,850 @@ export default function ServiceIdentityPage() { load(); }, [load]); + useEffect(() => { + if (selected.size === 0) return; + const validIds = new Set(entries.map((e) => e.id)); + setSelected((prev) => { + const pruned = new Set([...prev].filter((id) => validIds.has(id))); + return pruned.size === prev.size ? prev : pruned; + }); + }, [entries]); + + useEffect(() => { + if (!canManage) return; + function onKeyDown(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + + if (e.key === "n") { + e.preventDefault(); + setAddSheetOpen(true); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [canManage]); + + const showSyncButton = canManage && identity && + (identity.mode === "did_plc" || identity.mode === "attach_account"); + async function handleAdd() { - setError(null); - setNotice(null); setAdding(true); try { const fid = fragmentId.startsWith("#") ? fragmentId : `#${fragmentId}`; await createServiceEntry({ fragment_id: fid, service_type: serviceType }); setFragmentId(""); setServiceType(""); - setNotice("Service entry added."); + setAddSheetOpen(false); + setSessionDirty(true); + toast.success("Service entry added", { + description: "Sync to the PLC directory to publish this change.", + }); await load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to add service entry", e); } finally { setAdding(false); } } - async function handleDelete(id: number) { - setError(null); - setNotice(null); + async function handleDelete(entry: ServiceEntry) { try { - await deleteServiceEntry(id); - setNotice("Service entry deleted."); + await deleteServiceEntry(entry.id); + setSelected((prev) => { + if (!prev.has(entry.id)) return prev; + const next = new Set(prev); + next.delete(entry.id); + return next; + }); + setSessionDirty(true); + toast.success(`Deleted ${entry.fragment_id}`, { + description: "Sync to the PLC directory to publish this change.", + }); await load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to delete service entry", e); } } function handleEntryClick(entry: ServiceEntry) { setSelectedEntry(entry); - setSheetOpen(true); + setEditSheetOpen(true); + } + + function handleEntrySaved() { + setSessionDirty(true); + load(); + } + + function toggleSelectAll() { + if (selected.size === filteredEntries.length) { + setSelected(new Set()); + } else { + setSelected(new Set(filteredEntries.map((e) => e.id))); + } + } + + function toggleSelect(id: number) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); } + async function handleBulkDelete() { + setBulkDeleting(true); + const ids = Array.from(selected); + const results = await Promise.allSettled(ids.map((id) => deleteServiceEntry(id))); + const succeeded = ids.filter((_, i) => results[i].status === "fulfilled"); + const failed = ids.length - succeeded.length; + + if (succeeded.length > 0) { + setSelected((prev) => { + const next = new Set(prev); + for (const id of succeeded) next.delete(id); + return next; + }); + setSessionDirty(true); + } + + if (failed === 0) { + toast.success(`Deleted ${succeeded.length} service ${succeeded.length === 1 ? "entry" : "entries"}`, { + description: "Sync to the PLC directory to publish this change.", + }); + } else if (succeeded.length === 0) { + toast.error("Failed to delete service entries"); + } else { + toast.warning(`Deleted ${succeeded.length} of ${ids.length} entries`, { + description: `${failed} ${failed === 1 ? "entry" : "entries"} failed to delete.`, + }); + } + + await load(); + setBulkDeleting(false); + } + + const allSelected = filteredEntries.length > 0 && selected.size === filteredEntries.length; + const someSelected = selected.size > 0 && selected.size < filteredEntries.length; + async function handleSyncPlc() { - setSyncError(null); - setSyncSuccess(null); setSyncing(true); try { await syncPlc(); - setSyncSuccess("DID document synced to PLC directory."); + setSessionDirty(false); + setLastSyncedAt(); + setSyncPopoverOpen(false); + toast.success("DID document synced", { + description: "Your service entries are now published to the PLC directory.", + }); } catch (e: unknown) { - setSyncError(e instanceof Error ? e.message : String(e)); + toastError("Failed to sync to PLC directory", e); } finally { setSyncing(false); } } async function handleSyncPlcRequest() { - setSyncError(null); - setSyncSuccess(null); setRequestingCode(true); try { await syncPlcRequest(); setCodeRequested(true); - setSyncSuccess("Confirmation code sent to the attached account's email."); + toast.success("Confirmation code sent", { + description: "Check the inbox for the attached account's email.", + }); } catch (e: unknown) { - setSyncError(e instanceof Error ? e.message : String(e)); + toastError("Failed to request confirmation code", e); } finally { setRequestingCode(false); } } async function handleSyncPlcSubmit() { - setSyncError(null); - setSyncSuccess(null); setSubmittingToken(true); try { await syncPlcSubmit(plcToken); - setSyncSuccess("DID document synced to PLC directory."); + setSessionDirty(false); + setLastSyncedAt(); + setSyncSheetOpen(false); setCodeRequested(false); setPlcToken(""); + toast.success("DID document synced", { + description: "Your service entries are now published to the PLC directory.", + }); } catch (e: unknown) { - setSyncError(e instanceof Error ? e.message : String(e)); + toastError("Failed to submit confirmation code", e); } finally { setSubmittingToken(false); } } async function handleConfirmChangeMode() { - setError(null); setChangingMode(true); try { await updateServiceIdentity({ mode: "not_exposed" }); router.push("/setup"); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to change identity mode", e); setChangingMode(false); } } + function handleAddKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && fragmentId.trim() && serviceType.trim() && !adding && !fragmentIdError) { + handleAdd(); + } + } + return ( <> -
- {error &&

{error}

} - {notice && ( -

{notice}

- )} +
- - -
-
- Identity Configuration - - {identity - ? formatMode(identity.mode) - : "No identity configured"} - + {/* Identity metadata grid */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ +
- {canManage && ( - - - - - - - Change identity mode? - - Changing identity mode will reset your service identity - configuration. Service entries will be preserved but the - DID and signing keys will be regenerated. - - - - Cancel - - {changingMode ? "Resetting…" : "Continue"} - - - - - )} -
- - {identity && ( - -
- + ))} +
+ ) : identity ? ( +
+
+ +
+ {formatMode(identity.mode)}
-
- +
+
+
-
- + +

+ {identity.did ?? (identity.mode === "did_web" ? `did:web:${typeof window !== "undefined" ? window.location.host : "…"}` : not set)} +

+
+
+ +
+ {identity.setup_complete ? "Complete" : "Incomplete"} - +
- - )} - +
+
+ ) : ( +
+

No identity configured.

+ +
+ )} + + -
+ {/* Action bar */} +

Service Entries

-

- Entries in this service's DID document that define access to - XRPC endpoints. -

+
+ {canManage && ( + + + + + + + Change identity mode? + +
+

+ This will reset your service identity configuration + and redirect you to the setup wizard. +

+

+ What changes: +

+
    +
  • DID and signing keys will be regenerated
  • +
  • PLC directory state will need to be re-synced
  • +
+

+ What stays: +

+
    +
  • Service entries are preserved
  • +
  • Records and lexicons are unaffected
  • +
+
+
+
+ + Cancel + + {changingMode ? "Resetting…" : "Continue"} + + +
+
+ )} + {showSyncButton && ( + identity?.mode === "did_plc" ? ( + needsSync ? ( + + + + + +
+

Sync to PLC Directory

+

+ Publish your current service entries. This signs and + submits a PLC update operation. Changes take effect + immediately. +

+
+ + +
+
+
+
+ ) : ( + + + + + + + + Your DID document is up to date. + + + ) + ) : needsSync ? ( + + ) : ( + + + + + + + + Your DID document is up to date. + + + ) + )} + {canManage && ( + + + + + {MOD_KEY}N + + )} +
-
- - - - Fragment ID - Type - XRPC Access - - - - - {entries.length === 0 && ( - - 0 && ( +
+ + {selected.size} {filterQuery ? `of ${entries.length} ` : ""}{selected.size === 1 ? "entry" : "entries"} selected + + + + + + + + + Delete {selected.size} service {selected.size === 1 ? "entry" : "entries"}? + + + This will remove the selected service entries from your + configuration. Changes take effect in the DID document + after your next PLC sync. + + + + Cancel + - No service entries yet. - + {bulkDeleting ? "Deleting…" : "Delete"} + + + + + +
+ )} + + {/* Filter */} + {!loading && entries.length > 0 && ( +
+ + setFilterQuery(e.target.value)} + placeholder="Filter entries…" + aria-label="Filter service entries" + className="pl-8 h-9" + /> +
+ )} + + {/* Service entries table */} + {loading ? ( +
+
+ + + {canManage && } + Fragment ID + Type + XRPC Access + {canManage && } - )} - {entries.map((entry) => ( - - -
+
+ ) : entries.length === 0 ? ( +
+

+ No service entries yet. +

+

+ Service entries define which XRPC endpoints are accessible through + your DID document. Each entry maps a fragment identifier to a + service type. +

+ {canManage && ( + + )} +
+ ) : ( +
+ + + + {canManage && ( + + + + )} + + + Fragment ID + + A unique identifier within the DID document + (e.g. #atproto_pds). + Used by clients to locate this service endpoint. + + + + + + Type + + The AT Protocol service type this entry represents + (e.g. AtprotoPersonalDataServer, BskyAppView). + + + + + + XRPC Access + + Controls which XRPC methods this service can handle. + "All" allows every method; "Specific" restricts + to an allowlist. + + + + {canManage && } + + + + {filteredEntries.length === 0 && filterQuery && ( + + - {entry.fragment_id} - - - {entry.service_type} - - - {entry.access_mode === "all" ? "All XRPCs" : "Specific"} - - - - {canManage && ( +

+ No entries match “{filterQuery}” +

- )} -
-
- ))} -
-
-
- - {canManage && - identity && - (identity.mode === "did_plc" || identity.mode === "attach_account") && ( - - - Sync to PLC Directory - - After adding or removing service entries, sync to update your - DID document in the PLC directory. - - - - {syncError && ( -

{syncError}

+ + )} - {syncSuccess && ( -

- {syncSuccess} -

- )} - - {identity.mode === "did_plc" && ( -
- -
- )} - - {identity.mode === "attach_account" && !codeRequested && ( -
-

- A confirmation code will be sent to the attached - account's email address. -

-
- -
-
- )} + {entry.fragment_id} + + + {entry.service_type} + + + {entry.access_mode === "all" ? "All XRPCs" : "Specific"} + + + {canManage && ( + + + + + + + + + Delete {entry.fragment_id}? + + + This will remove the service entry from your + configuration. The change will take effect in the + DID document after your next PLC sync. + + + + Cancel + handleDelete(entry)} + > + Delete + + + + + + )} + + ))} + + +
+ )} +
- {identity.mode === "attach_account" && codeRequested && ( -
-
- - setPlcToken(e.target.value)} - placeholder="Enter the code from your email" - /> -
-
- - -
-
- )} -
- - )} + {/* Edit service entry sheet */} + {selectedEntry && ( + + )} - {canManage && ( -
-

Add Service Entry

+ {/* Add service entry sheet */} + { + setAddSheetOpen(open); + if (!open) { + setFragmentId(""); + setServiceType(""); + } else { + requestAnimationFrame(() => fragmentIdRef.current?.focus()); + } + }}> + + + Add Service Entry + + Register a new service endpoint in your DID document. + + + +
- + setFragmentId(e.target.value)} + onKeyDown={handleAddKeyDown} placeholder="#atproto_pds" /> -

- The fragment identifier (e.g. #atproto_pds). A{" "} - # will be prepended automatically if omitted. -

+ {fragmentIdError ? ( +

{fragmentIdError}

+ ) : ( +

+ A # will be prepended automatically if omitted. +

+ )}
- + setServiceType(e.target.value)} + onKeyDown={handleAddKeyDown} placeholder="AtprotoPersonalDataServer" />
-
- -
- )} -
- {selectedEntry && ( - - )} + + + + + + + {/* Sync PLC sheet — attach_account mode only (did_plc uses popover) */} + { + setSyncSheetOpen(open); + if (!open) { + setCodeRequested(false); + setPlcToken(""); + } + }}> + + + Sync to PLC Directory + + Publish your current service entries to the{" "} + + PLC directory + + . This updates your public DID document so clients can discover + your service endpoints. + + + +
+ {!codeRequested && ( +
+

+ Because this identity is attached to an existing account, + syncing requires a confirmation code sent to the + account's email address. +

+ +
+ )} + + {codeRequested && ( +
+

+ Check the inbox for the attached account's email. + The code expires after a few minutes. +

+
+ + setPlcToken(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && plcToken.trim() && !submittingToken) { + handleSyncPlcSubmit(); + } + }} + placeholder="Enter the code from your email" + /> +
+
+ + + +
+
+ )} +
+
+
); } diff --git a/web/src/components/service-entry-sheet.tsx b/web/src/components/service-entry-sheet.tsx index fac698e..f8a097c 100644 --- a/web/src/components/service-entry-sheet.tsx +++ b/web/src/components/service-entry-sheet.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from "react"; import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { toastError } from "@/lib/format"; import { getServiceEntryXrpcs, updateServiceEntry, @@ -11,6 +13,17 @@ import { deleteServiceEntry, type ServiceEntry, } from "@/lib/api"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; @@ -49,7 +62,6 @@ export function ServiceEntrySheet({ const [selected, setSelected] = useState>(new Set()); const [newXrpc, setNewXrpc] = useState(""); const [adding, setAdding] = useState(false); - const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); @@ -59,7 +71,7 @@ export function ServiceEntrySheet({ const list = await getServiceEntryXrpcs(entry.id); setXrpcs(list); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to load XRPC list", e); } }, [entry.id, accessMode]); @@ -67,7 +79,6 @@ export function ServiceEntrySheet({ if (open) { setAccessMode(entry.access_mode); setSelected(new Set()); - setError(null); } }, [open, entry]); @@ -99,55 +110,55 @@ export function ServiceEntrySheet({ async function handleRemoveSelected() { if (selected.size === 0) return; - setError(null); try { await removeServiceEntryXrpcs(entry.id, Array.from(selected)); + toast.success(`Removed ${selected.size} XRPC${selected.size > 1 ? "s" : ""}`); setSelected(new Set()); await loadXrpcs(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to remove XRPCs", e); } } async function handleAddXrpc() { const value = newXrpc.trim(); if (!value) return; - setError(null); setAdding(true); try { await addServiceEntryXrpcs(entry.id, [value]); + toast.success(`Added ${value}`); setNewXrpc(""); await loadXrpcs(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to add XRPC", e); } finally { setAdding(false); } } async function handleSave() { - setError(null); setSaving(true); try { await updateServiceEntry(entry.id, { access_mode: accessMode }); + toast.success("Service entry updated"); onSaved(); onOpenChange(false); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to update service entry", e); } finally { setSaving(false); } } async function handleDelete() { - setError(null); setDeleting(true); try { await deleteServiceEntry(entry.id); + toast.success(`Deleted ${entry.fragment_id}`); onSaved(); onOpenChange(false); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to delete service entry", e); } finally { setDeleting(false); } @@ -165,8 +176,6 @@ export function ServiceEntrySheet({
- {error &&

{error}

} -

XRPC Access

@@ -211,15 +220,7 @@ export function ServiceEntrySheet({ { - if (el) - ( - el as HTMLButtonElement & { - indeterminate: boolean; - } - ).indeterminate = someSelected; - }} + checked={allSelected || (someSelected && "indeterminate")} onCheckedChange={toggleSelectAll} aria-label="Select all" /> @@ -234,8 +235,8 @@ export function ServiceEntrySheet({ colSpan={2} className="text-muted-foreground text-center text-sm" > - No XRPCs configured. Add XRPCs that this service can - access. + No XRPCs configured. Add methods below that this + service entry can access. )} @@ -273,7 +274,7 @@ export function ServiceEntrySheet({ onClick={handleAddXrpc} disabled={adding || !newXrpc.trim()} > - {adding ? "Adding..." : "Add"} + {adding ? "Adding…" : "Add"}
@@ -281,17 +282,38 @@ export function ServiceEntrySheet({
- + + + + + + + + Delete {entry.fragment_id}? + + + This will permanently remove the service entry and its XRPC + configuration. The change will take effect in the DID document + after your next PLC sync. + + + + Cancel + + {deleting ? "Deleting…" : "Delete"} + + + + diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 0e6b885..93e6fc1 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,3 +1,35 @@ +import { toast } from "sonner"; + +export function toastError(context: string, e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + const lower = msg.toLowerCase(); + if (lower.includes("unique") || lower.includes("duplicate") || lower.includes("already exists")) { + toast.error(`${context}: already exists`, { + description: "An entry with this identifier is already configured.", + }); + return; + } + if (lower.includes("network") || lower.includes("fetch") || lower.includes("econnrefused")) { + toast.error(`${context}: connection failed`, { + description: "Check that the server is running and try again.", + }); + return; + } + if (lower.includes("unauthorized") || lower.includes("403") || lower.includes("forbidden")) { + toast.error(`${context}: permission denied`, { + description: "You may not have the required permissions for this action.", + }); + return; + } + if (lower.includes("timeout")) { + toast.error(`${context}: request timed out`, { + description: "The server took too long to respond. Try again in a moment.", + }); + return; + } + toast.error(context, { description: msg }); +} + export function formatDate( date: Date | string | number | undefined, opts: Intl.DateTimeFormatOptions = {}, -- 2.51.2