Files
arcadia-admin/app/routes/announcements.tsx
jules 4b817b85ff Wire operator Integrations page + capability-gating framework
Completes the arcadia-admin operator surface for the integration registry and
the capability/route-guard framework it depends on.

- Integration registry: route + Data-group nav entry + `platform.integrations`
  capability; the in-app client now delegates to the shared
  `@crema/integration-registry-client` lib (vite alias + tsconfig); the
  operator Integrations page (committed earlier) is now reachable.
- Capability gating: capabilities map + route-guard + jwt helpers + the
  apps/plan/entitlements routes and supporting tenants/session changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:09:24 +10:00

824 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from "react"
import {
CheckCircle2,
Megaphone,
Plus,
RefreshCw,
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import {
ActionsCell,
BadgeCell,
DataTable,
DateCell,
Pagination,
useTable,
type ActionItem,
type BadgeTone,
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import { Switch } from "~/components/ui/switch"
import { Textarea } from "~/components/ui/textarea"
import {
createAnnouncement,
deleteAnnouncement,
listAnnouncements,
updateAnnouncement,
type Announcement,
type AnnouncementInput,
type AnnouncementType,
} from "~/lib/arcadia/announcements"
import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterContext } from "@crema/aifirst-ui/context"
export const meta = () => pageTitle("Announcements")
const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"]
const KIND_OPTIONS: { value: AnnouncementType; hint: string }[] = [
{ value: "info", hint: "Neutral update" },
{ value: "warning", hint: "Degraded service or heads-up" },
{ value: "maintenance", hint: "Scheduled work" },
{ value: "incident", hint: "Active outage" },
{ value: "feature", hint: "Something new shipped" },
]
function typeToAlertVariant(
t: AnnouncementType,
): "info" | "success" | "warning" | "error" | "neutral" {
if (t === "incident") return "error"
if (t === "warning" || t === "maintenance") return "warning"
if (t === "feature") return "success"
return "info"
}
function publishButtonLabel(opts: {
isEdit: boolean
active: boolean
audience: "platform" | "tenant"
tenantId: string
tenants: Tenant[]
}): string {
if (opts.isEdit) return "Save changes"
if (!opts.active) return "Save draft"
if (opts.audience === "tenant") {
const name = opts.tenants.find((t) => t.id === opts.tenantId)?.name
return name ? `Publish to ${name}` : "Publish to tenant"
}
return "Publish to all users"
}
type Editor =
| { kind: "create" }
| { kind: "edit"; announcement: Announcement }
| null
export default function AnnouncementsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [items, setItems] = useState<Announcement[]>([])
const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null)
const [refreshedAt, setRefreshedAt] = useState<number | null>(null)
const [now, setNow] = useState(() => Date.now())
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [a, t] = await Promise.all([
listAnnouncements(arcadia),
listTenants(arcadia).catch(() => [] as Tenant[]),
])
setItems(a)
setTenants(t)
setRefreshedAt(Date.now())
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (refreshedAt == null) return
const id = window.setInterval(() => setNow(Date.now()), 30_000)
return () => window.clearInterval(id)
}, [refreshedAt])
const lastRefreshedLabel = useMemo(() => {
if (refreshedAt == null) return null
const seconds = Math.max(1, Math.round((now - refreshedAt) / 1000))
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
return `${Math.round(minutes / 60)}h ago`
}, [refreshedAt, now])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const columns = useMemo<Column<Announcement>[]>(
() => [
{
id: "title",
header: "Title",
accessor: "title",
sortable: true,
cell: (a) => (
<div className="flex flex-col">
<span className="font-medium">{a.title}</span>
{a.body ? (
<span className="line-clamp-1 text-xs text-muted-foreground">{a.body}</span>
) : null}
</div>
),
},
{
id: "type",
header: "Type",
accessor: "announcement_type",
sortable: true,
cell: (a) => <BadgeCell label={a.announcement_type} tone={typeTone(a.announcement_type)} />,
},
{
id: "scope",
header: "Audience",
cell: (a) => {
if (!a.tenant_id) return <Badge>All apps</Badge>
const t = tenants.find((x) => x.id === a.tenant_id)
return <Badge variant="secondary">{t?.slug ?? "Single tenant"}</Badge>
},
},
{
id: "active",
header: "Active",
accessor: "active",
sortable: true,
cell: (a) => (
<BadgeCell label={a.active ? "live" : "off"} tone={a.active ? "success" : "default"} />
),
},
{
id: "window",
header: "Window",
cell: (a) => (
<span className="text-xs text-muted-foreground">
{a.starts_at ? new Date(a.starts_at).toLocaleDateString() : "—"}
{" → "}
{a.ends_at ? new Date(a.ends_at).toLocaleDateString() : "∞"}
</span>
),
},
{
id: "updated",
header: "Updated",
accessor: "updated_at",
sortable: true,
cell: (a) => <DateCell value={a.updated_at} format="short" />,
},
{
id: "actions",
header: "",
align: "right",
cell: (a) => {
const items: ActionItem[] = [
{
id: "edit",
label: "Edit",
dataAction: `announcement-${a.id}-edit`,
onSelect: () => setEditor({ kind: "edit", announcement: a }),
},
{
id: "toggle",
label: a.active ? "Deactivate" : "Activate",
dataAction: `announcement-${a.id}-toggle`,
onSelect: async () => {
try {
await updateAnnouncement(arcadia, a.id, { active: !a.active })
setInfo(a.active ? "Announcement deactivated." : "Announcement activated.")
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
}
},
},
{
id: "delete",
label: "Delete",
icon: <Trash2 className="size-4" />,
destructive: true,
dataAction: `announcement-${a.id}-delete`,
onSelect: () => setPendingDelete(a),
},
]
return <ActionsCell items={items} triggerDataAction={`announcement-${a.id}-actions`} />
},
},
],
[arcadia, refresh, tenants],
)
const summary = useMemo(
() => ({
total: items.length,
active: items.filter((a) => a.active).length,
byType: countBy(items, (a) => a.announcement_type),
}),
[items],
)
useRegisterContext("announcements", summary)
const table = useTable<Announcement>({
data: items,
columns,
getRowId: (a) => a.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
return (
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h1 className="text-[26px] font-[620] leading-[1.1] tracking-[-0.02em]">
Announcements
</h1>
<p className="mt-1.5 max-w-[56ch] text-[13.5px] leading-[1.5] text-muted-foreground">
Banners that appear at the top of every Sky AI app. Use them for maintenance
windows, incidents, or new features.
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
{lastRefreshedLabel ? (
<span
className="text-xs tabular-nums text-muted-foreground"
aria-live="polite"
title={`Last refreshed ${lastRefreshedLabel}`}
>
<span className="hidden sm:inline">Updated </span>
{lastRefreshedLabel}
</span>
) : null}
<Button
variant="ghost"
size="icon-sm"
onClick={refresh}
disabled={loading}
aria-label="Refresh announcements"
data-action="announcements-refresh"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
</Button>
{items.length > 0 ? (
<Button
size="sm"
onClick={() => setEditor({ kind: "create" })}
data-action="announcements-create"
>
<Plus className="size-4" />
New announcement
</Button>
) : null}
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by title, body, or type"
data-action="announcements-search"
className="max-w-sm flex-1"
/>
{items.length > 0 ? (
<div className="ml-auto text-xs tabular-nums text-muted-foreground">
{search && table.total !== items.length
? `${table.total} of ${items.length}`
: `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
</div>
) : null}
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && items.length === 0}
label="Loading announcements…"
/>
{table.total === 0 && !loading ? (
<EmptyState
icon={
<div
className="grid size-14 place-items-center rounded-full"
style={{
background:
"radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)",
}}
>
<Megaphone
className="size-6"
style={{ color: "var(--primary)" }}
/>
</div>
}
title={search ? "No announcements match." : "No announcements yet."}
description={
search
? "Try a different search."
: "Post your first banner. Show it to everyone, or scope it to a single tenant."
}
action={
search ? (
<Button
size="sm"
variant="outline"
onClick={() => setSearch("")}
data-action="announcements-clear-search"
>
Clear search
</Button>
) : (
<Button
size="sm"
onClick={() => setEditor({ kind: "create" })}
data-action="announcements-create-empty"
>
<Plus className="size-4" />
New announcement
</Button>
)
}
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(a) => a.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && items.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete announcement?"
description={pendingDelete ? `${pendingDelete.title} will be removed for all users.` : ""}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
try {
await deleteAnnouncement(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Announcement deleted.")
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
}
}}
/>
<AnnouncementEditorDialog
state={editor}
tenants={tenants}
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
}}
onError={setError}
/>
</AppShell>
)
}
function typeTone(t: AnnouncementType): BadgeTone {
if (t === "incident") return "danger"
if (t === "warning" || t === "maintenance") return "warning"
if (t === "feature") return "success"
return "default"
}
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return arr.reduce<Record<string, number>>((acc, x) => {
const k = key(x)
acc[k] = (acc[k] ?? 0) + 1
return acc
}, {})
}
function AnnouncementEditorDialog({
state,
tenants,
onClose,
onSaved,
onError,
}: {
state: Editor
tenants: Tenant[]
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const open = state !== null
const isEdit = state?.kind === "edit"
const initial = isEdit ? state.announcement : null
const [title, setTitle] = useState("")
const [body, setBody] = useState("")
const [type, setType] = useState<AnnouncementType>("info")
const [audience, setAudience] = useState<"platform" | "tenant">("platform")
const [tenantId, setTenantId] = useState<string>("")
const [actionLabel, setActionLabel] = useState("")
const [actionUrl, setActionUrl] = useState("")
const [startsAt, setStartsAt] = useState("")
const [endsAt, setEndsAt] = useState("")
const [dismissible, setDismissible] = useState(true)
const [active, setActive] = useState(true)
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
useEffect(() => {
if (!open) setLocalError(null)
}, [open])
useEffect(() => {
if (!open) return
if (initial) {
setTitle(initial.title)
setBody(initial.body ?? "")
setType(initial.announcement_type)
setAudience(initial.tenant_id ? "tenant" : "platform")
setTenantId(initial.tenant_id ?? "")
setActionLabel(initial.action_label ?? "")
setActionUrl(initial.action_url ?? "")
setStartsAt(initial.starts_at ? initial.starts_at.slice(0, 16) : "")
setEndsAt(initial.ends_at ? initial.ends_at.slice(0, 16) : "")
setDismissible(initial.dismissible)
setActive(initial.active)
} else {
setTitle("")
setBody("")
setType("info")
setAudience("platform")
setTenantId("")
setActionLabel("")
setActionUrl("")
setStartsAt("")
setEndsAt("")
setDismissible(true)
setActive(true)
}
}, [open, initial])
const submit = async () => {
onError(null)
setLocalError(null)
setSaving(true)
try {
const input: AnnouncementInput = {
title,
body: body || undefined,
announcement_type: type,
audience,
action_label: actionLabel || null,
action_url: actionUrl || null,
starts_at: startsAt ? new Date(startsAt).toISOString() : null,
ends_at: endsAt ? new Date(endsAt).toISOString() : null,
dismissible,
active,
tenant_id: audience === "tenant" ? tenantId || null : null,
}
if (isEdit && initial) {
await updateAnnouncement(arcadia, initial.id, input)
await onSaved("Announcement updated.")
} else {
await createAnnouncement(arcadia, input)
await onSaved("Announcement posted.")
}
} catch (err) {
const msg =
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed."
setLocalError(msg)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit announcement" : "New announcement"}</DialogTitle>
<DialogDescription>
A banner shows at the top of every Sky AI app. It's visible when it's switched on
and today falls inside its date range.
</DialogDescription>
</DialogHeader>
{/* Live preview — what users will see. Updates as the form is edited so
the operator never has to imagine the output or publish blind. */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Preview
</Label>
<div className="rounded-md border bg-muted/30 p-3">
<AlertBanner
variant={typeToAlertVariant(type)}
title={title || "Your banner title appears here"}
dismissible={dismissible}
onDismiss={() => {}}
action={
actionLabel && actionUrl ? (
<Button size="xs" variant="outline" type="button" tabIndex={-1}>
{actionLabel}
</Button>
) : undefined
}
>
{body || (
<span className="italic opacity-60">Body text appears here.</span>
)}
</AlertBanner>
<p className="mt-2 text-[11px] text-muted-foreground">
{audience === "tenant"
? `Visible to users of ${
tenants.find((t) => t.id === tenantId)?.name ?? "the selected tenant"
} only.`
: "Visible to everyone across every Sky AI app."}
</p>
</div>
</div>
{localError ? (
<AlertBanner
variant="error"
dismissible
onDismiss={() => setLocalError(null)}
>
{localError}
</AlertBanner>
) : null}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="ann-title">Title</Label>
<Input
id="ann-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
data-action="announcement-form-title"
placeholder="Scheduled maintenance Sunday 2am AEST"
/>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="ann-body">Body</Label>
<Textarea
id="ann-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
data-action="announcement-form-body"
placeholder="Expect ~10 minutes of downtime while we ship the new tenant switcher."
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger data-action="announcement-form-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{KIND_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex flex-col">
<span className="font-medium capitalize">{opt.value}</span>
<span className="text-xs text-muted-foreground">{opt.hint}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Who sees this</Label>
<Select value={audience} onValueChange={(v) => setAudience(v as "platform" | "tenant")}>
<SelectTrigger data-action="announcement-form-audience">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="platform">Everyone</SelectItem>
<SelectItem value="tenant">Just one tenant</SelectItem>
</SelectContent>
</Select>
</div>
{audience === "tenant" ? (
<div className="col-span-2 flex flex-col gap-1.5">
<Label>Which tenant</Label>
<Select value={tenantId} onValueChange={setTenantId}>
<SelectTrigger data-action="announcement-form-tenant">
<SelectValue placeholder="Pick a tenant" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.slug})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ann-starts">Starts</Label>
<Input
id="ann-starts"
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
data-action="announcement-form-starts"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ann-ends">Ends</Label>
<Input
id="ann-ends"
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
data-action="announcement-form-ends"
/>
</div>
{/* Optional link group — heading clarifies these two are paired. */}
<div className="col-span-2 flex flex-col gap-2 rounded-md border border-dashed p-3">
<div className="flex items-baseline justify-between gap-2">
<Label className="text-sm">Add a link</Label>
<span className="text-xs text-muted-foreground">Optional</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ann-action-label" className="text-xs text-muted-foreground">
Button text
</Label>
<Input
id="ann-action-label"
value={actionLabel}
onChange={(e) => setActionLabel(e.target.value)}
placeholder="Read more"
data-action="announcement-form-action-label"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ann-action-url" className="text-xs text-muted-foreground">
Where it goes
</Label>
<Input
id="ann-action-url"
value={actionUrl}
onChange={(e) => setActionUrl(e.target.value)}
placeholder="/changelog/v2"
data-action="announcement-form-action-url"
/>
</div>
</div>
</div>
{/* End-user behavior toggle, not publish state — kept with content fields. */}
<div className="col-span-2 flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex flex-col">
<Label className="text-sm">Let users dismiss</Label>
<span className="text-xs text-muted-foreground">
Adds an × users can click to hide the banner.
</span>
</div>
<Switch
checked={dismissible}
onCheckedChange={setDismissible}
data-action="announcement-form-dismissible"
/>
</div>
</div>
<DialogFooter className="flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Active = publish state, paired with the publish button. */}
<label
htmlFor="ann-active"
className="flex items-center gap-2 text-xs text-muted-foreground sm:mr-auto"
>
<Switch
id="ann-active"
checked={active}
onCheckedChange={setActive}
data-action="announcement-form-active"
/>
<span>{active ? "Switched on" : "Switched off (draft)"}</span>
</label>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
data-action="announcement-form-save"
>
{saving ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<CheckCircle2 className="size-4" />
)}
{publishButtonLabel({ isEdit, active, audience, tenantId, tenants })}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}