diff --git a/apps/server/src/settings/schemas.ts b/apps/server/src/settings/schemas.ts
index 5d41660..98b3f40 100644
--- a/apps/server/src/settings/schemas.ts
+++ b/apps/server/src/settings/schemas.ts
@@ -6,7 +6,7 @@ export const DEFAULT_SYSTEM_PROMPT =
'Replies are rendered as Markdown. Raw HTML is not rendered, and images load only when the user clicks them.'
const minutes = (title: string, fallback: number) =>
- z.number().positive().default(fallback).meta({ title })
+ z.number().positive().default(fallback).meta({ title, unit: 'minutes' })
/** Admin settings kept in the app_setting table, one schema per key. */
export const settingSchemas = {
@@ -15,22 +15,41 @@ export const settingSchemas = {
inviteRoles: z.array(z.string()).default([]),
}),
general: z.object({
- appName: z.string().trim().min(1).default('SCN Chat').meta({ title: 'App name' }),
+ appName: z.string().trim().min(1).default('SCN Chat').meta({
+ title: 'App name',
+ description: 'Shown on the login page, in the sidebar, and in the browser tab.',
+ }),
}),
sessions: z.object({
- ttlDays: z.number().int().min(1).default(30).meta({ title: 'Days a web session lasts' }),
+ ttlDays: z.number().int().min(1).default(30).meta({
+ title: 'Session length',
+ description: 'How long people stay signed in on the web before they have to sign in again.',
+ unit: 'days',
+ }),
}),
turns: z.object({
- ratePerMinute: z.number().int().min(1).default(10).meta({ title: 'Turns per user per minute' }),
- maxSteps: z.number().int().min(1).default(8).meta({ title: 'Model steps per turn' }),
- timeoutSeconds: z.number().int().min(1).default(600).meta({ title: 'Turn timeout in seconds' }),
+ ratePerMinute: z.number().int().min(1).default(10).meta({
+ title: 'Turns per minute',
+ description: 'How many replies each person can start in a minute. More wait their turn.',
+ }),
+ maxSteps: z.number().int().min(1).default(8).meta({
+ title: 'Steps per turn',
+ description: 'How many times a model can call tools and go on before it has to answer.',
+ }),
+ timeoutSeconds: z.number().int().min(1).default(600).meta({
+ title: 'Turn timeout',
+ description: 'A reply still running after this long is stopped.',
+ unit: 'seconds',
+ }),
backfillMinutes: z.number().int().min(0).default(60).meta({
- title: 'Backfill window in minutes',
+ title: 'Backfill window',
description: 'How old a synced message asking for a reply can be and still get one.',
+ unit: 'minutes',
}),
systemPrompt: z.string().default(DEFAULT_SYSTEM_PROMPT).meta({
title: 'System prompt',
- description: 'Placeholders: {{appName}}, {{date}}, {{time}}, {{timezone}}.',
+ description:
+ 'Sent with every turn. Placeholders: {{appName}}, {{date}}, {{time}}, {{timezone}}.',
multiline: true,
}),
}),
@@ -38,30 +57,39 @@ export const settingSchemas = {
safetyNet: z
.object({
enabled: z.boolean().default(true).meta({ title: 'Enabled' }),
- intervalMinutes: minutes('Every this many minutes', 15),
+ intervalMinutes: minutes('Runs every', 15),
activeWithinHours: z
.number()
.positive()
.default(24)
- .meta({ title: 'For users active within this many hours' }),
+ .meta({ title: 'For people active in the last', unit: 'hours' }),
})
.prefault({})
- .meta({ title: 'Safety net' }),
+ .meta({
+ title: 'Safety net',
+ description:
+ 'Syncs the chats of recently active people now and then, in case a change notice from their PDS was missed.',
+ }),
discovery: z
.object({
- intervalMinutes: minutes('Every this many minutes', 60),
+ intervalMinutes: minutes('Runs every', 60),
activeWithinDays: z
.number()
.positive()
.default(30)
- .meta({ title: 'For users active within this many days' }),
+ .meta({ title: 'For people active in the last', unit: 'days' }),
})
.prefault({})
- .meta({ title: 'Discovery' }),
- allowUserOptOut: z
- .boolean()
- .default(true)
- .meta({ title: 'Users can turn off background sync' }),
+ .meta({
+ title: 'Discovery',
+ description:
+ "Looks for chats missing from people's chat index, such as ones another app made, and adds them.",
+ }),
+ allowUserOptOut: z.boolean().default(true).meta({
+ title: 'People can turn off background sync',
+ description:
+ 'Each person can turn it off in their own Sync settings. When this is off, it runs for everyone.',
+ }),
}),
}
diff --git a/apps/web/src/features/admin/components/Invites.tsx b/apps/web/src/features/admin/components/Invites.tsx
index f1db07c..a9454d9 100644
--- a/apps/web/src/features/admin/components/Invites.tsx
+++ b/apps/web/src/features/admin/components/Invites.tsx
@@ -3,9 +3,12 @@ import { api, json, read } from '../../../shared/api.ts'
import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
import { lastError } from '../../../shared/errors.ts'
import { useAppForm } from '../../../shared/form.tsx'
+import { Button } from '../../../shared/ui/Button.tsx'
+import { Item, ItemList, ItemNames, ListLabel } from '../../../shared/ui/ItemList.tsx'
+import { cardAlert, inlineForm, SettingsGroup } from '../../../shared/ui/SettingsPage.tsx'
import { adminInvitesQuery } from '../queries.ts'
-/** People an admin added who haven't signed in yet, and a form to add more. */
+/** People an admin added who haven't signed in yet, under a form to add more. */
export function Invites() {
const { data: invites } = useSuspenseQuery(adminInvitesQuery)
const queryClient = useQueryClient()
@@ -24,11 +27,12 @@ export function Invites() {
add.mutate(value.identifier, { onSuccess: () => formApi.reset() }),
})
return (
-
+
-
-
Add user
-
They can create an account whatever the registration mode is.
- )}
-
-
-
- {(field) => (
-
+ <>
+
+
+
+
+ {listed.length > 0 && (
+ // Models the provider lists, to tick the ones to offer, over the field to type one.
+
+
+
+
+
+ >
)
}
-interface ModelTableProps {
+/** What adding a provider's models takes, which depends on its key and whether it lists them. */
+function addHint(provider: Provider) {
+ if (!provider.hasAdminKey) return "Add an admin key to offer this provider's models."
+ if (provider.listsModels)
+ return 'Pick from the models the provider lists, using the settings above as they are now, or type a model ID.'
+ return 'Type the ID of a model to offer.'
+}
+
+interface ModelListProps {
pluginId: string
models: AdminModel[]
}
-/** The provider's admin models, each linking to its page. */
-function ModelTable(props: ModelTableProps) {
- if (props.models.length === 0) return null
+/** The provider's admin models, each opening its page, with what it can do and who gets it. */
+function ModelList(props: ModelListProps) {
+ if (props.models.length === 0) return No models yet.
return (
-
+
+ {props.models.map((model) => {
+ const capabilities = capabilityNames.filter((c) => model.capabilities[c])
+ return (
+
+
+ {model.name}
+ {' '}
+ {model.default && Default}
+ >
+ }
+ details={[
+
+ {model.id}
+ ,
+ capabilities.length > 0 && capabilities.join(', '),
+ model.roles.length ? `For ${model.roles.join(', ')}` : 'No roles',
+ ]}
+ />
+ )
+ })}
+
)
}
diff --git a/apps/web/src/features/admin/components/ReorderButtons.tsx b/apps/web/src/features/admin/components/ReorderButtons.tsx
index 3e8040f..30c9f97 100644
--- a/apps/web/src/features/admin/components/ReorderButtons.tsx
+++ b/apps/web/src/features/admin/components/ReorderButtons.tsx
@@ -1,24 +1,33 @@
+import { ArrowDownIcon, ArrowUpIcon } from '../../../shared/icons.tsx'
+import { IconButton } from '../../../shared/ui/Button.tsx'
+
interface Props {
index: number
count: number
+ /** Whether a move is on its way, so another waits for it. */
+ disabled?: boolean
/** Move the item by this many places: -1 for up, 1 for down. */
onMove: (by: number) => void
}
-/** Up and Down buttons for an item in an ordered list. */
+/** Up and down arrows for an item in an ordered list. */
export function ReorderButtons(props: Props) {
return (
- <>
- {' '}
-
- >
+
+
+
)
}
diff --git a/apps/web/src/features/admin/components/RolePicker.tsx b/apps/web/src/features/admin/components/RolePicker.tsx
index 67bda46..d362816 100644
--- a/apps/web/src/features/admin/components/RolePicker.tsx
+++ b/apps/web/src/features/admin/components/RolePicker.tsx
@@ -1,17 +1,27 @@
+import { cn } from '../../../shared/cn.ts'
+import { SettingsEmpty } from '../../../shared/ui/SettingsPage.tsx'
+
interface Props {
names: string[]
picked: string[]
onChange: (roles: string[]) => void
+ /** Classes for the row, such as dropping its padding inside a field that has its own. */
+ className?: string
}
-/** A checkbox for each role, for choosing several. */
+/** A checkbox for each role, for choosing several, drawn as a row of chips that turn green when picked. */
export function RolePicker(props: Props) {
+ if (!props.names.length) return No roles yet.
return (
- <>
+
{props.names.map((name) => (
-
)
}
diff --git a/apps/web/src/features/admin/components/SuspendControl.tsx b/apps/web/src/features/admin/components/SuspendControl.tsx
index 29adf83..6199f11 100644
--- a/apps/web/src/features/admin/components/SuspendControl.tsx
+++ b/apps/web/src/features/admin/components/SuspendControl.tsx
@@ -1,4 +1,7 @@
+import { useState } from 'react'
import { useAppForm } from '../../../shared/form.tsx'
+import { Badge } from '../../../shared/ui/Badge.tsx'
+import { Button } from '../../../shared/ui/Button.tsx'
import type { AdminUser } from '../queries.ts'
interface Props {
@@ -9,26 +12,68 @@ interface Props {
onRestore: () => void
}
-/** Who suspended an account and why, with Restore, or a form to suspend it. */
+/**
+ * Access at the end of an account's row, which drops under its details in a narrow column, in line
+ * with them.
+ */
+const access =
+ 'mt-0.5 inline-flex flex-none items-start gap-2 @max-narrow:mt-0 @max-narrow:basis-full @max-narrow:ps-11.5'
+
+/**
+ * Whether an account may use the app: Suspended, with who did it and why cut short under it, and
+ * Restore, or Active with Suspend, which asks for a reason across the row first.
+ */
export function SuspendControl(props: Props) {
- if (props.suspension)
+ const [suspending, setSuspending] = useState(false)
+ if (props.suspension) {
+ const by = `By ${props.suspension.by}`
+ const note = props.suspension.reason ? `${props.suspension.reason} · ${by}` : by
return (
- <>
- Suspended by {props.suspension.by}
- {props.suspension.reason && `: ${props.suspension.reason}`}{' '}
-
+
+ )
+ }
+ if (!suspending)
+ return (
+
+
+ Active
+
+ setSuspending(true)}>
+ Suspend
+
+
)
- return
+ return (
+ setSuspending(false)}
+ onSuspend={(reason) => {
+ setSuspending(false)
+ props.onSuspend(reason)
+ }}
+ />
+ )
}
interface SuspendFormProps {
name: string
+ onCancel: () => void
onSuspend: (reason: string) => void
}
+/** Asking for a reason, across the row under the account's details. */
function SuspendForm(props: SuspendFormProps) {
const form = useAppForm({
defaultValues: { reason: '' },
@@ -36,16 +81,22 @@ function SuspendForm(props: SuspendFormProps) {
})
return (
-
+
{(field) => (
)}
- Suspend
+ Cancel
+
+ Suspend
+
)
diff --git a/apps/web/src/features/admin/components/UserRow.tsx b/apps/web/src/features/admin/components/UserRow.tsx
index 4ba8af1..d7c1166 100644
--- a/apps/web/src/features/admin/components/UserRow.tsx
+++ b/apps/web/src/features/admin/components/UserRow.tsx
@@ -1,4 +1,8 @@
-import { LocalTime } from '../../../shared/LocalTime.tsx'
+import { ClientOnly } from '@tanstack/react-router'
+import { CloseIcon } from '../../../shared/icons.tsx'
+import { timeAgo } from '../../../shared/time.ts'
+import { Badge, Chip } from '../../../shared/ui/Badge.tsx'
+import { Item, ItemMark, ItemNames } from '../../../shared/ui/ItemList.tsx'
import type { UserActions } from '../hooks/useUserActions.ts'
import type { AdminUser } from '../queries.ts'
import { SuspendControl } from './SuspendControl.tsx'
@@ -10,59 +14,79 @@ interface Props {
actions: UserActions
}
-/** An account: who it is, its roles, when it was last active, and what an admin can do to it. */
+/**
+ * An account: its initial, its handle over its DID, storage, and when it was last active, its roles
+ * as chips with a way to add one, and its access at the end.
+ */
export function UserRow(props: Props) {
const user = props.user
const name = user.handle ?? user.did
return (
-
+
+
+
+ props.actions.suspend(user.did, reason)}
+ onRestore={() => props.actions.restore(user.did)}
+ />
+
)
}
diff --git a/apps/web/src/features/admin/components/UserSearch.tsx b/apps/web/src/features/admin/components/UserSearch.tsx
index b963e19..f39d750 100644
--- a/apps/web/src/features/admin/components/UserSearch.tsx
+++ b/apps/web/src/features/admin/components/UserSearch.tsx
@@ -1,5 +1,9 @@
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
+import { cn } from '../../../shared/cn.ts'
+import { Button } from '../../../shared/ui/Button.tsx'
+import { field } from '../../../shared/ui/controls.tsx'
+import { inlineForm } from '../../../shared/ui/SettingsPage.tsx'
interface Props {
q: string
@@ -17,6 +21,8 @@ export function UserSearch(props: Props) {
}
return (
)
}
diff --git a/apps/web/src/features/admin/pages/AccessAdmin.tsx b/apps/web/src/features/admin/pages/AccessAdmin.tsx
index c11d0bb..3f77041 100644
--- a/apps/web/src/features/admin/pages/AccessAdmin.tsx
+++ b/apps/web/src/features/admin/pages/AccessAdmin.tsx
@@ -1,8 +1,15 @@
import { useStore as useFormStore } from '@tanstack/react-form'
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { api, json, read } from '../../../shared/api.ts'
-import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
+import { messageOf } from '../../../shared/errors.ts'
import { formKey, useAppForm } from '../../../shared/form.tsx'
+import {
+ Choice,
+ Choices,
+ SaveBar,
+ SettingsGroup,
+ SettingsPage,
+} from '../../../shared/ui/SettingsPage.tsx'
import { RolePicker } from '../components/RolePicker.tsx'
import { roleNames } from '../lib/roles.ts'
import { adminAccessQuery, adminRolesQuery } from '../queries.ts'
@@ -14,13 +21,14 @@ interface Access {
inviteRoles: string[]
}
-const modes: { mode: Registration; label: string }[] = [
- { mode: 'open', label: 'Open: anyone with an atproto account can create an account.' },
+const modes: { mode: Registration; label: string; detail: string }[] = [
+ { mode: 'open', label: 'Open', detail: 'Anyone with an atproto account can create an account.' },
{
mode: 'invite',
- label: 'Invite: members of the roles below, and people added on the Users page.',
+ label: 'Invite',
+ detail: 'Members of the roles below, and people added on the Users page.',
},
- { mode: 'closed', label: 'Closed: only people added on the Users page.' },
+ { mode: 'closed', label: 'Closed', detail: 'Only people added on the Users page.' },
]
/** Who can create an account. */
@@ -33,60 +41,71 @@ export function AccessAdmin() {
onSuccess: (saved) => queryClient.setQueryData(adminAccessQuery.queryKey, saved),
})
return (
-
-
Access
+
-
-
+
)
}
interface AccessFormProps {
stored: Access
roleNames: string[]
+ pending: boolean
saved: boolean
+ error?: string | null
onSave: (access: Access) => void
+ /** Called on any change, since what is shown is no longer what was saved. */
+ onEdit: () => void
}
function AccessForm(props: AccessFormProps) {
const form = useAppForm({
defaultValues: props.stored,
+ listeners: { onChange: () => props.onEdit() },
onSubmit: ({ value }) => props.onSave(value),
})
const registration = useFormStore(form.store, (state) => state.values.registration)
return (
-
- Who can create an account. People who already have one keep it, until you suspend them on
- the Users page. Admins can always sign in, and anyone can sign in from a share link to
- view a chat shared with them.
-
- {(field) =>
- modes.map(({ mode, label }) => (
-
- (
+
+ {modes.map((mode) => (
+ field.handleChange(mode)}
- />{' '}
- {label}
-
- ))
- }
+ value={mode.mode}
+ checked={field.state.value === mode.mode}
+ onChange={() => field.handleChange(mode.mode)}
+ label={mode.label}
+ description={mode.detail}
+ />
+ ))}
+
+ )}
{registration === 'invite' && (
-
+
)}
- Save
- {props.saved && Saved.}
+
)
diff --git a/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx b/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx
index 3f48996..107a7aa 100644
--- a/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx
+++ b/apps/web/src/features/admin/pages/ApiKeysAdmin.tsx
@@ -1,20 +1,54 @@
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
+import { ClientOnly } from '@tanstack/react-router'
import { api, json, read } from '../../../shared/api.ts'
import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
+import { messageOf } from '../../../shared/errors.ts'
import { useAppForm } from '../../../shared/form.tsx'
-import { LocalTime } from '../../../shared/LocalTime.tsx'
+import { timeAgo } from '../../../shared/time.ts'
+import { Item, ItemList, ItemNames } from '../../../shared/ui/ItemList.tsx'
+import { Secret } from '../../../shared/ui/Secret.tsx'
+import {
+ Callout,
+ ConfirmButton,
+ cardAlert,
+ SaveBar,
+ SettingRow,
+ SettingsEmpty,
+ SettingsGroup,
+ SettingsPage,
+} from '../../../shared/ui/SettingsPage.tsx'
import { RolePicker } from '../components/RolePicker.tsx'
import { rolesWithUser } from '../lib/roles.ts'
-import { adminApiKeysQuery, adminRolesQuery } from '../queries.ts'
+import { type AdminApiKey, adminApiKeysQuery, adminRolesQuery } from '../queries.ts'
/** Issue and revoke the keys that scripts, such as a crontab, send as a Bearer token. */
export function ApiKeysAdmin() {
+ const keysChanged = useKeysChanged()
+ const issue = useMutation({
+ mutationFn: (draft: { label: string; roles: string[] }) =>
+ read(api.admin['api-keys'].$post({}, json(draft))),
+ onSuccess: keysChanged,
+ })
return (
-
-
API keys
-
-
-
+
+ {issue.data && (
+
+ Copy this key now. It can't be shown again.
+
+
+ )}
+
+
+
+ issue.mutate(draft, { onSuccess: issued })}
+ />
+
)
}
@@ -23,102 +57,135 @@ function useKeysChanged() {
return () => queryClient.invalidateQueries({ queryKey: adminApiKeysQuery.queryKey })
}
-function ApiKeyTable() {
+/** The issued keys with their roles and use, each with a way to revoke it. */
+function ApiKeyList() {
const { data: keys } = useSuspenseQuery(adminApiKeysQuery)
const keysChanged = useKeysChanged()
const revoke = useMutation({
mutationFn: (id: string) => read(api.admin['api-keys'][':id'].$delete({ param: { id } })),
onSuccess: keysChanged,
})
+ if (!keys.length) return No keys yet.
return (
<>
-
+ Issue a key with the admin role on the{' '}
+ API keys page.
+
+
+ Call cron on a schedule, for example every five minutes from a crontab:
+
+
+
+
+
+
+
)
}
+/** The crontab line for this server, which only the browser knows the address of. */
+function CronLine() {
+ return (
+ " ${window.location.origin}/api/cron`}
+ />
+ )
+}
+
+const notUpdated = "Plugins that work on a schedule, such as member lists, aren't being updated."
+
interface LastRunProps {
- run: { startedAt: string; finishedAt: string | null; failed: string[] } | null
+ /** The last run, or null when cron has never run. */
+ run: CronRun | null
}
-/** How the last run went, warning when cron has never run or hasn't run in a day. */
+/** How the last run went, warning when cron has never run or hasn't in over a day. */
function LastRun(props: LastRunProps) {
if (!props.run)
return (
-
- Cron has never run. Plugins that work on a schedule, such as member lists, aren't being
- updated.
-
- Cron hasn't run in over a day. Plugins that work on a schedule, such as member lists,
- aren't being updated.
-
+ // Times are in the browser's locale and time zone, which the server rendering the page lacks.
+
+ {Date.now() - Date.parse(run.startedAt) > dayMs && (
+
+
+
+ )}
-
+
+
+ {messageOf(remove.error)}
+
+ )
+ }
+ >
+ remove.mutate()}
+ />
+
+
+
)
}
interface ModelFormProps {
model: AdminModel
roleNames: string[]
+ pending: boolean
saved: boolean
+ error?: string | null
onSave: (model: AdminModel) => void
- onRemove: () => void
+ /** Called on any change, since what is shown is no longer what was saved. */
+ onEdit: () => void
}
+const capabilities = [
+ { key: 'vision', label: 'Vision', description: 'Reads images people attach.' },
+ {
+ key: 'reasoning',
+ label: 'Thinking',
+ description: 'Thinks before it answers, at the effort people pick.',
+ },
+ { key: 'tools', label: 'Tools', description: 'Calls the tools plugins offer.' },
+] as const
+
/** One admin model's name, capabilities, and roles. */
function ModelForm(props: ModelFormProps) {
const form = useAppForm({
defaultValues: props.model,
+ listeners: { onChange: () => props.onEdit() },
onSubmit: ({ value }) => props.onSave(value),
})
return (
- {(field) => }
-
-
- Save{' '}
-
- Remove model
-
- {props.saved && Saved.}
+
+
)
diff --git a/apps/web/src/features/admin/pages/ModelsAdmin.tsx b/apps/web/src/features/admin/pages/ModelsAdmin.tsx
index 8d3d8b0..d2a95c4 100644
--- a/apps/web/src/features/admin/pages/ModelsAdmin.tsx
+++ b/apps/web/src/features/admin/pages/ModelsAdmin.tsx
@@ -1,8 +1,18 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
+import { useId } from 'react'
import { api, json, read } from '../../../shared/api.ts'
import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
import { lastError } from '../../../shared/errors.ts'
+import { Badge } from '../../../shared/ui/Badge.tsx'
+import { Radio } from '../../../shared/ui/controls.tsx'
+import { Item, ItemNames } from '../../../shared/ui/ItemList.tsx'
+import {
+ cardAlert,
+ SettingsEmpty,
+ SettingsGroup,
+ SettingsPage,
+} from '../../../shared/ui/SettingsPage.tsx'
import { ReorderButtons } from '../components/ReorderButtons.tsx'
import { useModelsChanged } from '../hooks/changes.ts'
import { storedModel } from '../lib/models.ts'
@@ -29,27 +39,38 @@ export function ModelsAdmin() {
onSuccess: modelsChanged,
})
return (
-
-
Models
-
- Models are added on their provider's plugin page, under{' '}
- Plugins. Here you set the default and the order users see
- them in.
-
-
- {models.map((model, index) => (
- makeDefault.mutate(model)}
- onMove={(by) => reorder.mutate(moveItem(models, index, by))}
- />
- ))}
-
-
-
+
+ The default model, and the order people see models in. Models are added on their
+ provider's plugin page, under Plugins.
+ >
+ }
+ >
+ {models.length === 0 && No models yet.}
+ {models.length > 0 && (
+
+
+ {models.map((model, index) => (
+ makeDefault.mutate(model)}
+ onMove={(by) => reorder.mutate(moveItem(models, index, by))}
+ />
+ ))}
+
+
+
+ )}
+
)
}
@@ -57,25 +78,54 @@ interface ModelRowProps {
model: AdminModelEntry
index: number
count: number
+ /** Whether a move is on its way. */
+ moving: boolean
onMakeDefault: () => void
onMove: (by: number) => void
}
+/**
+ * A model as a row that is also a radio, which picks it as the default from a click anywhere on
+ * it, with arrows that move it beside it.
+ */
function ModelRow(props: ModelRowProps) {
const model = props.model
+ const ref = `${model.provider}/${model.id}`
+ const id = useId()
+ const warningId = useId()
return (
-
+
+ )}
remove.mutate()}
/>
-
- >
+
)
}
interface PluginDetailsProps {
- instance: AdminPlugin | undefined
+ instance: AdminPlugin
issues: AdminPlugin['issues']
+ pending: boolean
saved: boolean
+ error?: string | null
+ removeError?: string | null
onSave: (change: PluginChange) => void
+ /** Called on any change, since what is shown is no longer what was saved. */
+ onEdit: () => void
onRemove: () => void
}
-/** The plugin's status and form, or that it isn't configured. */
+/** The plugin's form, with its provider models from the models list. */
function PluginDetails(props: PluginDetailsProps) {
const { data: models } = useSuspenseQuery(adminModelsQuery)
- if (!props.instance) return
+
-
+
{(field) => (
Add member
-
- >
+
+
+
+ )
+}
+
+interface MemberListProps extends RoleProps {
+ /** The admins set in the environment, listed on the admin role but not removable. */
+ environmentAdmins: string[]
+ onRemove: (did: string) => void
+}
+
+/** A member's DID, when their handle names them, and who added them. */
+function memberNote(member: AdminRole['members'][number]) {
+ const note = [member.handle && member.did, member.addedBy && `Added by ${member.addedBy}`]
+ return note.filter(Boolean).join(' · ') || undefined
+}
+
+/** A role's explicit members, with who added each, or a note that there are none. */
+function MemberList(props: MemberListProps) {
+ if (!props.role.members.length && !props.environmentAdmins.length)
+ return No one has been added yet.
+ return (
+ // The list follows the form it adds to, with no line between.
+
+ {props.environmentAdmins.map((did) => (
+
+ {null}
+
+ ADMIN_DIDS
+
+ ))}
+ {props.role.members.map((member) => (
+
+ {member.handle?.slice(0, 1).toUpperCase()}
+
+ props.onRemove(member.did)}>
+ Remove
+
+
+ ))}
+
)
}
diff --git a/apps/web/src/features/admin/pages/RolesAdmin.tsx b/apps/web/src/features/admin/pages/RolesAdmin.tsx
index 08776c9..256e4f5 100644
--- a/apps/web/src/features/admin/pages/RolesAdmin.tsx
+++ b/apps/web/src/features/admin/pages/RolesAdmin.tsx
@@ -1,32 +1,39 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { Link, useNavigate } from '@tanstack/react-router'
import { api, json, read } from '../../../shared/api.ts'
+import { cn } from '../../../shared/cn.ts'
import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
import { useAppForm } from '../../../shared/form.tsx'
+import { plural } from '../../../shared/plural.ts'
+import { Badge } from '../../../shared/ui/Badge.tsx'
+import { ItemList, LinkItem } from '../../../shared/ui/ItemList.tsx'
+import {
+ cardAlert,
+ inlineForm,
+ SettingsGroup,
+ SettingsPage,
+} from '../../../shared/ui/SettingsPage.tsx'
import { useRolesChanged } from '../hooks/changes.ts'
-import { adminRolesQuery } from '../queries.ts'
+import { type AdminRole, adminRolesQuery } from '../queries.ts'
/** A summary of every role, each linking to its page, and a form to create one. */
export function RolesAdmin() {
const { data } = useSuspenseQuery(adminRolesQuery)
return (
-
-
Roles
-
- Everyone who has signed in holds the user role. A role's members are the people
- added to it, plus everyone whose PDS host or handle domain matches.
-
-
-
-
-
Role
-
Description
-
Members
-
PDS hosts
-
Handle domains
-
-
-
+
+
+ Everyone who has signed in holds user. A role's members are the people
+ added to it, plus everyone whose PDS host or handle domain matches.
+ >
+ }
+ >
+
{data.roles.map((role) => (
))}
-
-
+
+
-
+
)
}
interface RoleRowProps {
- role: {
- name: string
- description: string
- members: unknown[]
- pdsHosts: string[]
- handleDomains: string[]
- }
+ role: AdminRole
/** How many admins the environment names, who count as members of the admin role. */
environmentAdmins: number
}
+/** A role's name, which opens its page, over its description, member count, and rules. */
function RoleRow(props: RoleRowProps) {
const role = props.role
+ const members = role.members.length + (role.name === 'admin' ? props.environmentAdmins : 0)
return (
-
+ {/* The name is short, so the description takes the rest of the line. */}
+ input:first-child]:max-w-48')}>
- {(field) => }
+ {(field) => (
+
+ )}
- {(field) => }
+ {(field) => (
+
+ )}
Create role
-
- >
+
+
)
}
diff --git a/apps/web/src/features/admin/pages/SettingsAdmin.tsx b/apps/web/src/features/admin/pages/SettingsAdmin.tsx
index 3995415..7e4143c 100644
--- a/apps/web/src/features/admin/pages/SettingsAdmin.tsx
+++ b/apps/web/src/features/admin/pages/SettingsAdmin.tsx
@@ -1,10 +1,10 @@
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { api, json, read } from '../../../shared/api.ts'
-import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
import { issuesOf, messageOf } from '../../../shared/errors.ts'
import { formKey, useAppForm } from '../../../shared/form.tsx'
import type { Issue } from '../../../shared/response.ts'
import { type Schema, SchemaFields } from '../../../shared/schema-fields/SchemaFields.tsx'
+import { SaveBar, SettingsGroup, SettingsPage } from '../../../shared/ui/SettingsPage.tsx'
import { adminSettingsQuery } from '../queries.ts'
interface Group {
@@ -45,72 +45,151 @@ async function saveGroups(groups: Group[], edited: SettingValues) {
}
}
+/** What each group of settings is called on the page. */
+const groupTitles: Record = {
+ general: 'App',
+ sessions: 'Sessions',
+ turns: 'Turns',
+ sync: 'Background sync',
+}
+
+/**
+ * A card of settings: one setting's fields, all of them or the ones named. A card of a single
+ * nested group of fields, such as the sync safety net, holds the group's own fields, under its
+ * title and description.
+ */
+export interface Section {
+ key: string
+ /** The card's heading, the setting's own name, or the group's, by default. */
+ title?: string
+ fields?: string[]
+}
+
+/** The schema with only the named fields, in its own order, or all of them. */
+const only = (schema: Schema, fields?: string[]): Schema =>
+ fields
+ ? {
+ ...schema,
+ properties: Object.fromEntries(
+ Object.entries(schema.properties ?? {}).filter(([key]) => fields.includes(key)),
+ ),
+ }
+ : schema
+
interface SettingsAdminProps {
title: string
- keys: string[]
+ /** One line under the title saying what the settings are for. */
+ description: string
+ sections: Section[]
}
-/** Some of the app's own settings, as forms generated from their schemas, with one Save. */
+/** Some of the app's own settings, in cards chosen by the route, with one Save. */
export function SettingsAdmin(props: SettingsAdminProps) {
const { data: settings } = useSuspenseQuery(adminSettingsQuery)
const queryClient = useQueryClient()
- const groups: Group[] = settings.filter((group) => props.keys.includes(group.key))
+ const keys = new Set(props.sections.map((section) => section.key))
+ const groups: Group[] = settings.filter((group) => keys.has(group.key))
const save = useMutation({
mutationFn: (edited: SettingValues) => saveGroups(groups, edited),
onSuccess: () => queryClient.invalidateQueries({ queryKey: adminSettingsQuery.queryKey }),
})
const failed = save.error instanceof GroupFailed ? save.error : null
return (
-
-
{props.title}
+
-
-
+
)
}
interface SettingsFormProps {
groups: Group[]
+ sections: Section[]
/** The group whose save failed, with the problems the server found in it. */
failed: { key: string; issues: Issue[] } | null
+ pending: boolean
saved: boolean
+ error?: string | null
onSave: (values: SettingValues) => void
+ /** Called on any change, since what is shown is no longer what was saved. */
+ onEdit: () => void
}
function SettingsForm(props: SettingsFormProps) {
// Fields are addressed by position, as in the user's plugin form.
const form = useAppForm({
defaultValues: { groups: props.groups.map((group) => group.value) } as SettingValues,
+ listeners: { onChange: () => props.onEdit() },
onSubmit: ({ value }) => props.onSave(value),
})
+ const issues = (key: string) => (props.failed?.key === key ? props.failed.issues : [])
return (
- {props.groups.map((group, i) => (
-
- ))}
- Save
- {props.saved && Saved.}
+ {props.sections.map((section) => {
+ const i = props.groups.findIndex((group) => group.key === section.key)
+ const group = props.groups[i]
+ if (!group) return null
+ const [name] = section.fields ?? []
+ const nested =
+ name && section.fields?.length === 1 ? group.schema.properties?.[name] : undefined
+ if (name && nested?.type === 'object' && nested.properties)
+ return (
+
+
+ {(field) => (
+ }
+ issues={issues(group.key)
+ .filter((issue) => issue.path[0] === name)
+ .map((issue) => ({ ...issue, path: issue.path.slice(1) }))}
+ onChange={(key, value) =>
+ field.handleChange((current) => ({
+ ...current,
+ [name]: { ...((current?.[name] ?? {}) as object), [key]: value },
+ }))
+ }
+ />
+ )}
+
+
+ )
+ return (
+
+
+ {(field) => (
+
+ field.handleChange((current) => ({ ...current, [key]: value }))
+ }
+ />
+ )}
+
+
+ )
+ })}
+
)
diff --git a/apps/web/src/features/admin/pages/UsersAdmin.tsx b/apps/web/src/features/admin/pages/UsersAdmin.tsx
index ca40966..3e6ddac 100644
--- a/apps/web/src/features/admin/pages/UsersAdmin.tsx
+++ b/apps/web/src/features/admin/pages/UsersAdmin.tsx
@@ -1,6 +1,15 @@
import { useSuspenseInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
import { ErrorAlert } from '../../../shared/ErrorAlert.tsx'
+import { Button } from '../../../shared/ui/Button.tsx'
+import { ItemList } from '../../../shared/ui/ItemList.tsx'
+import {
+ cardAlert,
+ SettingsEmpty,
+ SettingsGroup,
+ SettingsPage,
+ undivided,
+} from '../../../shared/ui/SettingsPage.tsx'
import { Invites } from '../components/Invites.tsx'
import { UserRow } from '../components/UserRow.tsx'
import { UserSearch } from '../components/UserSearch.tsx'
@@ -15,57 +24,53 @@ interface UsersAdminProps {
/** Accounts, with their roles and suspensions, and the people added who haven't signed in yet. */
export function UsersAdmin(props: UsersAdminProps) {
return (
-
-
Users
+
-
Accounts
-
- {/* Only the results wait for a new search, so the search box keeps its focus. */}
- Loading…}>
-
-
-
+
+
+ {/* Only the results wait for a new search, so the search box keeps its focus. */}
+ Loading…}>
+
+
+
+
)
}
-interface UserTableProps {
+interface UserListProps {
q: string
}
-/** The accounts matching the search, a page at a time. */
-function UserTable(props: UserTableProps) {
+/** The accounts matching the search, a page at a time, read as the search's results. */
+function UserList(props: UserListProps) {
const users = useSuspenseInfiniteQuery(adminUsersQuery(props.q))
const { data: roles } = useSuspenseQuery(adminRolesQuery)
const actions = useUserActions()
const assignable = roleNames(roles.roles)
+ const accounts = users.data.pages.flatMap((page) => page.users)
return (
<>
-
)}
-
+
>
)
}
diff --git a/apps/web/src/features/admin/queries.ts b/apps/web/src/features/admin/queries.ts
index 6a6ff8b..e8f5e29 100644
--- a/apps/web/src/features/admin/queries.ts
+++ b/apps/web/src/features/admin/queries.ts
@@ -78,14 +78,24 @@ export const adminSettingsQuery = queryOptions({
queryFn: async () => (await read(api.admin.settings.$get())).settings,
})
+const fetchApiKeys = async () => (await read(api.admin['api-keys'].$get())).keys
+
+/** An issued key, without the key itself. */
+export type AdminApiKey = Awaited>[number]
+
export const adminApiKeysQuery = queryOptions({
queryKey: ['admin', 'api-keys'],
staleTime,
- queryFn: async () => (await read(api.admin['api-keys'].$get())).keys,
+ queryFn: fetchApiKeys,
})
+const fetchCron = async () => (await read(api.admin.cron.$get())).lastRun
+
+/** Cron's last run, or null when it has never run. */
+export type CronRun = NonNullable>>
+
export const adminCronQuery = queryOptions({
queryKey: ['admin', 'cron'],
staleTime,
- queryFn: async () => (await read(api.admin.cron.$get())).lastRun,
+ queryFn: fetchCron,
})
diff --git a/apps/web/src/features/chat-list/components/SyncStatus.tsx b/apps/web/src/features/chat-list/components/SyncStatus.tsx
index e151805..4383cc1 100644
--- a/apps/web/src/features/chat-list/components/SyncStatus.tsx
+++ b/apps/web/src/features/chat-list/components/SyncStatus.tsx
@@ -3,10 +3,10 @@ import { useContext, useEffect, useState } from 'react'
import { cn } from '../../../shared/cn.ts'
import { CloudIcon, DriveIcon, SyncIcon } from '../../../shared/icons.tsx'
import { plural } from '../../../shared/plural.ts'
+import { timeAgo } from '../../../shared/time.ts'
import type { HandoverState } from '../../../store/handover.ts'
import { useStore, useStoreState, useSyncStatus } from '../../../store/react.tsx'
import { MeContext } from '../../auth/session.ts'
-import { timeAgo } from '../lib/groups.ts'
/** The current time, ticking every half minute so "2 min ago" stays true. */
function useNow(): Date {
diff --git a/apps/web/src/features/chat-list/lib/groups.ts b/apps/web/src/features/chat-list/lib/groups.ts
index 2209f95..195f108 100644
--- a/apps/web/src/features/chat-list/lib/groups.ts
+++ b/apps/web/src/features/chat-list/lib/groups.ts
@@ -34,17 +34,3 @@ export function groupByDate(
}
return groups
}
-
-/** Relative times in the interface's language, which a translation changes along with the copy. */
-const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
-
-/** How long ago a time was, such as "just now", "5 minutes ago", or "1 hour ago". */
-export function timeAgo(then: Date, now = new Date()): string {
- const seconds = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000))
- if (seconds < 45) return 'just now'
- const minutes = Math.round(seconds / 60)
- if (minutes < 60) return relative.format(-minutes, 'minute')
- const hours = Math.round(minutes / 60)
- if (hours < 24) return relative.format(-hours, 'hour')
- return then.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
-}
diff --git a/apps/web/src/routes/_app/admin/general.tsx b/apps/web/src/routes/_app/admin/general.tsx
index b88440d..5c93211 100644
--- a/apps/web/src/routes/_app/admin/general.tsx
+++ b/apps/web/src/routes/_app/admin/general.tsx
@@ -1,10 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'
-import { SettingsAdmin } from '../../../features/admin/pages/SettingsAdmin.tsx'
+import { type Section, SettingsAdmin } from '../../../features/admin/pages/SettingsAdmin.tsx'
import { adminSettingsQuery } from '../../../features/admin/queries.ts'
-const keys = ['general', 'sessions']
+const sections: Section[] = [{ key: 'general' }, { key: 'sessions' }]
export const Route = createFileRoute('/_app/admin/general')({
loader: ({ context }) => context.queryClient.query(adminSettingsQuery),
- component: () => ,
+ component: () => (
+
+ ),
})
diff --git a/apps/web/src/routes/_app/admin/sync.tsx b/apps/web/src/routes/_app/admin/sync.tsx
index 2f765c9..0fe615c 100644
--- a/apps/web/src/routes/_app/admin/sync.tsx
+++ b/apps/web/src/routes/_app/admin/sync.tsx
@@ -1,10 +1,20 @@
import { createFileRoute } from '@tanstack/react-router'
-import { SettingsAdmin } from '../../../features/admin/pages/SettingsAdmin.tsx'
+import { type Section, SettingsAdmin } from '../../../features/admin/pages/SettingsAdmin.tsx'
import { adminSettingsQuery } from '../../../features/admin/queries.ts'
-const keys = ['sync']
+const sections: Section[] = [
+ { key: 'sync', fields: ['safetyNet'] },
+ { key: 'sync', fields: ['discovery'] },
+ { key: 'sync', title: 'Opting out', fields: ['allowUserOptOut'] },
+]
export const Route = createFileRoute('/_app/admin/sync')({
loader: ({ context }) => context.queryClient.query(adminSettingsQuery),
- component: () => ,
+ component: () => (
+
+ ),
})
diff --git a/apps/web/src/routes/_app/admin/turns.tsx b/apps/web/src/routes/_app/admin/turns.tsx
index 4ffbcd1..f8f8386 100644
--- a/apps/web/src/routes/_app/admin/turns.tsx
+++ b/apps/web/src/routes/_app/admin/turns.tsx
@@ -1,10 +1,23 @@
import { createFileRoute } from '@tanstack/react-router'
-import { SettingsAdmin } from '../../../features/admin/pages/SettingsAdmin.tsx'
+import { type Section, SettingsAdmin } from '../../../features/admin/pages/SettingsAdmin.tsx'
import { adminSettingsQuery } from '../../../features/admin/queries.ts'
-const keys = ['turns']
+const sections: Section[] = [
+ {
+ key: 'turns',
+ title: 'Limits',
+ fields: ['ratePerMinute', 'maxSteps', 'timeoutSeconds', 'backfillMinutes'],
+ },
+ { key: 'turns', title: 'Instructions', fields: ['systemPrompt'] },
+]
export const Route = createFileRoute('/_app/admin/turns')({
loader: ({ context }) => context.queryClient.query(adminSettingsQuery),
- component: () => ,
+ component: () => (
+
+ ),
})
diff --git a/apps/web/src/shared/cn.ts b/apps/web/src/shared/cn.ts
index 7c475d8..67c74db 100644
--- a/apps/web/src/shared/cn.ts
+++ b/apps/web/src/shared/cn.ts
@@ -18,6 +18,7 @@ export const themeNames = {
'menu',
'bubble',
'editor',
+ 'secret',
'mark',
'composer',
'quote',
diff --git a/apps/web/src/shared/time.ts b/apps/web/src/shared/time.ts
index 0873f0e..e1055c8 100644
--- a/apps/web/src/shared/time.ts
+++ b/apps/web/src/shared/time.ts
@@ -33,3 +33,17 @@ export function parseDate(iso: unknown): Date | null {
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? null : date
}
+
+/** Relative times in the interface's language, which a translation changes along with the copy. */
+const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
+
+/** How long ago a time was, such as "just now", "5 minutes ago", or "1 hour ago". */
+export function timeAgo(then: Date, now = new Date()): string {
+ const seconds = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000))
+ if (seconds < 45) return 'just now'
+ const minutes = Math.round(seconds / 60)
+ if (minutes < 60) return relative.format(-minutes, 'minute')
+ const hours = Math.round(minutes / 60)
+ if (hours < 24) return relative.format(-hours, 'hour')
+ return then.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+}
diff --git a/apps/web/src/shared/ui/Badge.tsx b/apps/web/src/shared/ui/Badge.tsx
index 2a6c02d..1101ba6 100644
--- a/apps/web/src/shared/ui/Badge.tsx
+++ b/apps/web/src/shared/ui/Badge.tsx
@@ -5,6 +5,7 @@ interface BadgeProps {
/** Green for what is live or good, red for what failed, grey otherwise. */
tone?: 'good' | 'danger'
title?: string
+ className?: string
children: ReactNode
}
@@ -17,6 +18,7 @@ export function Badge(props: BadgeProps) {
'inline-flex items-center whitespace-nowrap rounded-badge bg-hover px-1.75 py-px font-semibold text-caption text-muted',
props.tone === 'good' && 'bg-accent-soft text-accent-strong',
props.tone === 'danger' && 'bg-danger-soft text-danger',
+ props.className,
)}
>
{props.children}
@@ -25,13 +27,17 @@ export function Badge(props: BadgeProps) {
}
interface ChipProps {
+ title?: string
children: ReactNode
}
/** A short value, such as a role or a domain, as a grey chip. */
export function Chip(props: ChipProps) {
return (
-
+
{props.children}
)
diff --git a/apps/web/src/shared/ui/ItemList.tsx b/apps/web/src/shared/ui/ItemList.tsx
index ea3f07a..438b569 100644
--- a/apps/web/src/shared/ui/ItemList.tsx
+++ b/apps/web/src/shared/ui/ItemList.tsx
@@ -9,11 +9,29 @@ import { ChevronRightIcon } from '../icons.tsx'
interface ListProps {
'aria-label'?: string
+ className?: string
children: ReactNode
}
export function ItemList(props: ListProps) {
- return
{props.children}
+ return (
+
+ {props.children}
+
+ )
+}
+
+interface LabelProps {
+ children: ReactNode
+}
+
+/** A quiet label over a list in a card, such as who is waiting to sign in. */
+export function ListLabel(props: LabelProps) {
+ return (
+
+ {props.children}
+
+ )
}
const row =
@@ -109,6 +127,10 @@ interface LinkItemProps {
details?: ReactNode[]
/** Buttons on the row, which still act on their own above the link. */
actions?: ReactNode
+ /** A small square before the names, such as the item's place in an order. */
+ mark?: ReactNode
+ /** More lines under the names, such as an error. */
+ children?: ReactNode
}
/**
@@ -123,12 +145,15 @@ export function LinkItem(props: LinkItemProps) {
'relative flex-nowrap has-[strong_a:focus-visible]:outline-2 has-[strong_a:focus-visible]:outline-focus has-[strong_a:focus-visible]:-outline-offset-2 hover:has-[strong_a:hover]:bg-row-hover [&_button]:relative [&_button]:z-1',
)}
>
+ {props.mark !== undefined && {props.mark}}
+ >
+ {props.children}
+
{props.actions}
diff --git a/apps/web/src/shared/ui/Secret.tsx b/apps/web/src/shared/ui/Secret.tsx
new file mode 100644
index 0000000..864676f
--- /dev/null
+++ b/apps/web/src/shared/ui/Secret.tsx
@@ -0,0 +1,18 @@
+import { CopyButton } from './CopyButton.tsx'
+
+interface Props {
+ /** The secret or command, which wraps so it reads whole, with Copy at its first line. */
+ text: string
+}
+
+/** A secret or command to copy: its mono text in a field, with its Copy button. */
+export function Secret(props: Props) {
+ return (
+
+
+ {props.text}
+
+
+
+ )
+}
diff --git a/apps/web/src/shared/ui/SettingsPage.tsx b/apps/web/src/shared/ui/SettingsPage.tsx
index 7a62fbd..075ef2e 100644
--- a/apps/web/src/shared/ui/SettingsPage.tsx
+++ b/apps/web/src/shared/ui/SettingsPage.tsx
@@ -60,8 +60,13 @@ interface GroupProps {
children: ReactNode
}
+const note = 'mx-card-pad mt-2.5 text-muted text-small'
+
const heading = 'ms-card-pad font-semibold text-muted text-small leading-tight'
+/** What a card is for, under its heading, in its words' own line height since it may wrap. */
+const groupDescription = cn(heading, '-mt-1 mb-2.5 font-normal leading-[1.45]')
+
/**
* A card of related settings under a quiet heading in line with the text of its rows. The card
* holds settings rather than being one to press, so it lies flat: a tint with no edge of its own,
@@ -77,11 +82,9 @@ export function SettingsGroup(props: GroupProps) {
{props.title}
)}
- {props.description && (
-
{props.description}
- )}
+ {props.description &&
{props.description}
}
{props.children}
- {props.note &&
{props.note}
}
+ {props.note &&
{props.note}
}
)
}
@@ -91,6 +94,15 @@ interface CardProps {
children: ReactNode
}
+/** What a card holds after something it continues, such as a search's results, with no line between. */
+export const undivided = 'before:hidden'
+
+/** A form of one field and its button across a card, such as a search or adding a person. */
+export const inlineForm = 'flex gap-2 p-card-pad [&>input]:min-w-0 [&>input]:flex-1'
+
+/** An error at the foot of a card, across it. */
+export const cardAlert = 'bg-danger-soft p-card-pad text-small'
+
/** Rows divided by lines inset to their text, for a card's rows or a set of rows within one. */
export const dividedRows =
'[&>*+*]:relative [&>*+*]:before:absolute [&>*+*]:before:inset-x-card-pad [&>*+*]:before:top-0 [&>*+*]:before:border-line [&>*+*]:before:border-t'
@@ -114,6 +126,8 @@ interface ChoicesProps {
/** What the choice is about, read as the group's name and shown as its heading. */
title: string
description?: ReactNode
+ /** A note under the card, such as who the choice doesn't apply to. */
+ note?: ReactNode
children: ReactNode
}
@@ -122,12 +136,11 @@ export function Choices(props: ChoicesProps) {
return (
)
}
diff --git a/apps/web/src/shared/ui/controls.tsx b/apps/web/src/shared/ui/controls.tsx
index 457a838..dbbf7f1 100644
--- a/apps/web/src/shared/ui/controls.tsx
+++ b/apps/web/src/shared/ui/controls.tsx
@@ -8,6 +8,12 @@ import { cn } from '../cn.ts'
export const field =
'rounded-small border border-line bg-raised px-2.75 py-1.5 text-ink transition-[border-color,box-shadow] duration-(--dur-feedback) placeholder:text-faint focus-visible:border-focus focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-accent-soft disabled:opacity-50'
+/**
+ * A field for code-like text, such as hosts one per line, in the mono font a step smaller, but
+ * never under the size that keeps phones from zooming in on it.
+ */
+export const monoField = 'font-mono text-[length:max(var(--input-floor),var(--text-small))]'
+
type InputProps = Omit, 'type'>
/**
diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css
index f948bda..164ab66 100644
--- a/apps/web/src/styles/theme.css
+++ b/apps/web/src/styles/theme.css
@@ -200,6 +200,8 @@
--radius-bubble: 1.5rem;
/* A card holding controls at an even inset, such as renaming a chat in the list. */
--radius-editor: calc(var(--radius-small) + 0.6rem + 1px);
+ /* A secret to copy, nesting its Copy button at an even inset. */
+ --radius-secret: calc(var(--radius-small) + 0.35rem + 1px);
/* A search match's tint. */
--radius-mark: 2px;
/* The composer, nesting its round Send button in its corner, and a quote held in it, nesting in
diff --git a/apps/web/tests/features/admin/admin.test.tsx b/apps/web/tests/features/admin/admin.test.tsx
index a267a7c..a5f75cb 100644
--- a/apps/web/tests/features/admin/admin.test.tsx
+++ b/apps/web/tests/features/admin/admin.test.tsx
@@ -17,6 +17,7 @@ import { RolesAdmin } from '../../../src/features/admin/pages/RolesAdmin.tsx'
import { SettingsAdmin } from '../../../src/features/admin/pages/SettingsAdmin.tsx'
import { UsersAdmin } from '../../../src/features/admin/pages/UsersAdmin.tsx'
import { renderAt } from '../../helpers/router.tsx'
+import { choose, optionsOf } from '../../helpers/select.ts'
afterEach(() => vi.unstubAllGlobals())
@@ -273,8 +274,8 @@ describe('Admin users', () => {
{ identifier: 'did:plc:bob' },
]),
)
- const row = screen.getByRole('row', { name: /did:plc:bob/ })
- await userEvent.click(within(row).getAllByRole('button', { name: 'Remove' })[0] as HTMLElement)
+ const row = screen.getByText('did:plc:bob').closest('li') as HTMLElement
+ await userEvent.click(within(row).getByRole('button', { name: 'Remove bob.test from member' }))
await waitFor(() =>
expect(callsTo(fetch, 'DELETE', '/api/admin/roles/member/members/did:plc:bob')).toHaveLength(
1,
@@ -348,6 +349,8 @@ describe('Admin roles', () => {
const fetch = stubServer()
const { router } = await renderPage(, '/admin/roles/member')
await userEvent.click(await screen.findByRole('button', { name: 'Delete role' }))
+ expect(screen.getByText('Delete this role?')).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Delete role' }))
await waitFor(() => expect(router.state.location.pathname).toBe('/admin/roles'))
expect(callsTo(fetch, 'DELETE', '/api/admin/roles/member')).toHaveLength(1)
})
@@ -378,7 +381,7 @@ describe('Admin added users and suspension', () => {
const fetch = stubServer()
await renderPage(, '/admin/users')
expect(await screen.findByText(/did:plc:dana/)).toBeInTheDocument()
- await userEvent.type(screen.getByLabelText('Add a user'), 'erin.test')
+ await userEvent.type(screen.getByRole('textbox', { name: 'Add a user' }), 'erin.test')
await userEvent.click(screen.getByRole('button', { name: 'Add user' }))
await waitFor(() =>
expect(callsTo(fetch, 'POST', '/api/admin/users')).toEqual([{ identifier: 'erin.test' }]),
@@ -393,7 +396,8 @@ describe('Admin added users and suspension', () => {
it('suspends an account with a reason', async () => {
const fetch = stubServer()
await renderPage(, '/admin/users')
- await userEvent.type(await screen.findByLabelText('Reason for suspending bob.test'), 'Spam')
+ await userEvent.click(await screen.findByRole('button', { name: 'Suspend' }))
+ await userEvent.type(screen.getByLabelText('Reason for suspending bob.test'), 'Spam')
await userEvent.click(screen.getByRole('button', { name: 'Suspend' }))
await waitFor(() =>
expect(callsTo(fetch, 'POST', '/api/admin/users/did:plc:bob/suspend')).toEqual([
@@ -433,7 +437,7 @@ describe('Admin plugin list', () => {
const fetch = stubServer()
await renderPage(, '/admin/plugins')
await screen.findByRole('link', { name: 'SCN' })
- await userEvent.click(screen.getAllByRole('button', { name: 'Down' })[0] as HTMLElement)
+ await userEvent.click(screen.getAllByRole('button', { name: 'Move down' })[0] as HTMLElement)
await waitFor(() =>
expect(callsTo(fetch, 'PUT', '/api/admin/plugins/order')).toEqual([{ ids: ['i2', 'i1'] }]),
)
@@ -510,7 +514,7 @@ describe('Admin plugin page', () => {
options: { id: 'scn', name: 'SCN' },
},
])
- await userEvent.click(await screen.findByRole('checkbox', { name: 'Gemma (gemma)' }))
+ await userEvent.click(await screen.findByRole('checkbox', { name: 'Gemma gemma' }))
await waitFor(() =>
expect(callsTo(fetch, 'POST', '/api/admin/models')).toEqual([
{
@@ -529,6 +533,7 @@ describe('Admin plugin page', () => {
const fetch = stubServer()
const { router } = await renderPage(, '/admin/plugins/i1')
await userEvent.click(await screen.findByRole('button', { name: 'Remove plugin' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Remove plugin' }))
await waitFor(() => expect(router.state.location.pathname).toBe('/admin/plugins'))
expect(callsTo(fetch, 'DELETE', '/api/admin/plugins/i1')).toHaveLength(1)
})
@@ -563,7 +568,7 @@ describe('Admin model page', () => {
it('saves a model', async () => {
const fetch = serveLlama()
await renderModel()
- await userEvent.click(await screen.findByRole('checkbox', { name: 'tools' }))
+ await userEvent.click(await screen.findByRole('checkbox', { name: 'Tools' }))
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(callsTo(fetch, 'PUT', '/api/admin/models')).toEqual([
@@ -576,6 +581,7 @@ describe('Admin model page', () => {
const fetch = serveLlama()
const { router } = await renderModel()
await userEvent.click(await screen.findByRole('button', { name: 'Remove model' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Remove model' }))
await waitFor(() => expect(router.state.location.pathname).toBe('/admin/plugins/i1'))
expect(callsTo(fetch, 'DELETE', '/api/admin/models')).toEqual([
{ provider: 'scn', id: 'meta/llama3:8b' },
@@ -587,15 +593,11 @@ describe('Admin adding a plugin', () => {
it('offers only packages that are not added yet, or can be added more than once', async () => {
stubServer()
await renderPage(, '/admin/plugins/new')
- const picker = await screen.findByLabelText('Plugin package')
- await waitFor(() =>
- expect(
- within(picker)
- .getAllByRole('option')
- .map((o) => o.getAttribute('value'))
- .filter(Boolean),
- ).toEqual(['@scn-chat/plugin-titles', '@scn-chat/plugin-openai-compatible']),
- )
+ const picker = await screen.findByRole('combobox', { name: 'Plugin package' })
+ expect(await optionsOf(picker)).toEqual([
+ '@scn-chat/plugin-titles',
+ '@scn-chat/plugin-openai-compatible',
+ ])
})
it('adds a plugin with its options and opens its page', async () => {
@@ -603,13 +605,12 @@ describe('Admin adding a plugin', () => {
'POST /api/admin/plugins': () => Response.json({ id: 'new1' }, { status: 201 }),
})
const { router } = await renderPage(, '/admin/plugins/new')
- await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
- await userEvent.selectOptions(
- screen.getByLabelText('Plugin package'),
+ await choose(
+ await screen.findByRole('combobox', { name: 'Plugin package' }),
'@scn-chat/plugin-titles',
)
await userEvent.type(screen.getByLabelText('Most words'), '6')
- await userEvent.click(screen.getByRole('button', { name: 'Add' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Add plugin' }))
await waitFor(() => expect(router.state.location.pathname).toBe('/admin/plugins/new1'))
expect(callsTo(fetch, 'POST', '/api/admin/plugins')).toEqual([
{ package: '@scn-chat/plugin-titles', options: { maxWords: 6 } },
@@ -621,7 +622,7 @@ describe('Admin models', () => {
it('marks a new default and reorders', async () => {
const fetch = stubServer()
await renderPage(, '/admin/models')
- await userEvent.click(await screen.findByRole('radio', { name: 'Llama (scn/llama)' }))
+ await userEvent.click(await screen.findByRole('radio', { name: 'Llama scn/llama' }))
await waitFor(() =>
expect(callsTo(fetch, 'PUT', '/api/admin/models')).toEqual([
{
@@ -634,7 +635,7 @@ describe('Admin models', () => {
},
]),
)
- await userEvent.click(screen.getAllByRole('button', { name: 'Up' })[1] as HTMLElement)
+ await userEvent.click(screen.getAllByRole('button', { name: 'Move up' })[1] as HTMLElement)
await waitFor(() =>
expect(callsTo(fetch, 'PUT', '/api/admin/models/order')).toEqual([
{
@@ -655,7 +656,7 @@ describe('Admin API keys', () => {
Response.json({ id: 'k2', key: 'scn_secret' }, { status: 201 }),
})
await renderPage(, '/admin/api-keys')
- await userEvent.type(await screen.findByLabelText('Key label'), 'crontab')
+ await userEvent.type(await screen.findByLabelText('Label'), 'crontab')
await userEvent.click(await screen.findByRole('checkbox', { name: 'admin' }))
await userEvent.click(screen.getByRole('button', { name: 'Issue key' }))
expect(await screen.findByText('scn_secret')).toBeInTheDocument()
@@ -663,6 +664,7 @@ describe('Admin API keys', () => {
{ label: 'crontab', roles: ['admin'] },
])
await userEvent.click(screen.getByRole('button', { name: 'Revoke' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Revoke' }))
await waitFor(() => expect(callsTo(fetch, 'DELETE', '/api/admin/api-keys/k1')).toHaveLength(1))
})
})
@@ -679,7 +681,11 @@ describe('Admin settings', () => {
it('saves the general settings, each group on its own', async () => {
const fetch = stubServer()
await renderPage(
- ,
+ ,
'/admin/general',
)
await userEvent.clear(await screen.findByLabelText('App name'))
@@ -712,12 +718,19 @@ describe('Admin settings', () => {
return Response.json({ ok: true })
},
})
- await renderPage(, '/admin/general')
+ await renderPage(
+ ,
+ '/admin/general',
+ )
await userEvent.clear(await screen.findByLabelText('App name'))
await userEvent.type(screen.getByLabelText('App name'), ' My Chat ')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(screen.getByLabelText('App name')).toHaveValue('My Chat'))
- expect(screen.getByText('Saved.')).toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent('Saved')
})
it("shows a failed group's issues next to its fields, and edits the system prompt as text", async () => {
@@ -732,11 +745,14 @@ describe('Admin settings', () => {
{ status: 400 },
),
})
- await renderPage(, '/admin/turns')
+ await renderPage(
+ ,
+ '/admin/turns',
+ )
const prompt = await screen.findByLabelText('System prompt')
expect(prompt.tagName).toBe('TEXTAREA')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Too long')).toBeInTheDocument()
- expect(screen.queryByText('Saved.')).toBeNull()
+ expect(screen.getByRole('status')).not.toHaveTextContent('Saved')
})
})
diff --git a/apps/web/tests/features/chat-list/lib/groups.test.ts b/apps/web/tests/features/chat-list/lib/groups.test.ts
index 60c6467..90cdad3 100644
--- a/apps/web/tests/features/chat-list/lib/groups.test.ts
+++ b/apps/web/tests/features/chat-list/lib/groups.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { dateLabel, groupByDate, timeAgo } from '../../../../src/features/chat-list/lib/groups.ts'
+import { dateLabel, groupByDate } from '../../../../src/features/chat-list/lib/groups.ts'
const now = new Date(2026, 9, 7, 15, 0)
const at = (month: number, day: number, hour = 12) => new Date(2026, month, day, hour)
@@ -30,11 +30,3 @@ describe('groupByDate', () => {
])
})
})
-
-describe('timeAgo', () => {
- it('says how long ago, briefly', () => {
- expect(timeAgo(new Date(now.getTime() - 10_000), now)).toBe('just now')
- expect(timeAgo(new Date(now.getTime() - 5 * 60_000), now)).toBe('5 minutes ago')
- expect(timeAgo(new Date(now.getTime() - 3 * 3_600_000), now)).toBe('3 hours ago')
- })
-})
diff --git a/apps/web/tests/shared/time.test.ts b/apps/web/tests/shared/time.test.ts
index 97eddb2..88bf833 100644
--- a/apps/web/tests/shared/time.test.ts
+++ b/apps/web/tests/shared/time.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { parseDate, shortTime } from '../../src/shared/time.ts'
+import { parseDate, shortTime, timeAgo } from '../../src/shared/time.ts'
const now = new Date(2026, 9, 7, 15, 0)
const clock = (date: Date) =>
@@ -22,3 +22,11 @@ describe('parseDate', () => {
expect(parseDate(undefined)).toBeNull()
})
})
+
+describe('timeAgo', () => {
+ it('says how long ago, briefly', () => {
+ expect(timeAgo(new Date(now.getTime() - 10_000), now)).toBe('just now')
+ expect(timeAgo(new Date(now.getTime() - 5 * 60_000), now)).toBe('5 minutes ago')
+ expect(timeAgo(new Date(now.getTime() - 3 * 3_600_000), now)).toBe('3 hours ago')
+ })
+})
diff --git a/docs/plugins.md b/docs/plugins.md
index 910b158..6f27b10 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -208,6 +208,7 @@ See [`plugins/pdf-text`](../plugins/pdf-text/src/index.ts) as an example.
If `userSettings` is configured users will see a settings form for the plugin on the settings page, generated from the defined zod schema.
- **Secrets:** Mark a field with `.meta({ secret: true })` to store it encrypted.
+- **Labels and units:** `.meta({ title, description })` names a field and says what it is for, and a number's `.meta({ unit })`, such as `'seconds'`, shows after its field. A string with `.meta({ multiline: true })` is a textarea.
- **Conditional fields:** Add a [JSON Forms rule](https://jsonforms.io/docs/uischema/rules) to a field with `.meta({ rule })` to show, hide, enable, or disable it based on another field's value. Conditions support `const`, `enum`, `not`, and `minLength`.
- **Reading settings:** `ctx.userSettings(user)` returns the user's settings.
- **Invalid values:** If stored values no longer match the schema an exception is raised.
diff --git a/specs/admin-plugins.md b/specs/admin-plugins.md
index dbe14b0..96d1728 100644
--- a/specs/admin-plugins.md
+++ b/specs/admin-plugins.md
@@ -118,9 +118,9 @@ admin_model (
Two sections join the admin sidebar:
- **Plugins** (`/admin/plugins`). The instances in load order, each with its name linking to its page, its package, its status and error, and up and down buttons, then an "Add plugin" link.
-- **A plugin's page** (`/admin/plugins/:id`). A link back to the list, the plugin's status and error, the options form generated from the schema with `SchemaFields`, an Enabled checkbox, Save, and "Remove plugin", which goes back to the list. Each plugin saves on its own, since each save rebuilds the runtime and can fail on its own. Secret fields show "set" when a value is stored, and a "Clear" checkbox.
+- **A plugin's page** (`/admin/plugins/:id`). A link back to the list, the plugin's status and error, the options form generated from the schema with `SchemaFields`, an Enabled checkbox, Save, and "Remove plugin", which asks to confirm and then goes back to the list. Each plugin saves on its own, since each save rebuilds the runtime and can fail on its own. Secret fields show "set" when a value is stored, and a "Clear" checkbox.
- **Adding a plugin** (`/admin/plugins/new`). A link back to the list, a picker of the installed packages that aren't added yet or allow more than one instance, the chosen package's description and form, and Add, which opens the new plugin's page.
-- **Models on a provider's page.** A provider plugin's page shows a table of its admin models below the options form, with each one's name, ID, capabilities, roles, and whether it's the default, plus the Refresh button and the list of models to tick. Each model links to its own page (`/admin/plugins//model?provider=…&id=…`), which edits its name, capabilities, and roles and removes it. Models save through the models routes, separately from the plugin's options.
+- **Models on a provider's page.** A provider plugin's page shows a list of its admin models below the options form, with each one's name, ID, capabilities, roles, and whether it's the default, and under it the models to tick, a field to type a model ID, and the Refresh button. Each model links to its own page (`/admin/plugins//model?provider=…&id=…`), which edits its name, capabilities, and roles and removes it once the admin confirms. Models save through the models routes, separately from the plugin's options.
- **Models** (`/admin/models`). The admin models from every provider in one list, for picking the default and setting the order across providers, with a link to each model's provider block.
Admin option forms support the same JSON Forms rules as user settings. Web search adds rules to its options, showing `apiKey` for engines that need a key and `baseURL` for SearXNG.
diff --git a/specs/admin-settings.md b/specs/admin-settings.md
index 04b7724..b069d64 100644
--- a/specs/admin-settings.md
+++ b/specs/admin-settings.md
@@ -34,7 +34,7 @@ The environment variables that used to override admin settings, `APP_NAME`, `SES
### Setting groups
-Each group is a key in the settings store, with a zod schema in `apps/server/src/settings/schemas.ts`. Fields carry `.meta({ title, description })` for the generated form, and the defaults match today's.
+Each group is a key in the settings store, with a zod schema in `apps/server/src/settings/schemas.ts`. Fields carry `.meta({ title, description })` for the generated form, and a number's `.meta({ unit })` names what it counts, such as `minutes`, shown after its field. The defaults match today's.
| Key | Fields |
|---|---|
@@ -80,7 +80,7 @@ After this spec, `.env` holds everything outside the database:
| `GET /api/admin/settings` | Every group, with its JSON schema and current value. |
| `PUT /api/admin/settings/:key` | Save one group. Failures return 400 with `{ error: 'InvalidRequest', issues }`. |
-Three sections join the admin sidebar: **General**, with the app name and session lifetime, **Turns**, and **Sync**. Each renders its groups with `SchemaFields` and has one Save. The system prompt is a textarea, which `SchemaFields` renders for string fields marked `.meta({ multiline: true })`.
+Three sections join the admin sidebar: **General**, with the app name and session lifetime, **Turns**, and **Sync**. Each renders its groups with `SchemaFields` and has one Save. Each route picks its cards: a whole group, some of a group's fields under a title of its own, such as Turns' Limits and Instructions, or one nested object, such as Sync's safety net, under its own title and description. The system prompt is a textarea, which `SchemaFields` renders for string fields marked `.meta({ multiline: true })`.
### Removing `config.yml`
diff --git a/specs/admin.md b/specs/admin.md
index 3d6f41f..fd44bcd 100644
--- a/specs/admin.md
+++ b/specs/admin.md
@@ -157,8 +157,8 @@ The web app gets an admin area at `/admin`, visible only to admins. It reuses th
Sections in this spec:
-- **Users** (`/admin/users`, the default). A form to add a person by handle or DID, the list of people added who haven't signed in yet, a search box, and a table of accounts with their handle, DID, storage mode, roles, last activity, and suspension, with viewers marked, and a "Load more" button. Each row can add the user to a role, remove them from one they hold explicitly, and suspend or restore them. Roles that come from a PDS host or handle domain are shown with their source but can't be removed there.
-- **Roles** (`/admin/roles`). A table of the roles with their description, member count, PDS hosts, and handle domains, each linking to the role's page, and a form that creates a role and opens its page. A role's page (`/admin/roles/`) edits its description and the PDS hosts and handle domains, one per line, lists its explicit members with who added them and a remove button, has an input to add a member by handle or DID, and deletes the role.
+- **Users** (`/admin/users`, the default). A form to add a person by handle or DID, the list of people added who haven't signed in yet, a search box, and a list of accounts with their handle, DID, storage mode, roles, last activity, and suspension, with viewers marked, and a "Load more" button. Each account can add the user to a role, remove them from one they hold explicitly, and suspend them, after asking for an optional reason, or restore them. Roles that come from a PDS host or handle domain are shown with their source but can't be removed there.
+- **Roles** (`/admin/roles`). A list of the roles with their description, member count, PDS hosts, and handle domains, each opening the role's page, and a form that creates a role and opens its page. A role's page (`/admin/roles/`) edits its description and the PDS hosts and handle domains, one per line, lists its explicit members with who added them and a remove button, has an input to add a member by handle or DID, and deletes the role once the admin confirms.
- **Access** (`/admin/access`). The registration mode, a checkbox per role for `inviteRoles`, shown in `invite` mode, and a Save button.
The UI stays as bare as the rest of the web app.
diff --git a/specs/web-ui.md b/specs/web-ui.md
index dfd6250..c67f03b 100644
--- a/specs/web-ui.md
+++ b/specs/web-ui.md
@@ -89,7 +89,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query
- API keys: a list of the user's keys, each with its name, provider, and last four characters, or a note when there are none. Deleting a key asks to confirm in its row first. Below it, a form to add a key, with its labels over its fields: provider, key, an optional name, and model IDs, with a button to load the provider's models where it lists them. For providers with user endpoints, base URL and slug fields.
- Plugins: one form with a card per plugin and a single Save, which saves every plugin's settings and any tool switches that changed. A card has a switch for each tool the user may switch, labeled "Enabled" when it is the plugin's only one and by tool name otherwise, with the tool's description, then the fields generated from the plugin's JSON schema. When no plugin has settings or switches for the user, the section says so rather than showing nothing.
- Sync: a note saying where the user's chats are stored, their PDS or this server, then the background sync switch, shown only when the admin allows users to opt out, and a button that deletes and rebuilds the browser store. The switch moves as soon as it is clicked.
-- **Admin.** The admin specs describe each section. `features/admin/pages/` mirrors `features/settings/pages/`: a layout for the sidebar, which groups the sections under People, Features, and App, each with an icon, links back to the chats and to the account settings, and marks the current section, a plugin's page counting as Plugins, and one file per section. A form that edits stored values starts from them, and starts over when they change after a save. A failed save shows its message, and the field issues the server returns show beside their fields.
+- **Admin.** The admin specs describe each section. `features/admin/pages/` mirrors `features/settings/pages/`: a layout for the sidebar, which groups the sections under People, Features, and App, each with an icon, links back to the chats and to the account settings, marks the current section, a plugin's page counting as Plugins, and one file per section. Sections are built like the settings ones, with tables and lists in cards, in the same column width as every other section. A page opened from another, such as a role's or a plugin's, has a link back above its title. Removing a role, a plugin, or a model, and revoking a key, asks to confirm in place first. Suspending a user first asks for a reason, seen only by admins. Status, such as a plugin's or whether an account is suspended, shows as a badge. A form that edits stored values starts from them, and starts over when they change after a save. A failed save shows its message, and the field issues the server returns show beside their fields.
### Schema forms