fix(web,adminsite): accessible dialogs, real confirmations, shared async UI
Four correctness/accessibility defects and the destructive-action flow. - Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg", a find/replace of "org" that landed inside a URL. Button also grows an href form, because <Link><Button> nested a button inside an anchor at nineteen call sites: invalid markup, two tab stops, and Enter firing only the anchor. - Fleet status was four meanings carried by hue with the distinction living in a title attribute, which touch never shows and screen readers need not announce. It now carries a text label and an accessible name, which is the one rule the design system states outright. - Modal had no focus management at all: no trap, no initial focus, no restore, no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so a stack decides which panel owns Escape and Tab. - Seven destructive actions went through window.confirm(). ConfirmDialog replaces them and can say what is about to happen; deleting a secret group, a shared base step or a workflow now requires typing the name, since those have no undo and a wide blast radius. adminsite keeps its own inline idiom rather than importing a dialog system it does not have. Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and friendlyMessage, replacing per-page loading ternaries and raw (error as Error).message text. Wired here only where a call site was already being edited; the remaining pages follow.
This commit is contained in:
@@ -164,11 +164,10 @@ export default function KeysPage() {
|
||||
<span className="text-text-secondary text-xs">{new Date(key.created_at).toLocaleDateString()}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${key.key_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View →
|
||||
</Button>
|
||||
</Link>
|
||||
<Button href={`/keys/${key.key_id}`} variant="ghost" size="sm">
|
||||
View <span aria-hidden="true">→</span>
|
||||
<span className="sr-only">{key.label}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
|
||||
@@ -283,9 +283,9 @@ export default function MonitorDetailPage() {
|
||||
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={`/monitors/${monitorId}/edit`}>
|
||||
<Button variant="secondary">Edit</Button>
|
||||
</Link>
|
||||
<Button href={`/monitors/${monitorId}/edit`} variant="secondary">
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" loading={isToggling} onClick={() => toggleEnabled(!monitor.enabled)}>
|
||||
{monitor.enabled ? "Pause checks" : "Resume checks"}
|
||||
</Button>
|
||||
@@ -384,11 +384,9 @@ export default function MonitorDetailPage() {
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary" size="sm" className="mt-4">
|
||||
Manage channels
|
||||
</Button>
|
||||
</Link>
|
||||
<Button href="/settings/notifications" variant="secondary" size="sm" className="mt-4">
|
||||
Manage channels
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface px-5 py-4">
|
||||
|
||||
@@ -138,12 +138,12 @@ export default function MonitorsPage() {
|
||||
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Monitors</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary">Notification channels</Button>
|
||||
</Link>
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="primary">New monitor</Button>
|
||||
</Link>
|
||||
<Button href="/settings/notifications" variant="secondary">
|
||||
Notification channels
|
||||
</Button>
|
||||
<Button href="/monitors/new" variant="primary">
|
||||
New monitor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,11 +158,9 @@ export default function MonitorsPage() {
|
||||
Add a check and Vantage records uptime and response time on your interval, opens an incident when it fails, and tells the
|
||||
channels you pick.
|
||||
</p>
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="primary" className="mt-4">
|
||||
Add your first check
|
||||
</Button>
|
||||
</Link>
|
||||
<Button href="/monitors/new" variant="primary" className="mt-4">
|
||||
Add your first check
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,18 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, Secret } from "@/lib/api";
|
||||
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import {
|
||||
AsyncBoundary,
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
TableSkeleton,
|
||||
friendlyMessage,
|
||||
useToast,
|
||||
} from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
@@ -116,17 +127,28 @@ spec:
|
||||
|
||||
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [revealed, setRevealed] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const { mutate: reveal, isPending: revealing } = useMutation({
|
||||
mutationFn: () => api.revealSecret(group, secret.key),
|
||||
onSuccess: (res) => setRevealed(res.value),
|
||||
onError: toast.error,
|
||||
});
|
||||
|
||||
const { mutate: remove, isPending: removing } = useMutation({
|
||||
const {
|
||||
mutate: remove,
|
||||
isPending: removing,
|
||||
error: removeError,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.deleteSecret(group, secret.key),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
|
||||
setConfirming(false);
|
||||
toast.success(`Deleted ${secret.key}.`);
|
||||
},
|
||||
});
|
||||
|
||||
async function copy() {
|
||||
@@ -164,15 +186,31 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
loading={removing}
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete key "${secret.key}"?`)) remove();
|
||||
}}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
Delete
|
||||
Delete<span className="sr-only"> {secret.key}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Delete key"
|
||||
confirmLabel="Delete key"
|
||||
loading={removing}
|
||||
error={removeError ? friendlyMessage(removeError) : null}
|
||||
onClose={() => setConfirming(false)}
|
||||
onConfirm={() => remove()}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{secret.key}</span> will be removed from the{" "}
|
||||
<span className="font-mono text-text-primary">{group}</span> group.
|
||||
</p>
|
||||
<p>Anything reading this key — a workflow step, an External Secrets sync — starts failing at its next run.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
@@ -225,17 +263,24 @@ export default function SecretGroupPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const group = decodeURIComponent(String(params.group));
|
||||
const toast = useToast();
|
||||
const [showYaml, setShowYaml] = useState(false);
|
||||
const [confirmingGroup, setConfirmingGroup] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["secret-group", group],
|
||||
queryFn: () => api.getSecretGroup(group),
|
||||
});
|
||||
|
||||
const { mutate: deleteGroup, isPending: deleting } = useMutation({
|
||||
const {
|
||||
mutate: deleteGroup,
|
||||
isPending: deleting,
|
||||
error: deleteError,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.deleteSecretGroup(group),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
|
||||
toast.success(`Deleted the ${group} group.`);
|
||||
router.push("/secrets");
|
||||
},
|
||||
});
|
||||
@@ -262,30 +307,49 @@ export default function SecretGroupPage() {
|
||||
</svg>
|
||||
ExternalSecret YAML
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-danger hover:text-danger"
|
||||
loading={deleting}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" className="text-danger hover:text-danger" onClick={() => setConfirmingGroup(true)}>
|
||||
Delete Group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmingGroup}
|
||||
title="Delete secret group"
|
||||
confirmLabel="Delete group"
|
||||
// No undo, and the blast radius is every consumer of the group
|
||||
// rather than one key — so the name has to be typed.
|
||||
requireTyped={group}
|
||||
loading={deleting}
|
||||
error={deleteError ? friendlyMessage(deleteError) : null}
|
||||
onClose={() => setConfirmingGroup(false)}
|
||||
onConfirm={() => deleteGroup()}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
This deletes <span className="font-mono text-text-primary">{group}</span> and all{" "}
|
||||
{data ? `${data.secrets.length} of its keys` : "of its keys"}. The values cannot be recovered.
|
||||
</p>
|
||||
<p>
|
||||
Every workflow step referencing this group, and any External Secrets sync reading{" "}
|
||||
<span className="font-mono">/api/secrets/{group}/values</span>, fails at its next run.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AddKeyCard group={group} />
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load group. It may have been deleted.</div>
|
||||
) : data && data.secrets.length > 0 ? (
|
||||
<AsyncBoundary
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<TableSkeleton columns={4} />}
|
||||
isEmpty={!data || data.secrets.length === 0}
|
||||
empty={<EmptyState title="This group has no keys yet." description="Add one above and it becomes available to workflow steps and External Secrets straight away." />}
|
||||
>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
@@ -296,14 +360,12 @@ export default function SecretGroupPage() {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{data.secrets.map((s: Secret) => (
|
||||
{data?.secrets.map((s: Secret) => (
|
||||
<SecretRow key={s.key} group={group} secret={s} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,9 +153,10 @@ export default function SecretsPage() {
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
|
||||
<Button variant="ghost" size="sm">View →</Button>
|
||||
</Link>
|
||||
<Button href={`/secrets/${encodeURIComponent(g.group)}`} variant="ghost" size="sm">
|
||||
View <span aria-hidden="true">→</span>
|
||||
<span className="sr-only">{g.group}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, Server } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
@@ -37,10 +36,37 @@ const DOT_LABELS: Record<DotStatus, string> = {
|
||||
ok: "OK",
|
||||
};
|
||||
|
||||
const DOT_TEXT: Record<DotStatus, string> = {
|
||||
offline: "text-danger",
|
||||
"needs-update": "text-warning",
|
||||
"has-package-updates": "text-accent",
|
||||
ok: "text-success",
|
||||
};
|
||||
|
||||
// Short forms for the desktop column, which is narrow. The full sentence is
|
||||
// still the accessible name, so nothing is lost to a screen reader.
|
||||
const DOT_SHORT: Record<DotStatus, string> = {
|
||||
offline: "Offline",
|
||||
"needs-update": "Agent stale",
|
||||
"has-package-updates": "Updates",
|
||||
ok: "OK",
|
||||
};
|
||||
|
||||
/*
|
||||
* The dot alone was the whole control: four meanings carried by hue, with the
|
||||
* distinction living in a `title` a touch user never sees and a screen reader
|
||||
* is not obliged to announce. This is the one rule the design system states
|
||||
* outright — state never reads by colour alone — so the label is now part of
|
||||
* the component rather than something each page remembers to add.
|
||||
*/
|
||||
function StatusDot({ status }: { status: DotStatus }) {
|
||||
return (
|
||||
<span title={DOT_LABELS[status]} className="flex items-center">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOT_CLASSES[status]}`} />
|
||||
<span className={`inline-flex items-center gap-2 whitespace-nowrap ${DOT_TEXT[status]}`}>
|
||||
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${DOT_CLASSES[status]}`} aria-hidden="true" />
|
||||
<span className="font-mono text-[0.65rem] uppercase tracking-[0.08em]" aria-hidden="true">
|
||||
{DOT_SHORT[status]}
|
||||
</span>
|
||||
<span className="sr-only">{DOT_LABELS[status]}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -104,14 +130,12 @@ function ServersPageBody() {
|
||||
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/servers/new">
|
||||
<Button variant="primary">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Server
|
||||
</Button>
|
||||
</Link>
|
||||
<Button href="/servers/new" variant="primary">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TagFilterBar value={selected} onChange={setSelected} />
|
||||
@@ -161,18 +185,26 @@ function ServersPageBody() {
|
||||
<StatusDot status={resolveStatus(server, latestVersion)} />
|
||||
</Td>
|
||||
<Td label="Last Seen">
|
||||
<span className="text-text-secondary">
|
||||
{server.last_seen
|
||||
? formatLastSeen(server.last_seen)
|
||||
: "Never"}
|
||||
</span>
|
||||
{server.last_seen ? (
|
||||
// "3d ago" is the useful reading; the exact instant is
|
||||
// what someone correlating an incident needs, so it is on
|
||||
// the element rather than gone.
|
||||
<time
|
||||
dateTime={server.last_seen}
|
||||
title={new Date(server.last_seen).toLocaleString()}
|
||||
className="text-text-secondary"
|
||||
>
|
||||
{formatLastSeen(server.last_seen)}
|
||||
</time>
|
||||
) : (
|
||||
<span className="text-text-secondary">Never</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/servers/${server.server_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View →
|
||||
</Button>
|
||||
</Link>
|
||||
<Button href={`/servers/${server.server_id}`} variant="ghost" size="sm">
|
||||
View <span aria-hidden="true">→</span>
|
||||
<span className="sr-only">{server.hostname}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -186,11 +218,9 @@ function ServersPageBody() {
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No servers registered yet.</p>
|
||||
<Link href="/servers/new">
|
||||
<Button variant="primary" size="sm" className="mt-4">
|
||||
Add your first server
|
||||
</Button>
|
||||
</Link>
|
||||
<Button href="/servers/new" variant="primary" size="sm" className="mt-4">
|
||||
Add your first server
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -229,12 +229,12 @@ export default function SettingsPage() {
|
||||
<Group label="Monitoring">
|
||||
<SectionCard title="Alerting" description="Alerts are delivered through notification channels, triggered by service monitors and by servers going offline." icon={<BellIcon />}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary">Manage notification channels</Button>
|
||||
</Link>
|
||||
<Link href="/monitors">
|
||||
<Button variant="ghost">View monitors</Button>
|
||||
</Link>
|
||||
<Button href="/settings/notifications" variant="secondary">
|
||||
Manage notification channels
|
||||
</Button>
|
||||
<Button href="/monitors" variant="ghost">
|
||||
View monitors
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-text-tertiary">
|
||||
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
|
||||
|
||||
@@ -113,16 +113,13 @@ export default function WorkflowsPage() {
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/workflows/${w.workflow_id}/runs`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Runs
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Open →
|
||||
</Button>
|
||||
</Link>
|
||||
<Button href={`/workflows/${w.workflow_id}/runs`} variant="ghost" size="sm">
|
||||
Runs<span className="sr-only"> for {w.name}</span>
|
||||
</Button>
|
||||
<Button href={`/workflows/${w.workflow_id}`} variant="ghost" size="sm">
|
||||
Open <span aria-hidden="true">→</span>
|
||||
<span className="sr-only">{w.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type InstanceUser, type Role } from "@/lib/api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { Badge, Button, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
|
||||
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { Field, inputClass } from "./Field";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
const ROLES: Role[] = ["owner", "admin", "member"];
|
||||
|
||||
/** The member a pending removal refers to, carried so the dialog and the
|
||||
* confirmation message name a person rather than a user_id. */
|
||||
type Member = { id: string; email: string };
|
||||
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -31,7 +35,9 @@ function roleVariant(role: Role) {
|
||||
export function MembersCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [removing, setRemoving] = useState<Member | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>("member");
|
||||
@@ -48,6 +54,7 @@ export function MembersCard() {
|
||||
mutationFn: () => api.createInstanceUser({ email, password, role }),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(`Added ${email} as ${role}.`);
|
||||
setAddOpen(false);
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
@@ -63,12 +70,23 @@ export function MembersCard() {
|
||||
onError: invalidate,
|
||||
});
|
||||
|
||||
const { mutate: removeUser, error: removeError } = useMutation({
|
||||
mutationFn: (userId: string) => api.deleteInstanceUser(userId),
|
||||
onSuccess: invalidate,
|
||||
const {
|
||||
mutate: removeUser,
|
||||
isPending: isRemoving,
|
||||
error: removeError,
|
||||
} = useMutation({
|
||||
mutationFn: (member: Member) => api.deleteInstanceUser(member.id),
|
||||
onSuccess: (_data, member) => {
|
||||
invalidate();
|
||||
toast.success(`Removed ${member.email}.`);
|
||||
setRemoving(null);
|
||||
},
|
||||
});
|
||||
|
||||
const actionError = (roleError ?? removeError) as Error | null;
|
||||
// Removal failures are shown inside the confirm dialog that raised them, so
|
||||
// only the inline role change lands here — otherwise the same sentence
|
||||
// appears twice on screen.
|
||||
const actionError = roleError as Error | null;
|
||||
|
||||
const isOwner = user?.role === "owner";
|
||||
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
|
||||
@@ -154,11 +172,10 @@ export function MembersCard() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
|
||||
}}
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
|
||||
>
|
||||
Remove
|
||||
Remove<span className="sr-only"> {u.email}</span>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -170,6 +187,25 @@ export function MembersCard() {
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={removing !== null}
|
||||
title="Remove member"
|
||||
confirmLabel="Remove member"
|
||||
loading={isRemoving}
|
||||
error={removeError ? friendlyMessage(removeError) : null}
|
||||
onClose={() => setRemoving(null)}
|
||||
onConfirm={() => removing && removeUser(removing)}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="text-text-primary">{removing?.email}</span> loses access to this instance immediately, including any
|
||||
open session.
|
||||
</p>
|
||||
<p>Their audit history is kept. Adding them again later creates a new member.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Button } from "./Button";
|
||||
|
||||
/*
|
||||
* Twenty-three copies of the same spinner div existed across app/ and
|
||||
* components/, each with the loading / error / empty branch rewritten by hand
|
||||
* beside it. They had already drifted: some said "Failed to load servers. Is
|
||||
* the backend running?", some rendered the raw exception message, some showed
|
||||
* nothing at all while a list was empty.
|
||||
*/
|
||||
|
||||
export function Spinner({ className, label = "Loading" }: { className?: string; label?: string }) {
|
||||
return (
|
||||
<span role="status" className="inline-flex items-center">
|
||||
<span
|
||||
className={clsx("inline-block animate-spin rounded-full border-2 border-border border-t-accent", className ?? "h-8 w-8")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CenteredSpinner({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* A skeleton rather than a spinner wherever the shape of what is coming is
|
||||
* already known: the table does not collapse and re-expand, so the page stops
|
||||
* jumping under the pointer as data lands.
|
||||
*/
|
||||
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
|
||||
return (
|
||||
<div className="animate-pulse p-4" aria-hidden="true">
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="flex gap-4 border-b border-border/40 py-3 last:border-0">
|
||||
{Array.from({ length: columns }).map((_, c) => (
|
||||
<div
|
||||
key={c}
|
||||
className="h-3 rounded bg-surface-2"
|
||||
style={{ width: `${[28, 20, 16, 12, 10, 8][c % 6]}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: { label: string; href?: string; onClick?: () => void };
|
||||
}) {
|
||||
return (
|
||||
<div className="px-6 py-16 text-center">
|
||||
{icon && (
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2 text-text-secondary">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[15px] font-semibold text-text-primary">{title}</p>
|
||||
{description && <p className="mx-auto mt-2 max-w-[46ch] text-sm text-text-secondary">{description}</p>}
|
||||
{action &&
|
||||
(action.href ? (
|
||||
<Button href={action.href} variant="primary" size="sm" className="mt-4">
|
||||
{action.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" className="mt-4" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="px-6 py-16 text-center" role="alert">
|
||||
<p className="text-[15px] font-semibold text-text-primary">{friendlyMessage(error)}</p>
|
||||
{detailOf(error) && <p className="mx-auto mt-2 max-w-[52ch] font-mono text-xs text-text-secondary">{detailOf(error)}</p>}
|
||||
{onRetry && (
|
||||
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One loading/error/empty decision instead of the same ternary chain rewritten
|
||||
* in every list page. `isEmpty` is passed rather than inferred, because only
|
||||
* the caller knows whether an empty array is empty or simply filtered to
|
||||
* nothing.
|
||||
*/
|
||||
export function AsyncBoundary({
|
||||
isLoading,
|
||||
error,
|
||||
isEmpty,
|
||||
onRetry,
|
||||
skeleton,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
error?: unknown;
|
||||
isEmpty?: boolean;
|
||||
onRetry?: () => void;
|
||||
skeleton?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isLoading) return <>{skeleton ?? <CenteredSpinner />}</>;
|
||||
if (error) return <ErrorState error={error} onRetry={onRetry} />;
|
||||
if (isEmpty && empty) return <>{empty}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/*
|
||||
* Backend messages went straight to the screen. Some are written for an
|
||||
* operator and are the most useful thing available; some are a Go error string
|
||||
* or a bare "Failed to fetch" from a dropped connection, which tells the
|
||||
* customer nothing and reads as a crash. Classify first, then show the detail
|
||||
* underneath rather than instead of an explanation.
|
||||
*/
|
||||
export function friendlyMessage(error: unknown): string {
|
||||
const status = (error as { status?: number } | null)?.status;
|
||||
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
||||
|
||||
// The backend writes its 4xx messages for an operator and they are usually
|
||||
// the most specific thing available ("default steps cannot be edited",
|
||||
// "vulnerability scanning is not licensed"). Keep them; only replace the
|
||||
// ones that are a status code wearing a coat.
|
||||
const useful = raw && !/^HTTP \d{3}$/.test(raw) && !/^[A-Z][a-z]+( [A-Z]?[a-z]+)*$/.test(raw) ? raw : "";
|
||||
|
||||
if (status === 401) return "Your session has expired. Sign in again to continue.";
|
||||
if (status === 403) return useful || "You do not have permission to do this.";
|
||||
if (status === 404) return useful || "That is no longer here.";
|
||||
if (status === 409) return useful || "That conflicts with the current state.";
|
||||
if (status === 429) return "Too many requests. Wait a moment and try again.";
|
||||
if (typeof status === "number" && status >= 500) return "The server could not complete that. Try again shortly.";
|
||||
if (typeof status === "number" && status >= 400) return useful || "That request was rejected.";
|
||||
|
||||
// fetch() rejects with a TypeError and no status when the request never
|
||||
// reached the server at all.
|
||||
if (!status && /failed to fetch|networkerror|load failed/i.test(raw)) {
|
||||
return "Cannot reach the server. Check your connection.";
|
||||
}
|
||||
|
||||
return raw || "Something went wrong.";
|
||||
}
|
||||
|
||||
function detailOf(error: unknown): string | null {
|
||||
const raw = error instanceof Error ? error.message : null;
|
||||
if (!raw) return null;
|
||||
return raw === friendlyMessage(error) ? null : raw;
|
||||
}
|
||||
@@ -1,13 +1,32 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { AnchorHTMLAttributes, ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
type Variant = "primary" | "secondary" | "danger" | "ghost";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface CommonProps {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ButtonProps extends CommonProps, Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof CommonProps> {
|
||||
loading?: boolean;
|
||||
href?: undefined;
|
||||
}
|
||||
|
||||
interface LinkButtonProps extends CommonProps, Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof CommonProps> {
|
||||
/**
|
||||
* Renders a next/link styled as this button instead of a <button>.
|
||||
*
|
||||
* <Link><Button/></Link> nests an interactive element inside an anchor: the
|
||||
* markup is invalid, the pair takes two tab stops, and a keyboard Enter fires
|
||||
* only the outer anchor. Nineteen call sites did that. Pass href here instead.
|
||||
*/
|
||||
href: string;
|
||||
loading?: undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -32,45 +51,67 @@ const sizeClasses: Record<Size, string> = {
|
||||
lg: "px-5 py-2.5 text-base",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ variant = "primary", size = "md", loading, className, children, disabled, ...props },
|
||||
ref
|
||||
) => {
|
||||
const baseClasses =
|
||||
"inline-flex items-center gap-2 rounded border font-semibold no-underline transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px";
|
||||
|
||||
function classesFor(variant: Variant, size: Size, className?: string) {
|
||||
return clsx(baseClasses, variantClasses[variant], sizeClasses[size], className);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps | LinkButtonProps>(
|
||||
({ variant = "primary", size = "md", className, children, ...rest }, ref) => {
|
||||
if (typeof rest.href === "string") {
|
||||
const { href, ...anchorProps } = rest as LinkButtonProps;
|
||||
return (
|
||||
<Link
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
href={href}
|
||||
className={classesFor(variant, size, className)}
|
||||
{...anchorProps}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const { loading, disabled, ...buttonProps } = rest as ButtonProps;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
disabled={disabled || loading}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-2 rounded border font-semibold transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
// A control that is busy is still a control; announce it rather than
|
||||
// leaving a screen reader on the pre-click label with nothing happening.
|
||||
aria-busy={loading || undefined}
|
||||
className={classesFor(variant, size, className)}
|
||||
{...buttonProps}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.instance/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{loading && <Spinner />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
/*
|
||||
* Destructive actions used to go through window.confirm(). That dialog is
|
||||
* browser chrome: it cannot say what is about to be deleted beyond one line of
|
||||
* plain text, it looks nothing like the product, it cannot show the error when
|
||||
* the delete then fails, and it offers the same two buttons whether the action
|
||||
* removes one key or an entire secret group.
|
||||
*
|
||||
* `requireTyped` is for the cases with no undo — deleting a secret group, a
|
||||
* step used by every workflow. Typing the name is not friction for its own
|
||||
* sake: it is what stops a muscle-memory Enter from destroying something whose
|
||||
* name the operator never actually read.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmLabel = "Delete",
|
||||
requireTyped,
|
||||
destructive = true,
|
||||
loading,
|
||||
error,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
/** When set, the confirm button stays disabled until this exact string is typed. */
|
||||
requireTyped?: string;
|
||||
destructive?: boolean;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const inputId = useId();
|
||||
|
||||
// A reopened dialog must not carry the previous attempt's typing.
|
||||
useEffect(() => {
|
||||
if (open) setTyped("");
|
||||
}, [open]);
|
||||
|
||||
const armed = !requireTyped || typed === requireTyped;
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={onClose}>
|
||||
<div className="space-y-4 text-sm text-text-secondary">
|
||||
<div className="space-y-2">{body}</div>
|
||||
|
||||
{requireTyped && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={inputId} className="block text-xs text-text-secondary">
|
||||
Type <span className="font-mono text-text-primary">{requireTyped}</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? "danger" : "primary"}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
disabled={!armed}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+148
-35
@@ -1,45 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useId, useRef } from "react";
|
||||
|
||||
/*
|
||||
* Dialogs nest: a confirm sits on top of the edit modal that raised it. Both
|
||||
* listen on document, so without a stack Escape would close the pair at once
|
||||
* and the trap of the covered dialog would fight the top one for focus. Only
|
||||
* the last opened panel acts.
|
||||
*/
|
||||
const stack: symbol[] = [];
|
||||
|
||||
const FOCUSABLE = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled]):not([type='hidden'])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const restoreRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const idRef = useRef<symbol>(Symbol("modal"));
|
||||
|
||||
if (!open) return null;
|
||||
/*
|
||||
* Everything below is what an accessible dialog owes the person using it,
|
||||
* and none of it was here: focus stayed on the page behind, Tab walked out
|
||||
* of the dialog into content the overlay had covered, the background
|
||||
* scrolled under the panel, and closing left focus on <body> so the next
|
||||
* Tab restarted from the top of the document.
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
// Escape closes. Tab is confined to the panel.
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Only the topmost dialog reacts.
|
||||
if (stack[stack.length - 1] !== idRef.current) return;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panelRef.current) return;
|
||||
|
||||
const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
if (items.length === 0) {
|
||||
// Nothing focusable inside; keep focus on the panel rather than
|
||||
// letting Tab escape to the page underneath.
|
||||
e.preventDefault();
|
||||
panelRef.current.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
|
||||
if (e.shiftKey && (active === first || active === panelRef.current)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const id = idRef.current;
|
||||
stack.push(id);
|
||||
restoreRef.current = document.activeElement as HTMLElement | null;
|
||||
|
||||
// The page behind must not scroll while a dialog is over it. Padding
|
||||
// replaces the scrollbar's width so the layout does not jump sideways
|
||||
// as it disappears.
|
||||
const { body } = document;
|
||||
const prevOverflow = body.style.overflow;
|
||||
const prevPadding = body.style.paddingRight;
|
||||
const gap = window.innerWidth - document.documentElement.clientWidth;
|
||||
body.style.overflow = "hidden";
|
||||
if (gap > 0) body.style.paddingRight = `${gap}px`;
|
||||
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
|
||||
// Focus the first real control, falling back to the panel itself. The
|
||||
// close button is deliberately not preferred: opening a dialog on its
|
||||
// dismiss control reads as "are you sure you want to be here".
|
||||
const target =
|
||||
panelRef.current?.querySelector<HTMLElement>(FOCUSABLE) ?? panelRef.current;
|
||||
target?.focus();
|
||||
|
||||
return () => {
|
||||
const at = stack.lastIndexOf(id);
|
||||
if (at !== -1) stack.splice(at, 1);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
// Each dialog restores what it found, so an inner one closing over
|
||||
// an outer one puts back "hidden" rather than releasing the page.
|
||||
body.style.overflow = prevOverflow;
|
||||
body.style.paddingRight = prevPadding;
|
||||
// Return focus to whatever opened the dialog, if it is still there.
|
||||
if (restoreRef.current?.isConnected) restoreRef.current.focus();
|
||||
};
|
||||
}, [open, onKeyDown]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} aria-hidden="true" />
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel focus:outline-none sm:rounded sm:border-b`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 id={titleId} className="text-sm font-bold text-text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { friendlyMessage } from "./Async";
|
||||
|
||||
/*
|
||||
* Mutations succeeded silently. Copying an install one-liner, generating a key,
|
||||
* restarting a container, rotating the ESO token — all of them changed
|
||||
* something and said nothing, so the only way to know it worked was to watch
|
||||
* for the list to redraw. Failures were worse: each page wired its own
|
||||
* `onError: setError` into its own inline div, so an error raised by a modal
|
||||
* that then closed had nowhere to land at all.
|
||||
*
|
||||
* No dependency for this. It is a context, a list and a fixed div; sonner would
|
||||
* be 12KB to render three lines of text in a palette we would then have to
|
||||
* override anyway.
|
||||
*/
|
||||
|
||||
type ToastKind = "success" | "error" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastApi {
|
||||
success: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
/** Accepts a thrown value directly, so call sites do not each re-derive a message. */
|
||||
error: (error: unknown) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
const DURATION: Record<ToastKind, number> = {
|
||||
// An error stays four times as long as a confirmation: it is the one the
|
||||
// reader has to act on, and it may be the only record of what failed.
|
||||
success: 4000,
|
||||
info: 5000,
|
||||
error: 12000,
|
||||
};
|
||||
|
||||
const KIND_CLASSES: Record<ToastKind, string> = {
|
||||
success: "border-success/40 text-success",
|
||||
error: "border-danger/40 text-danger",
|
||||
info: "border-accent/40 text-accent",
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<ToastKind, string> = {
|
||||
success: "Success",
|
||||
error: "Error",
|
||||
info: "Note",
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextId = useRef(1);
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setToasts((list) => list.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(kind: ToastKind, message: string) => {
|
||||
const id = nextId.current++;
|
||||
// Cap the stack. A mutation looping on a failing endpoint would
|
||||
// otherwise paper over the screen with the same sentence.
|
||||
setToasts((list) => [...list.slice(-2), { id, kind, message }]);
|
||||
timers.current.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), DURATION[kind]),
|
||||
);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
success: (message) => push("success", message),
|
||||
info: (message) => push("info", message),
|
||||
error: (error) => push("error", friendlyMessage(error)),
|
||||
}),
|
||||
[push],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-[60] flex flex-col items-center gap-2 p-4 sm:items-end"
|
||||
// Announce without stealing focus. Errors are assertive because
|
||||
// they usually mean the thing the operator asked for did not
|
||||
// happen; a success can wait for a pause in speech.
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
role={t.kind === "error" ? "alert" : "status"}
|
||||
className={clsx(
|
||||
"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded border bg-surface px-4 py-3 text-sm shadow-panel",
|
||||
KIND_CLASSES[t.kind],
|
||||
)}
|
||||
>
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 break-words text-text-primary">
|
||||
<span className="sr-only">{KIND_LABEL[t.kind]}: </span>
|
||||
{t.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="shrink-0 rounded p-0.5 text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -3,4 +3,15 @@ export { Badge } from "./Badge";
|
||||
export { Card, CardHeader, CardTitle } from "./Card";
|
||||
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
|
||||
export { Modal } from "./Modal";
|
||||
export { ConfirmDialog } from "./ConfirmDialog";
|
||||
export { Pagination, usePagination, PAGE_SIZES } from "./Pagination";
|
||||
export {
|
||||
AsyncBoundary,
|
||||
CenteredSpinner,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Spinner,
|
||||
TableSkeleton,
|
||||
friendlyMessage,
|
||||
} from "./Async";
|
||||
export { ToastProvider, useToast } from "./Toast";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep, InputParam } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { Button, ConfirmDialog, Modal, friendlyMessage, useToast } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
@@ -16,6 +16,8 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
|
||||
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Default steps are re-seeded from the image on every boot, so the server
|
||||
// refuses to update or delete them. The form mirrors that rather than
|
||||
@@ -33,19 +35,22 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
|
||||
if (step) await api.updateStep(step.step_id, payload);
|
||||
else await api.createStep(payload);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
toast.success(step ? `Saved ${payload.name}.` : `Created ${payload.name}.`);
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
} catch (e) { setError(friendlyMessage(e)); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
|
||||
if (!step) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.deleteStep(step.step_id);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflow"] });
|
||||
toast.success(`Deleted ${step.name}.`);
|
||||
setConfirmingDelete(false);
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
} catch (e) { setError(friendlyMessage(e)); setConfirmingDelete(false); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,13 +111,34 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{step && !locked ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
|
||||
{step && !locked ? <Button variant="danger" onClick={() => setConfirmingDelete(true)}>Delete step</Button> : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>{locked ? "Close" : "Cancel"}</Button>
|
||||
{!locked && <Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmingDelete}
|
||||
title="Delete base step"
|
||||
confirmLabel="Delete step"
|
||||
// A base step is shared: deleting it edits every workflow that uses it,
|
||||
// which is not what "delete this one thing" usually implies.
|
||||
requireTyped={step?.name}
|
||||
loading={busy}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
onConfirm={del}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{step?.name}</span> is a shared library step. Deleting it removes it from every
|
||||
workflow that references it.
|
||||
</p>
|
||||
<p>Runs already recorded keep their snapshot of the script and are not affected.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { Button, ConfirmDialog, Modal, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
import { DualListBox } from "./DualListBox";
|
||||
|
||||
@@ -20,6 +20,8 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(workflow.target_tags ?? {}));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const toast = useToast();
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
|
||||
|
||||
@@ -42,23 +44,25 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
target_tags: Object.fromEntries(tagRows.filter(([k, v]) => k && v)),
|
||||
});
|
||||
onSaved(updated);
|
||||
toast.success(`Saved ${updated.name}.`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setError(friendlyMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteWorkflow(workflow.workflow_id);
|
||||
toast.success(`Deleted ${workflow.name}.`);
|
||||
router.push("/workflows");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setError(friendlyMessage(e));
|
||||
setConfirmingDelete(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -154,7 +158,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
<ScheduleCard workflow={workflow} />
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border-soft pt-4">
|
||||
<Button variant="danger" onClick={del} loading={busy}>
|
||||
<Button variant="danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete workflow
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
@@ -167,6 +171,25 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmingDelete}
|
||||
title="Delete workflow"
|
||||
confirmLabel="Delete workflow"
|
||||
requireTyped={workflow.name}
|
||||
loading={busy}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
onConfirm={del}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{workflow.name}</span> and its schedule are removed. Its base steps stay
|
||||
in the library.
|
||||
</p>
|
||||
<p>Past runs and their logs are kept, but nothing new can be run from this workflow.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user