+
+
+
Mode
+
+ {identity.mode === "not_exposed"
+ ? "Your service is not discoverable through a DID document. Other services cannot resolve your endpoints."
+ : "Determines how your service identity is published and verified on the AT Protocol network."}
+
- {formatMode(identity.mode)}
+
+
+ {formatMode(identity.mode)}
-
-
+
+
+
+
DID
+
+ Your decentralized identifier — a globally unique address
+ that other services use to find and verify this AppView.
+
-
- {identity.did ?? (identity.mode === "did_web" ? `did:web:${window.location.host}` : not set )}
-
-
-
-
+
+
+ {identity.did ?? (identity.mode === "did_web" ? `did:web:${typeof window !== "undefined" ? window.location.host : "…"}` : not set )}
+
+
+
+
+
Status
+
+ {identity.setup_complete
+ ? "Setup is complete and your identity is ready to use."
+ : "Setup has not been completed. Run the setup wizard to finish configuring your identity."}
+
-
+
+
+
{identity.setup_complete ? "Complete" : "Incomplete"}
-
+
-
- )}
-
+
+
+ ) : (
+
+
No identity configured.
+
router.push("/setup")}>
+ Run Setup Wizard
+
+
+ )}
+
+
-
+ {/* Action bar */}
+
Service Entries
-
- Entries in this service's DID document that define access to
- XRPC endpoints.
-
+
+ {canManage && (
+
+
+
+ Change Mode
+
+
+
+
+ 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
+
+
+
+
+
Sync to PLC Directory
+
+ Publish your current service entries. This signs and
+ submits a PLC update operation. Changes take effect
+ immediately.
+
+
+ setSyncPopoverOpen(false)}
+ >
+ Cancel
+
+
+ {syncing ? "Syncing…" : "Sync Now"}
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ Sync
+
+
+
+
+ Your DID document is up to date.
+
+
+ )
+ ) : needsSync ? (
+
setSyncSheetOpen(true)}
+ >
+
+
+ Sync
+
+ ) : (
+
+
+
+
+
+ Sync
+
+
+
+
+ Your DID document is up to date.
+
+
+ )
+ )}
+ {canManage && (
+
+
+ setAddSheetOpen(true)}>
+
+ Add Service Entry
+
+
+ {MOD_KEY}N
+
+ )}
+
-
-
-
-
- Fragment ID
- Type
- XRPC Access
-
-
-
-
- {entries.length === 0 && (
-
- 0 && (
+
+
+ {selected.size} {filterQuery ? `of ${entries.length} ` : ""}{selected.size === 1 ? "entry" : "entries"} selected
+
+
+
+
+
+ {bulkDeleting ? "Deleting…" : "Delete 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"}
+
+
+
+
+
setSelected(new Set())}
+ >
+ Clear Selection
+
+
+ )}
+
+ {/* 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) => (
-
-
- handleEntryClick(entry)}
+
+
+ {[1, 2].map((i) => (
+
+ {canManage && }
+
+
+
+ {canManage && }
+
+ ))}
+
+
+
+ ) : 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 && (
+
setAddSheetOpen(true)}
+ >
+
+ Add Service Entry
+
+ )}
+
+ ) : (
+
+
+
+
+ {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}”
+
handleDelete(entry.id)}
- aria-label={`Delete ${entry.fragment_id}`}
+ size="sm"
+ className="mt-2"
+ onClick={() => setFilterQuery("")}
>
-
+ Clear filter
- )}
-
-
- ))}
-
-
-
-
- {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" && (
-
-
- {syncing ? "Syncing..." : "Sync Now"}
-
-
- )}
-
- {identity.mode === "attach_account" && !codeRequested && (
-
-
- A confirmation code will be sent to the attached
- account's email address.
-
-
-
(
+
+ {canManage && (
+
+ toggleSelect(entry.id)}
+ aria-label={`Select ${entry.fragment_id}`}
+ />
+
+ )}
+
+ handleEntryClick(entry)}
>
- {requestingCode ? "Sending..." : "Request Code"}
-
-
-
- )}
+ {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 && (
-
-
- Confirmation Code
- setPlcToken(e.target.value)}
- placeholder="Enter the code from your email"
- />
-
-
- {
- setCodeRequested(false);
- setPlcToken("");
- setSyncError(null);
- setSyncSuccess(null);
- }}
- >
- Cancel
-
-
- {submittingToken ? "Submitting..." : "Submit"}
-
-
-
- )}
-
-
- )}
+ {/* 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.
+
+
+
+
-
Fragment ID
+
+
+ Fragment ID
+
+ A short identifier prefixed with # that names this service
+ in the DID document. Common values:{" "}
+ #atproto_pds,{" "}
+ #bsky_appview.
+
+
+
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.
+
+ )}
- Service Type
+
+
+ Service Type
+
+ The AT Protocol service type identifier. Examples:{" "}
+ AtprotoPersonalDataServer,{" "}
+ BskyAppView,{" "}
+ BskyLabeler.
+
+
+
setServiceType(e.target.value)}
+ onKeyDown={handleAddKeyDown}
placeholder="AtprotoPersonalDataServer"
/>
-
-
- {adding ? "Adding..." : "Add"}
-
-
- )}
-
- {selectedEntry && (
-
- )}
+
+
+ {adding ? "Adding…" : "Add Entry"}
+
+
+
+
+
+ {/* 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.
+
+
+ {requestingCode ? "Sending…" : "Request Code"}
+
+
+ )}
+
+ {codeRequested && (
+
+
+ Check the inbox for the attached account's email.
+ The code expires after a few minutes.
+
+
+ Confirmation Code
+ setPlcToken(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && plcToken.trim() && !submittingToken) {
+ handleSyncPlcSubmit();
+ }
+ }}
+ placeholder="Enter the code from your email"
+ />
+
+
+ {
+ setCodeRequested(false);
+ setPlcToken("");
+ }}
+ >
+ Cancel
+
+
+ {submittingToken ? "Submitting…" : "Sync"}
+
+
+ {requestingCode ? "Sending…" : "Resend Code"}
+
+
+
+ )}
+
+
+
>
);
}
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({
-
-
- {deleting ? "Deleting..." : "Delete Service"}
-
+
+
+
+
+ Delete Service
+
+
+
+
+
+ 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"}
+
+
+
+
- {saving ? "Saving..." : "Save"}
+ {saving ? "Saving…" : "Save"}
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 = {},