portal-mojo 0.2.21 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/admin-assistant.md +75 -23
- package/docs/admin-dns-domains.md +96 -90
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-dns-registrar.md +29 -4
- package/docs/admin-dns.md +2 -3
- package/docs/admin-modals.md +21 -0
- package/docs/menus-and-access.md +24 -0
- package/package.json +1 -1
- package/src/admin/assistant/AssistantFeed.tsx +7 -4
- package/src/admin/assistant/AssistantSetupPage.tsx +141 -64
- package/src/admin/assistant/api.ts +9 -7
- package/src/admin/assistant/pages.tsx +125 -109
- package/src/admin/assistant/setup.ts +1 -1
- package/src/admin/assistant/skill-lifecycle.ts +5 -4
- package/src/admin/core/index.ts +10 -2
- package/src/admin/dns/DnsRecordsPanel.tsx +45 -9
- package/src/admin/dns/DomainAssignment.tsx +55 -0
- package/src/admin/dns/DomainDetail.tsx +55 -38
- package/src/admin/dns/DomainOnboardingContent.tsx +9 -4
- package/src/admin/dns/DomainPurchaseDetail.tsx +42 -7
- package/src/admin/dns/DomainPurchaseWizard.tsx +102 -72
- package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +21 -6
- package/src/admin/dns/DomainRegistrarSettings.tsx +28 -48
- package/src/admin/dns/DomainRegistrationPanel.tsx +127 -0
- package/src/admin/dns/DomainsPage.tsx +25 -24
- package/src/admin/dns/ProviderCredentialsPage.tsx +32 -18
- package/src/admin/dns/RegistrantContactPage.tsx +73 -27
- package/src/admin/dns/api.ts +26 -25
- package/src/admin/dns/domain-authority.ts +11 -0
- package/src/admin/dns/index.ts +2 -2
- package/src/admin/dns/purchase-data.ts +5 -3
- package/src/admin/dns/registrar-api.ts +57 -11
- package/src/admin/domains/infrastructure.ts +8 -8
- package/src/admin/domains/operations.ts +6 -6
- package/src/admin/edge/VhostsPage.tsx +6 -1
- package/src/admin/jobs/JobDashboardPage.tsx +14 -4
- package/src/client/mock.ts +14 -8
- package/src/styles/components/admin-assistant.css +83 -20
- package/src/styles/components/admin-domains.css +67 -11
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-jobs.css +1 -0
- package/src/ui/SidebarNav.tsx +10 -1
- package/src/ui/menu-registry.ts +2 -0
|
@@ -1,132 +1,148 @@
|
|
|
1
|
-
import { useQuery } from '@tanstack/react-query';
|
|
2
|
-
import type
|
|
3
|
-
import {
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
|
3
|
+
import { getAuthSessionRevision, getAuthSnapshot, subscribeAuth } from '../../client/auth';
|
|
4
|
+
import { hasPermission, memberHasPermission, useAuthSnapshot, useMe, type Me, type MemberLike } from '../../client/me';
|
|
4
5
|
import type { FileReference } from '../../client/record-feed';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { mojoCall, type FetchOpts } from '../../client/client';
|
|
7
|
+
import { useRealtime, useRealtimeStatus } from '../../client/realtime';
|
|
8
|
+
import { useTableParams } from '../../client/params';
|
|
9
|
+
import { Badge } from '../../ui/ui';
|
|
10
|
+
import { CollectionSelect } from '../../ui/CollectionSelect';
|
|
11
|
+
import { Popover } from '../../ui/Popover';
|
|
12
|
+
import { Tabs } from '../../ui/Tabs';
|
|
13
|
+
import { datetime } from '../../ui/format';
|
|
7
14
|
import { AssistantFeed } from './AssistantFeed';
|
|
8
|
-
import { ASSISTANT_PERMISSIONS } from './
|
|
15
|
+
import { ASSISTANT_PERMISSIONS } from './permissions';
|
|
9
16
|
import { deleteAssistantMemory, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
|
|
10
17
|
import { setAssistantSkillActive } from './skill-lifecycle';
|
|
11
18
|
import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeTurn } from './streaming';
|
|
12
19
|
import type { AssistantConversation, AssistantConversationSummary, AssistantSkill } from './types';
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
type Scope = { me: Me; valid(permission?: string | string[]): boolean; options: Pick<FetchOpts, 'signal' | 'beforeSend'> };
|
|
22
|
+
const ScopeContext = createContext<Scope | null>(null);
|
|
23
|
+
function Access({ children, memory = false }: { children: ReactNode; memory?: boolean }) {
|
|
24
|
+
const { data: me } = useMe(), auth = useAuthSnapshot();
|
|
25
|
+
const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
|
|
26
|
+
const permission = memory ? 'sys.assistant' : ASSISTANT_PERMISSIONS;
|
|
27
|
+
if (!auth.authenticated || !me || String(me.id) !== auth.uid || !hasPermission(me, permission)) return <p className="panel panel-pad" role="alert">Assistant access required.</p>;
|
|
28
|
+
return <ScopeOwner key={`${auth.uid}:${revision}:${JSON.stringify([me.is_superuser, me.permissions])}`} me={me} revision={revision} permission={permission}>{children}</ScopeOwner>;
|
|
29
|
+
}
|
|
30
|
+
function ScopeOwner({ me, revision, permission, children }: { me: Me; revision: number; permission: string | string[]; children: ReactNode }) {
|
|
31
|
+
const qc = useQueryClient();
|
|
32
|
+
const live = useRef(true);
|
|
33
|
+
const valid = (required = permission) => { const auth = getAuthSnapshot(), current = qc.getQueryData<Me>(['me', auth.uid]); return live.current && revision === getAuthSessionRevision() && auth.authenticated && auth.uid === String(me.id) && !!current && current.id === me.id && hasPermission(current, required); };
|
|
34
|
+
useLayoutEffect(() => { live.current = true; return () => { live.current = false; }; }, []);
|
|
35
|
+
const options = { beforeSend: () => { if (!valid()) throw new DOMException('Assistant scope ended', 'AbortError'); } };
|
|
36
|
+
return <ScopeContext.Provider value={{ me, valid, options }}>{children}</ScopeContext.Provider>;
|
|
37
|
+
}
|
|
38
|
+
function useScope() { return useContext(ScopeContext)!; }
|
|
39
|
+
function Help({ label, children }: { label: string; children: ReactNode }) {
|
|
40
|
+
const ref = useRef<HTMLButtonElement>(null), id = useId(), [open, setOpen] = useState(false);
|
|
41
|
+
return <><button ref={ref} type="button" className="assistant-page-help" aria-label={`About ${label}`} aria-expanded={open} aria-controls={open ? id : undefined} onClick={() => setOpen(!open)}><i className="bi bi-info-circle" aria-hidden="true" /></button><Popover anchorRef={ref} open={open} onClose={() => setOpen(false)} id={id} aria-label={`About ${label}`} className="assistant-page-help-content">{children}</Popover></>;
|
|
42
|
+
}
|
|
43
|
+
function Dialog({ title, close, busy = false, children, wide = false, readOnly = false, footer }: { title: string; close(): void; busy?: boolean; children: ReactNode; wide?: boolean; readOnly?: boolean; footer?: ReactNode }) {
|
|
44
|
+
const ref = useRef<HTMLDialogElement>(null), id = useId();
|
|
45
|
+
useLayoutEffect(() => { const node = ref.current, opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; node?.showModal(); node?.querySelector<HTMLElement>('[data-dialog-autofocus]')?.focus(); return () => { if (node?.open) node.close(); if (opener?.isConnected) opener.focus(); }; }, []);
|
|
46
|
+
const dismiss = () => { if (!busy) close(); };
|
|
47
|
+
return <dialog ref={ref} className={`assistant-page-dialog${wide ? ' assistant-page-dialog-conversation' : ''}${readOnly ? ' assistant-page-dialog-history' : ''}`} aria-labelledby={id} onCancel={event => { event.preventDefault(); dismiss(); }} onMouseDown={event => { if (event.target === ref.current) dismiss(); }} onClose={() => { if (ref.current && !ref.current.open) { if (busy) ref.current.showModal(); else close(); } }}><header><h2 id={id}>{title}</h2><button className="btn-icon" type="button" disabled={busy} aria-label="Close dialog" onClick={dismiss}><i className="bi bi-x-lg" aria-hidden="true" /></button></header><div className="assistant-page-dialog-body">{children}</div>{footer && <footer>{footer}</footer>}</dialog>;
|
|
48
|
+
}
|
|
49
|
+
function State({ loading, error, retry }: { loading: boolean; error: string; retry(): void }) { return loading ? <p className="assistant-page-empty" role="status">Loading…</p> : error ? <div className="assistant-page-empty"><p className="form-alert" role="alert">{error}</p><button type="button" className="btn" onClick={retry}>Retry</button></div> : null; }
|
|
50
|
+
function Heading({ title, children, help }: { title: string; children?: ReactNode; help: ReactNode }) { return <header className="assistant-page-heading"><div><span className="eyebrow">Assistant</span><h1>{title} <Help label={title}>{help}</Help></h1></div><div className="assistant-page-actions">{children}</div></header>; }
|
|
51
|
+
function Refresh({ load, disabled }: { load(): void; disabled: boolean }) { return <button type="button" className="btn assistant-page-refresh" aria-label="Refresh" title="Refresh" disabled={disabled} onClick={load}><i className="bi bi-arrow-clockwise" aria-hidden="true" /><span>Refresh</span></button>; }
|
|
52
|
+
function Pager({ page, size, count, loading, change }: { page: number; size: number; count: number; loading: boolean; change(page: number): void }) { return count > size || page > 1 ? <nav className="assistant-page-pager" aria-label="Results pages"><span>{count ? `${(page - 1) * size + 1}–${Math.min(page * size, count)} of ${count}` : 'No results'}</span><button className="btn btn-compact" disabled={loading || page <= 1} onClick={() => change(page - 1)}>Previous</button><button className="btn btn-compact" disabled={loading || page * size >= count} onClick={() => change(page + 1)}>Next</button></nav> : null; }
|
|
53
|
+
const audience = (tier: string) => tier === 'user' ? 'Personal' : tier === 'group' ? 'Group' : 'Global';
|
|
54
|
+
const messageOf = (cause: unknown, fallback: string) => cause instanceof Error ? cause.message : fallback;
|
|
16
55
|
|
|
17
56
|
function ConversationInspect({ id, close }: { id: number; close(): void }) {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
useEffect(() => () => { stream.current?.cancel();
|
|
25
|
-
useEffect(() => { if (!canAssistant) { stream.current?.cancel(); stream.current = null; setResponding(false); close(); } }, [canAssistant, close]);
|
|
26
|
-
useEffect(() => { if (!canViewAdmin && stream.current) { stream.current.cancel(); stream.current = null; setResponding(false); void load(); } }, [canViewAdmin, load]);
|
|
27
|
-
if (loading || error || !data || !me) return <div className="modal-pad"><PageState loading={loading} error={error || (!data ? 'Conversation unavailable' : '')} retry={() => void load()} /><button type="button" className="btn" onClick={close}>Close</button></div>;
|
|
28
|
-
const owner = data.user.id === me.id;
|
|
57
|
+
const scope = useScope(), realtime = useRealtime(), realtimeStatus = useRealtimeStatus();
|
|
58
|
+
const [data, setData] = useState<AssistantConversation | null>(null), [loading, setLoading] = useState(true), [responding, setResponding] = useState(false), [error, setError] = useState('');
|
|
59
|
+
const stream = useRef<AssistantRealtimeTurn | null>(null), seen = useRef(new Set<number | string>()), live = useRef(true), flight = useRef(false), generation = useRef(0);
|
|
60
|
+
const valid = () => live.current && scope.valid();
|
|
61
|
+
const options = { ...scope.options, beforeSend: () => { if (!valid()) throw new DOMException('Conversation closed', 'AbortError'); } };
|
|
62
|
+
const load = async () => { const request = ++generation.current; setLoading(true); setError(''); try { const next = await getAssistantConversation(id, options); if (valid() && request === generation.current) { seen.current = new Set(next.messages.map(item => item.id)); setData(next); } } catch (cause) { if (valid() && request === generation.current) setError(messageOf(cause, 'Conversation unavailable')); } finally { if (valid() && request === generation.current) setLoading(false); } };
|
|
63
|
+
useEffect(() => { live.current = true; void load(); return () => { live.current = false; generation.current++; stream.current?.cancel(); }; }, []);
|
|
29
64
|
const send = async (message: string, attachments: readonly FileReference[] = []) => {
|
|
30
|
-
if (!
|
|
31
|
-
setResponding(true); const now = Math.floor(Date.now() / 1000);
|
|
65
|
+
if (!valid() || !data || data.user.id !== scope.me.id || flight.current) throw new Error('This conversation cannot be continued.');
|
|
66
|
+
flight.current = true; setResponding(true); const now = Math.floor(Date.now() / 1000);
|
|
32
67
|
const userMessage = { id: `inspect-user-${Date.now()}`, role: 'user' as const, content: message, created: now, blocks: attachments.length ? [{ type: 'attachment' as const, files: [...attachments] }] : [] };
|
|
33
|
-
|
|
68
|
+
const update = (apply: (current: AssistantConversation) => AssistantConversation) => { if (valid()) setData(current => current ? apply(current) : current); };
|
|
69
|
+
update(current => ({ ...current, messages: [...current.messages, userMessage] }));
|
|
34
70
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
onResponse: (next) => setData((current) => current ? { ...current, modified: now, messages: current.messages.some((item) => item.id === next.id) ? current.messages : [...current.messages, next] } : current),
|
|
42
|
-
onReconcile: setData,
|
|
43
|
-
onUnknown: () => { window.dispatchEvent(new CustomEvent('portal-mojo:assistant-list-refresh')); },
|
|
44
|
-
});
|
|
45
|
-
stream.current = turn; await turn.promise; stream.current = null;
|
|
71
|
+
if (chooseAssistantTransport({ owner: true, textOnly: attachments.length === 0, hasViewAdmin: scope.valid('sys.view_admin'), realtimeStatus: realtimeStatus.status }) === 'websocket') {
|
|
72
|
+
const turn = startAssistantRealtimeTurn(realtime, { message, conversationId: data.id, seenMessageIds: seen.current }, {
|
|
73
|
+
onConversation: () => {}, onText: next => update(current => ({ ...current, messages: [...current.messages, next] })), onProgress: () => {},
|
|
74
|
+
onResponse: next => update(current => ({ ...current, modified: now, messages: current.messages.some(item => item.id === next.id) ? current.messages : [...current.messages, next] })),
|
|
75
|
+
onReconcile: next => { if (valid()) setData(next); }, onUnknown: () => { if (valid()) window.dispatchEvent(new CustomEvent('portal-mojo:assistant-list-refresh')); },
|
|
76
|
+
}); stream.current = turn; await turn.promise;
|
|
46
77
|
} else {
|
|
47
|
-
const reply = await sendAssistantMessage(message, data.id, attachments);
|
|
48
|
-
|
|
78
|
+
const reply = await sendAssistantMessage(message, data.id, attachments, options);
|
|
79
|
+
update(current => ({ ...current, modified: now, messages: [...current.messages, { id: `inspect-assistant-${Date.now()}`, role: 'assistant', content: reply.response, created: now, blocks: reply.blocks }] }));
|
|
49
80
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
} finally { stream.current = null; setResponding(false); }
|
|
81
|
+
if (valid()) window.dispatchEvent(new CustomEvent('portal-mojo:assistant-list-refresh'));
|
|
82
|
+
} catch (cause) { if (!(cause instanceof AssistantOutcomeUnknownError)) update(current => ({ ...current, messages: current.messages.filter(item => item.id !== userMessage.id) })); throw cause; }
|
|
83
|
+
finally { stream.current = null; flight.current = false; if (valid()) setResponding(false); }
|
|
54
84
|
};
|
|
55
|
-
|
|
85
|
+
const owner = data?.user.id === scope.me.id;
|
|
86
|
+
return <Dialog title={data?.title || 'Conversation'} wide readOnly={!!data && !owner} busy={responding} close={close}><State loading={loading} error={error} retry={() => void load()} />{!loading && !error && data && <><p className="assistant-page-meta">{data.user.display_name} · {datetime(data.modified)} <Help label="Conversation details"><p>Conversation #{data.id}. Started {datetime(data.created)}.</p><p>Only its owner can send messages. Closing does not delete history.</p></Help></p>{!owner && <p className="assistant-page-readonly">Viewing history. Only {data.user.display_name} can continue this conversation.</p>}<AssistantFeed messages={data.messages} user={data.user} viewerId={scope.me.id} onSend={send} responding={responding} disabled={!owner} readOnly={!owner} attachmentKey={data.id} /></>}</Dialog>;
|
|
56
87
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return <
|
|
88
|
+
export function ConversationsPage() { return <Access><Conversations /></Access>; }
|
|
89
|
+
function Conversations() {
|
|
90
|
+
const scope = useScope(), params = useTableParams({ size: 10, sort: '-modified' });
|
|
91
|
+
const [rows, setRows] = useState<AssistantConversationSummary[]>([]), [count, setCount] = useState(0), [loading, setLoading] = useState(true), [error, setError] = useState(''), [selected, setSelected] = useState<number | null>(null);
|
|
92
|
+
const generation = useRef(0), live = useRef(true);
|
|
93
|
+
const load = useCallback(async () => { const request = ++generation.current; setLoading(true); setError(''); try { const next = await listAssistantConversations({ start: (params.page - 1) * params.size, size: params.size, sort: '-modified', ...(params.search ? { title__icontains: params.search } : {}) }); if (live.current && scope.valid() && request === generation.current) { setRows(next.rows); setCount(next.count); } } catch (cause) { if (live.current && scope.valid() && request === generation.current) setError(messageOf(cause, 'Conversations unavailable')); } finally { if (live.current && scope.valid() && request === generation.current) setLoading(false); } }, [params.page, params.size, params.search]);
|
|
94
|
+
useEffect(() => { live.current = true; void load(); const refresh = () => void load(); window.addEventListener('portal-mojo:assistant-list-refresh', refresh); return () => { live.current = false; generation.current++; window.removeEventListener('portal-mojo:assistant-list-refresh', refresh); }; }, [load]);
|
|
95
|
+
return <main className="assistant-admin-page"><Heading title="Conversations" help={<p>Open a conversation to read its history or continue your own. Start a new conversation using the Assistant button in the app header.</p>}><Refresh load={() => void load()} disabled={loading} /></Heading><label className="assistant-page-search"><i className="bi bi-search" aria-hidden="true" /><input className="input" type="search" aria-label="Search conversation titles" placeholder="Search conversations…" value={params.search} onChange={event => params.setSearch(event.target.value)} /></label><section className="panel assistant-page-list"><State loading={loading} error={error} retry={() => void load()} />{!loading && !error && <>{rows.length ? <table><thead><tr><th>Conversation</th><th>Owner</th><th>Updated</th></tr></thead><tbody>{rows.map(row => <tr key={row.id}><td><button className="link-button" onClick={() => setSelected(row.id)}>{row.title || 'Untitled conversation'}</button></td><td>{row.user.display_name}{row.user.id === scope.me.id && <Badge tone="info">You</Badge>}</td><td>{datetime(row.modified)}</td></tr>)}</tbody></table> : <p className="assistant-page-empty">{params.search ? 'No matching conversations.' : 'No conversations yet. Open Assistant in the app header to start one.'}</p>}<Pager {...params} count={count} loading={loading} change={params.setPage} /></>}</section>{selected != null && <ConversationInspect key={selected} id={selected} close={() => setSelected(null)} />}</main>;
|
|
65
96
|
}
|
|
66
97
|
|
|
67
|
-
function SkillInspect({ id, close }: { id: number; close(): void }) {
|
|
68
|
-
const [skill, setSkill] = useState<AssistantSkill | null>(null);
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
sections={[{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section">{error && <div className="form-alert">{error}</div>}{skill ? <><FlatRow label="Tier">{skill.tier}</FlatRow><FlatRow label="Active">{skill.is_active ? 'Yes' : 'No'}</FlatRow><FlatRow label="Auto execute">{skill.auto_execute ? 'Yes' : 'No'}</FlatRow><h3>Triggers</h3><ul>{skill.triggers.map(trigger => <li key={trigger}>{trigger}</li>)}</ul><h3>Steps</h3><ol>{skill.steps.map((step, index) => <li key={index}><code>{JSON.stringify(step)}</code></li>)}</ol></> : <p>Loading…</p>}</div> }]} />;
|
|
98
|
+
function SkillInspect({ id, close, changed }: { id: number; close(): void; changed(): void }) {
|
|
99
|
+
const scope = useScope(), [skill, setSkill] = useState<AssistantSkill | null>(null), [error, setError] = useState(''), [busy, setBusy] = useState(false), [loading, setLoading] = useState(true);
|
|
100
|
+
const live = useRef(true), flight = useRef(false);
|
|
101
|
+
const valid = () => live.current && scope.valid();
|
|
102
|
+
const options = { ...scope.options, beforeSend: () => { if (!valid()) throw new DOMException('Skill closed', 'AbortError'); } };
|
|
103
|
+
const load = async () => { setLoading(true); setError(''); try { const next = await getAssistantSkill(id, options); if (valid()) setSkill(next); } catch (cause) { if (valid()) setError(messageOf(cause, 'Skill unavailable')); } finally { if (valid()) setLoading(false); } };
|
|
104
|
+
useEffect(() => { live.current = true; void load(); return () => { live.current = false; }; }, []);
|
|
105
|
+
const toggle = async () => { if (!skill || !valid() || !scope.valid('sys.view_admin') || flight.current) return; flight.current = true; setBusy(true); setError(''); try { const next = await setAssistantSkillActive(id, !skill.is_active, { ...options, beforeSend: () => { options.beforeSend(); if (!scope.valid('sys.view_admin')) throw new DOMException('Skill authority ended', 'AbortError'); } }); if (valid()) { setSkill(next); changed(); } } catch (cause) { if (valid()) setError(messageOf(cause, 'Skill change failed. Refresh to check its status.')); } finally { flight.current = false; if (valid()) setBusy(false); } };
|
|
106
|
+
return <Dialog title={skill?.name ?? 'Skill'} close={close} busy={busy} footer={<>{scope.valid('sys.view_admin') && skill && <button className="btn" disabled={busy || loading} onClick={() => void toggle()}>{busy ? 'Saving…' : skill.is_active ? 'Deactivate skill' : 'Reactivate skill'}</button>}<button className="btn" disabled={busy} onClick={close}>Done</button></>}><State loading={loading} error={!skill ? error : ''} retry={() => void load()} />{skill && <div className="assistant-skill-content">{error && <p className="form-alert" role="alert">{error} <button className="link-button" disabled={busy} onClick={() => void load()}>Refresh status</button></p>}<p className="assistant-skill-description">{skill.description}</p><div className="assistant-page-chips"><Badge tone={skill.is_active ? 'success' : 'muted'}>{skill.is_active ? 'Active' : 'Inactive'}</Badge><Badge tone="muted">{audience(skill.tier)}</Badge><Help label="Skill details"><p>Skill #{skill.id}. Updated {datetime(skill.modified)}.</p><p>Skills are learned in Assistant conversations. Deactivation preserves their history.</p></Help></div>{skill.auto_execute && <p className="assistant-page-warning">Read-only steps can run automatically. Changes still require approval.</p>}<section><h3>When to use it</h3>{skill.triggers.length ? <ul className="assistant-skill-triggers">{skill.triggers.map(trigger => <li key={trigger}>{trigger}</li>)}</ul> : <p className="dim">No trigger phrases saved.</p>}</section><section><h3>Steps</h3>{skill.steps.length ? <ol className="assistant-skill-steps">{skill.steps.map((step, index) => <li key={index}><span>{step.description || step.tool}</span><Help label={`Step ${index + 1}`}><p>Tool: <code>{step.tool}</code></p>{step.condition && <p>Condition: {step.condition}</p>}</Help>{step.condition && <small>When: {step.condition}</small>}</li>)}</ol> : <p className="dim">No steps saved.</p>}</section></div>}</Dialog>;
|
|
77
107
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
export function SkillsPage() { return <Access><Skills /></Access>; }
|
|
109
|
+
function Skills() {
|
|
110
|
+
const scope = useScope(), params = useTableParams({ size: 10, sort: 'name' });
|
|
111
|
+
const [rows, setRows] = useState<AssistantSkill[]>([]), [count, setCount] = useState(0), [loading, setLoading] = useState(true), [error, setError] = useState(''), [selected, setSelected] = useState<number | null>(null);
|
|
112
|
+
const generation = useRef(0), live = useRef(true);
|
|
113
|
+
const load = useCallback(async () => { const request = ++generation.current; setLoading(true); setError(''); try { const next = await listAssistantSkills({ start: (params.page - 1) * params.size, size: params.size, sort: 'name', ...(params.search ? { name__icontains: params.search } : {}) }); if (live.current && scope.valid() && request === generation.current) { setRows(next.rows); setCount(next.count); } } catch (cause) { if (live.current && scope.valid() && request === generation.current) setError(messageOf(cause, 'Skills unavailable')); } finally { if (live.current && scope.valid() && request === generation.current) setLoading(false); } }, [params.page, params.size, params.search]);
|
|
114
|
+
useEffect(() => { live.current = true; void load(); return () => { live.current = false; generation.current++; }; }, [load]);
|
|
115
|
+
return <main className="assistant-admin-page"><Heading title="Skills" help={<p>Reusable procedures learned through Assistant conversations. Open a skill to see its trigger phrases and steps, or deactivate it while keeping its history.</p>}><Refresh load={() => void load()} disabled={loading} /></Heading><label className="assistant-page-search"><i className="bi bi-search" aria-hidden="true" /><input className="input" type="search" aria-label="Search skill names" placeholder="Search skills…" value={params.search} onChange={event => params.setSearch(event.target.value)} /></label><section className="panel assistant-page-list"><State loading={loading} error={error} retry={() => void load()} />{!loading && !error && <>{rows.length ? <table><thead><tr><th>Skill</th><th>Audience</th><th>Status</th></tr></thead><tbody>{rows.map(row => <tr key={row.id}><td><button className="link-button" onClick={() => setSelected(row.id)}>{row.name}</button><small>{row.description}</small></td><td>{audience(row.tier)}</td><td><Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge></td></tr>)}</tbody></table> : <p className="assistant-page-empty">{params.search ? 'No matching skills.' : 'No skills yet. Ask Assistant to remember a reusable procedure.'}</p>}<Pager {...params} count={count} loading={loading} change={params.setPage} /></>}</section>{selected != null && <SkillInspect key={selected} id={selected} close={() => setSelected(null)} changed={() => void load()} />}</main>;
|
|
84
116
|
}
|
|
85
117
|
|
|
86
|
-
interface GroupChoice { id: number; name: string }
|
|
87
118
|
type MemoryTier = 'global' | 'user' | 'group';
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const [
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
useEffect(() => { void load(); }, [
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
finally { lock.current = false; if (scope.current.mounted) setBusy(false); }
|
|
118
|
-
};
|
|
119
|
-
const save = async () => { if (!validKey || !validValue) return; await mutate(() => saveAssistantMemory(tier, key, value, tier === 'group' ? group! : undefined), scopeKey); };
|
|
120
|
-
const remove = async (entryKey: string) => {
|
|
121
|
-
const frozen = { tier, group: tier === 'group' ? group! : undefined, key: scopeKey };
|
|
122
|
-
if (!canManage) return;
|
|
123
|
-
const confirmed = await modal.confirm({ title: 'Remove memory key?', message: `Remove “${entryKey}” from ${frozen.tier} memory${frozen.group == null ? '' : ` for group #${frozen.group} (${groups.find(item => item.id === frozen.group)?.name ?? 'Unknown'})`}? The previous value cannot be restored by this action.`, confirmText: 'Remove key', danger: true });
|
|
124
|
-
if (confirmed) await mutate(() => deleteAssistantMemory(frozen.tier, entryKey, frozen.group), frozen.key);
|
|
125
|
-
};
|
|
126
|
-
return <div><Heading eyebrow="Assistant" title="Memory" description="Manage global, personal, or explicitly selected group memory without ambient group state." />
|
|
127
|
-
<div className="panel panel-pad assistant-memory-controls"><div className="seg">{(['global', 'user', 'group'] as MemoryTier[]).map(item => <button key={item} disabled={busy} className={`seg-btn${tier === item ? ' seg-active' : ''}`} onClick={() => setTier(item)}>{item}</button>)}</div>
|
|
128
|
-
{tier === 'group' && <label className="field"><span className="field-label">Group (bounded first 50)</span><select className="input" value={group ?? ''} disabled={busy} onChange={event => setGroup(event.target.value ? Number(event.target.value) : null)}><option value="">Choose a group</option>{groups.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>}
|
|
129
|
-
{!canManage && <p className="dim">Memory editing requires system Assistant permission and, for a selected group, inherited member Assistant authority or superuser access.</p>}
|
|
130
|
-
<div className="assistant-memory-form"><label className="field"><span className="field-label">Key</span><input className="input" disabled={!canManage || busy} maxLength={64} value={key} onChange={event => setKey(event.target.value)} placeholder="lowercase:key" /></label><label className="field"><span className="field-label">Value</span><textarea className="input" disabled={!canManage || busy} maxLength={500} rows={2} value={value} onChange={event => setValue(event.target.value)} /></label><button className="btn btn-primary" disabled={!canManage || busy || !validKey || !validValue || loading} onClick={() => void save()}>Save</button></div>{error && <div className="form-alert" role="alert">{error}</div>}</div>
|
|
131
|
-
<div className="panel assistant-list">{loading ? <p className="empty">Loading…</p> : Object.entries(entries).length ? <table><thead><tr><th>Key</th><th>Value</th><th /></tr></thead><tbody>{Object.entries(entries).slice(0, 100).map(([entryKey, entryValue]) => <tr key={entryKey}><td><code>{entryKey}</code></td><td>{String(entryValue).slice(0, 500)}</td><td>{canManage && <button className="btn-icon" disabled={busy} aria-label={`Remove ${entryKey}`} onClick={() => void remove(entryKey)}><i className="bi bi-trash" /></button>}</td></tr>)}</tbody></table> : <p className="empty">No {tier} memory.</p>}</div></div>;
|
|
119
|
+
interface GroupChoice { id: number; name: string }
|
|
120
|
+
const projectGroup = (row: GroupChoice): GroupChoice => ({ id: Number(row.id), name: String(row.name) });
|
|
121
|
+
export function MemoriesPage() { return <Access memory><Memories /></Access>; }
|
|
122
|
+
function Memories() {
|
|
123
|
+
const [tier, setTier] = useState<MemoryTier>('user'), [group, setGroup] = useState<GroupChoice | null>(null);
|
|
124
|
+
return <main className="assistant-admin-page"><Heading title="Memory" help={<p>Saved facts and preferences used by the AI Assistant. Personal memory belongs to you; Global memory is shared across the installation; Group memory applies only to the selected group.</p>} /><Tabs activeKey={tier} onActiveKeyChange={next => setTier(next as MemoryTier)} ariaLabel="Memory audience" items={(['user','global','group'] as MemoryTier[]).map(item => ({ key: item, label: audience(item), panel: <div className="assistant-memory-area">{item === 'group' && <div className="assistant-memory-group"><CollectionSelect<GroupChoice> endpoint="/api/group" emptyFetch={false} sanitizeRow={projectGroup} label="Group" value={group as (GroupChoice & Record<string, unknown>) | null} onChange={(id, row) => setGroup(id != null && row ? projectGroup(row) : null)} placeholder="Find a group…" /></div>}<MemoryScope key={`${item}:${group?.id ?? ''}`} tier={item} group={item === 'group' ? group : null} /></div> }))} /></main>;
|
|
125
|
+
}
|
|
126
|
+
function MemoryScope({ tier, group }: { tier: MemoryTier; group: GroupChoice | null }) {
|
|
127
|
+
const scope = useScope(), qc = useQueryClient(), keyId = useId(), valueId = useId();
|
|
128
|
+
const memberKey = ['assistant-memory-member', scope.me.id, getAuthSessionRevision(), group?.id] as const;
|
|
129
|
+
const member = useQuery({ queryKey: memberKey, enabled: tier === 'group' && group != null && scope.me.is_superuser !== true, queryFn: async () => (await mojoCall(`/api/group/${group!.id}/member`, scope.options)).data as MemberLike, retry: false });
|
|
130
|
+
const canRead = () => scope.valid('sys.assistant') && (tier !== 'group' || !!group && (scope.me.is_superuser === true || qc.getQueryState(memberKey)?.status === 'success' && qc.getQueryData<MemberLike>(memberKey) != null));
|
|
131
|
+
const canManage = () => canRead() && (tier !== 'group' || scope.me.is_superuser === true || memberHasPermission(qc.getQueryData<MemberLike>(memberKey), 'assistant'));
|
|
132
|
+
const [entries, setEntries] = useState<Record<string, unknown>>({}), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('');
|
|
133
|
+
const [editor, setEditor] = useState<{ key: string; value: string; existing: boolean } | null>(null), [remove, setRemove] = useState<string | null>(null);
|
|
134
|
+
const live = useRef(true), flight = useRef(false), generation = useRef(0);
|
|
135
|
+
const valid = () => live.current && canRead();
|
|
136
|
+
const options = { ...scope.options, beforeSend: () => { if (!valid()) throw new DOMException('Memory scope ended', 'AbortError'); } };
|
|
137
|
+
const writeOptions = { ...options, beforeSend: () => { options.beforeSend(); if (!canManage()) throw new DOMException('Memory editing authority ended', 'AbortError'); } };
|
|
138
|
+
const label = tier === 'group' ? `${group?.name ?? 'Selected group'} memory` : `${audience(tier)} memory`;
|
|
139
|
+
const permitted = canRead(), writable = canManage();
|
|
140
|
+
const load = async () => { const request = ++generation.current; setLoading(true); setError(''); try { const next = await getAssistantMemory(tier, group?.id, options); if (valid() && request === generation.current) setEntries(next); } catch (cause) { if (valid() && request === generation.current) setError(messageOf(cause, 'Memory unavailable')); } finally { if (live.current && request === generation.current) setLoading(false); } };
|
|
141
|
+
useEffect(() => { live.current = true; if (!writable) { setEditor(null); setRemove(null); } if (permitted) void load(); else { setEntries({}); setLoading(false); setEditor(null); setRemove(null); } return () => { live.current = false; generation.current++; }; }, [permitted, writable]);
|
|
142
|
+
const change = async (operation: () => Promise<void>, success: string) => { if (!valid() || !canManage() || flight.current) return; flight.current = true; setBusy(true); setError(''); setNotice(''); try { await operation(); if (valid()) { setEditor(null); setRemove(null); setNotice(success); await load(); } } catch (cause) { if (valid()) setError(messageOf(cause, 'Memory change failed. Refresh before retrying.')); } finally { flight.current = false; if (live.current) setBusy(false); } };
|
|
143
|
+
if (tier === 'group' && !group) return <p className="panel assistant-page-empty">Choose a group to view its memory.</p>;
|
|
144
|
+
if (tier === 'group' && member.isLoading && scope.me.is_superuser !== true) return <p className="panel assistant-page-empty" role="status">Checking group access…</p>;
|
|
145
|
+
if (!permitted) return <div className="panel assistant-page-empty"><p role="alert">{member.isError ? 'Group access could not be checked.' : 'You need Assistant access in this group to manage its memory.'}</p>{member.isError && <button className="btn" onClick={() => void member.refetch()}>Retry access check</button>}</div>;
|
|
146
|
+
const pairs = Object.entries(entries), validKey = !!editor && /^[a-z0-9][a-z0-9:_-]{0,63}$/.test(editor.key), validValue = !!editor && editor.value.trim().length > 0 && editor.value.length <= 500;
|
|
147
|
+
return <><div className="assistant-memory-toolbar"><span>{label} <Help label="Memory audience"><p>{tier === 'user' ? 'Only your Assistant conversations use these values.' : tier === 'global' ? 'These values affect Assistant conversations across this installation. Changes are shared.' : `These values apply to ${group?.name}. Every request explicitly selects this group.`}</p></Help></span><div className="assistant-page-actions"><Refresh load={() => void load()} disabled={busy || loading} />{writable && <button className="btn btn-primary" disabled={busy || loading || !!error} onClick={() => { setError(''); setEditor({ key: '', value: '', existing: false }); }}>Add memory</button>}</div></div>{!writable && <p className="assistant-page-readonly">You can view this group’s memory. Editing requires Assistant access in the group.</p>}{notice && <p className="assistant-page-notice" role="status">{notice}</p>}<section className="panel assistant-page-list"><State loading={loading} error={!editor && !remove ? error : ''} retry={() => void load()} />{!loading && !error && (pairs.length ? <ul className="assistant-memory-list">{pairs.map(([key, value]) => <li key={key}><div><strong>{key}</strong><p>{String(value)}</p></div>{writable && <div className="assistant-page-actions"><button className="btn-icon" disabled={busy} aria-label={`Edit ${key}`} onClick={() => { setError(''); setEditor({ key, value: String(value), existing: true }); }}><i className="bi bi-pencil" aria-hidden="true" /></button><button className="btn-icon" disabled={busy} aria-label={`Remove ${key}`} onClick={() => { setError(''); setRemove(key); }}><i className="bi bi-trash" aria-hidden="true" /></button></div>}</li>)}</ul> : <div className="assistant-page-empty"><p>No {audience(tier).toLowerCase()} memory yet.</p>{writable && <p className="dim">Add a fact or preference for Assistant to remember.</p>}</div>)}</section>{editor && writable && <Dialog title={editor.existing ? 'Edit memory' : 'Add memory'} busy={busy} close={() => { setEditor(null); setError(''); }} footer={<><button className="btn" disabled={busy} onClick={() => { setEditor(null); setError(''); }}>Cancel</button><button className="btn btn-primary" disabled={busy || !validKey || !validValue || !editor.existing && Object.hasOwn(entries, editor.key)} onClick={() => void change(() => saveAssistantMemory(tier, editor.key, editor.value, group?.id, writeOptions), 'Memory saved.')}>{busy ? 'Saving…' : 'Save memory'}</button></>}><p className="assistant-page-meta">{label}</p>{tier !== 'user' && <p className="assistant-page-warning">This change is shared with {tier === 'global' ? 'the entire installation' : group?.name}.</p>}{error && <p className="form-alert" role="alert">{error}</p>}<div className="field"><div className="field-label"><label htmlFor={keyId}>Key</label> <Help label="Memory key"><p>Start with a lowercase letter or number. Use up to 64 lowercase letters, numbers, colons, underscores or hyphens. The key identifies this memory; it cannot change while editing.</p></Help></div><input id={keyId} data-dialog-autofocus={editor.existing ? undefined : true} className="input" value={editor.key} maxLength={64} disabled={busy || editor.existing} onChange={event => setEditor({ ...editor, key: event.target.value })} placeholder="preferred_format" /></div>{!editor.existing && Object.hasOwn(entries, editor.key) && <p className="field-error">This key already exists. Edit the existing memory instead.</p>}{editor.key && !validKey && <p className="field-error">Start with a lowercase letter or number; use only lowercase letters, numbers, colons, underscores or hyphens.</p>}<label className="field" htmlFor={valueId}><span className="field-label">Value</span><textarea id={valueId} data-dialog-autofocus={editor.existing ? true : undefined} className="input" rows={4} maxLength={500} disabled={busy} value={editor.value} onChange={event => setEditor({ ...editor, value: event.target.value })} placeholder="Use concise answers with concrete examples." /></label><small className="dim">{editor.value.length}/500</small></Dialog>}{remove && writable && <Dialog title="Remove memory?" busy={busy} close={() => { setRemove(null); setError(''); }} footer={<><button className="btn" data-dialog-autofocus disabled={busy} onClick={() => { setRemove(null); setError(''); }}>Cancel</button><button className="btn btn-danger" disabled={busy} onClick={() => void change(() => deleteAssistantMemory(tier, remove, group?.id, writeOptions), 'Memory removed.')}>{busy ? 'Removing…' : 'Remove memory'}</button></>}><p>Remove <strong>{remove}</strong> from {label}?</p><p className="dim">The saved value cannot be restored.{tier !== 'user' ? ' This affects everyone using this audience.' : ''}</p>{error && <p className="form-alert" role="alert">{error}</p>}</Dialog>}</>;
|
|
132
148
|
}
|
|
@@ -114,7 +114,7 @@ export function assistantSetupReadiness(state: AssistantSetupState): string {
|
|
|
114
114
|
if (!state.realtime_installed) return 'The realtime application is not installed. Install it before using the Assistant.';
|
|
115
115
|
if (state.emergency_stop) return 'An emergency stop is blocking Assistant requests.';
|
|
116
116
|
if (state.model.capability?.mode === 'auto' && state.route.provider && state.route.credential && !state.model.effective) return 'Automatic model resolution is unavailable. Refresh the provider catalogue and review its reported freshness. Request admission does not prove that a usable model is ready.';
|
|
117
|
-
if (!state.route.provider || !state.route.model || !state.route.credential) return 'The deployment’s model and key selection are unavailable. Review the deployed policy and its selected credential in
|
|
117
|
+
if (!state.route.provider || !state.route.model || !state.route.credential) return 'The deployment’s model and key selection are unavailable. Review the deployed policy and its selected credential in Safety & usage; adding an Assistant key alone may not resolve this.';
|
|
118
118
|
if (!state.route.ready) return 'The deployment configuration needs attention. Review its selected key and policy.';
|
|
119
119
|
if (!state.enabled) return 'The deployment configuration is ready. Enable the Assistant when you are ready.';
|
|
120
120
|
return 'Assistant configuration is ready. Provider availability has not been checked.';
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { FetchOpts } from '../../client/client';
|
|
2
|
+
import { mojoCall } from '../../client/client';
|
|
2
3
|
import { getAssistantSkill } from './api';
|
|
3
4
|
import type { AssistantSkill } from './types';
|
|
4
5
|
|
|
5
6
|
/** Lifecycle writes use existing REST save; conversation history is never removed. */
|
|
6
|
-
export async function setAssistantSkillActive(id: number, isActive: boolean): Promise<AssistantSkill> {
|
|
7
|
-
await mojoCall(`/api/assistant/skill/${id}`, { method: 'POST', body: { is_active: isActive } });
|
|
8
|
-
return getAssistantSkill(id);
|
|
7
|
+
export async function setAssistantSkillActive(id: number, isActive: boolean, options: Pick<FetchOpts, 'signal' | 'beforeSend'> = {}): Promise<AssistantSkill> {
|
|
8
|
+
await mojoCall(`/api/assistant/skill/${id}`, { ...options, method: 'POST', body: { is_active: isActive } });
|
|
9
|
+
return getAssistantSkill(id, options);
|
|
9
10
|
}
|
package/src/admin/core/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export type AdminNavigationGroup =
|
|
|
18
18
|
| 'observability'
|
|
19
19
|
| 'operations'
|
|
20
20
|
| 'infrastructure'
|
|
21
|
+
| 'hosting'
|
|
22
|
+
| 'storage'
|
|
23
|
+
| 'configuration'
|
|
21
24
|
| 'communications'
|
|
22
25
|
| 'assistant'
|
|
23
26
|
| 'other';
|
|
@@ -31,6 +34,9 @@ export const ADMIN_NAVIGATION_GROUPS: Readonly<Record<AdminNavigationGroup, {
|
|
|
31
34
|
observability: { id: 'admin:observability', label: 'Observability', icon: 'bi-activity', order: 30 },
|
|
32
35
|
operations: { id: 'admin:operations', label: 'Operations', icon: 'bi-gear', order: 40 },
|
|
33
36
|
infrastructure: { id: 'admin:infrastructure', label: 'Infrastructure', icon: 'bi-cloud', order: 50 },
|
|
37
|
+
hosting: { id: 'admin:hosting', label: 'Domains & Hosting', icon: 'bi-globe2', order: 51 },
|
|
38
|
+
storage: { id: 'admin:storage', label: 'Storage', icon: 'bi-hdd-stack', order: 55 },
|
|
39
|
+
configuration: { id: 'admin:configuration', label: 'Configuration', icon: 'bi-sliders', order: 65 },
|
|
34
40
|
communications: { id: 'admin:communications', label: 'Communications', icon: 'bi-chat-dots', order: 60 },
|
|
35
41
|
assistant: { id: 'admin:assistant', label: 'Assistant', icon: 'bi-stars', order: 70 },
|
|
36
42
|
other: { id: 'admin:other', label: 'Other', icon: 'bi-grid', order: 80 },
|
|
@@ -40,6 +46,8 @@ type AdminRouteBase = {
|
|
|
40
46
|
path: string;
|
|
41
47
|
permissions?: PermSpec;
|
|
42
48
|
label?: string;
|
|
49
|
+
/** Secondary destination: available through search, context links and direct URLs. */
|
|
50
|
+
searchOnly?: boolean;
|
|
43
51
|
fallbackToFirstVisible?: boolean;
|
|
44
52
|
/** Literal installation owner; permission and group grants cannot satisfy it. */
|
|
45
53
|
requireSuperuser?: boolean;
|
|
@@ -219,7 +227,7 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
|
|
|
219
227
|
const labeled = section.routes.filter((route) => route.label);
|
|
220
228
|
const destinations: MenuItem[] = labeled.length > 0 ? labeled.map((route) => ({
|
|
221
229
|
id: `admin:${section.id}:${route.path || 'index'}`, label: route.label!, keywords: [section.title],
|
|
222
|
-
route: absolutePath(base, route.path), exact: true, requireSuperuser: route.requireSuperuser, permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
|
|
230
|
+
route: absolutePath(base, route.path), exact: true, searchOnly: route.searchOnly, requireSuperuser: route.requireSuperuser, permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
|
|
223
231
|
})) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), exact: true, permissionClauses: [section.permissions] }];
|
|
224
232
|
if (section.navigationGroup === null) {
|
|
225
233
|
items.push(...destinations.map((destination) => ({ ...destination, icon: section.icon })));
|
|
@@ -240,7 +248,7 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
|
|
|
240
248
|
items.push({
|
|
241
249
|
label: section.title, icon: section.icon, permissions: section.permissions,
|
|
242
250
|
...(base ? { route: absolutePath(base), exact: true } : {}),
|
|
243
|
-
children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), exact: true, permissions: route.permissions, requireSuperuser: route.requireSuperuser })),
|
|
251
|
+
children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), exact: true, searchOnly: route.searchOnly, permissions: route.permissions, requireSuperuser: route.requireSuperuser })),
|
|
244
252
|
});
|
|
245
253
|
}
|
|
246
254
|
return { name: opts.name ?? 'admin', scope: 'admin', presentation: opts.presentation ?? 'static', items };
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
3
|
import { modal, toast } from '../../ui';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { OperationsHelp } from '../core/OperationsHelp';
|
|
5
|
+
import { getAuthSessionRevision, subscribeAuth, useAuthSnapshot, useCan } from '../../client/runtime';
|
|
6
|
+
import { deleteDnsRecordSet, fetchDnsCapabilities, useDnsCapabilities, useDnsRecordCoordinator, useDnsRecords } from './api';
|
|
7
|
+
import { domainSession, requireDomainManager } from './domain-authority';
|
|
5
8
|
import { DNS_RECORD_TYPES, diffRecordSet, isSpentAcmeChallenge, recordKey, recordWarnings, relativeRecordName, snapshotRecordOwner } from './dns-data';
|
|
6
9
|
import { DnsRecordEditor } from './DnsRecordEditor';
|
|
7
|
-
import { DNS_MANAGE_PERMISSIONS, type DnsRecordRow, type DomainRow } from './models';
|
|
10
|
+
import { DNS_MANAGE_PERMISSIONS, DnsCredentialModel, DomainModel, type DnsRecordRow, type DomainRow } from './models';
|
|
8
11
|
|
|
9
|
-
export interface DnsRecordsPanelProps { domain: DomainRow; recordType?: string; onRecordTypeChange?: (type: string) => void }
|
|
12
|
+
export interface DnsRecordsPanelProps { domain: DomainRow; recordType?: string; onRecordTypeChange?: (type: string) => void; onBusyChange?: (busy: boolean) => void }
|
|
10
13
|
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
11
14
|
const groupId = (domain: DomainRow): number | null => typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null;
|
|
12
15
|
|
|
13
|
-
export function DnsRecordsPanel({ domain, recordType
|
|
16
|
+
export function DnsRecordsPanel({ domain, recordType: controlledType, onRecordTypeChange, onBusyChange }: DnsRecordsPanelProps) {
|
|
17
|
+
const qc = useQueryClient();
|
|
18
|
+
const [localType, setLocalType] = useState('');
|
|
19
|
+
const recordType = controlledType ?? localType;
|
|
20
|
+
const changeType = (value: string) => { setLocalType(value); onRecordTypeChange?.(value); };
|
|
14
21
|
const active = domain.status === 'active';
|
|
15
22
|
const generalProvider = ['route53', 'godaddy'].includes(domain.provider.toLowerCase());
|
|
16
23
|
const credential = typeof domain.credential === 'object' ? domain.credential : null;
|
|
@@ -19,17 +26,46 @@ export function DnsRecordsPanel({ domain, recordType = '', onRecordTypeChange }:
|
|
|
19
26
|
const capabilities = useDnsCapabilities(groupId(domain), { enabled: eligible });
|
|
20
27
|
const records = useDnsRecords(domain.id, eligible && !capabilities.isError);
|
|
21
28
|
const { can: canManage } = useCan(DNS_MANAGE_PERMISSIONS);
|
|
29
|
+
const auth = useAuthSnapshot();
|
|
30
|
+
const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
|
|
31
|
+
const identity = JSON.stringify([domain.id, domain.name, domain.provider, groupId(domain), credential?.id, auth.uid, revision, canManage]);
|
|
32
|
+
const latest = useRef(identity); latest.current = identity;
|
|
33
|
+
const lifetime = useRef<AbortController | null>(null); const launching = useRef(false);
|
|
34
|
+
const [repairing, setRepairing] = useState(false);
|
|
35
|
+
const pending = useRef(onBusyChange); pending.current = onBusyChange;
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const owner = new AbortController(); lifetime.current = owner; launching.current = false; setRepairing(false);
|
|
38
|
+
return () => { owner.abort(); pending.current?.(false); };
|
|
39
|
+
}, [identity]);
|
|
22
40
|
const coordinate = useDnsRecordCoordinator(domain.id);
|
|
23
41
|
const providerKnown = capabilities.data?.providers.some((provider) => provider.name.toLowerCase() === domain.provider.toLowerCase()) ?? false;
|
|
24
42
|
const rows = records.data?.records ?? [];
|
|
25
43
|
const knownObserved = [...new Set(rows.map((record) => record.type.toUpperCase()))];
|
|
26
44
|
const choices = [...DNS_RECORD_TYPES.filter((type) => capabilities.data?.allowed_record_types.includes(type) || knownObserved.includes(type)), ...knownObserved.filter((type) => !DNS_RECORD_TYPES.includes(type as typeof DNS_RECORD_TYPES[number]))];
|
|
27
45
|
const visible = recordType ? rows.filter((record) => record.type.toUpperCase() === recordType.toUpperCase()) : rows;
|
|
46
|
+
const repair = async () => {
|
|
47
|
+
if (launching.current || !credential || !canManage || !lifetime.current) return;
|
|
48
|
+
const owner = lifetime.current, session = domainSession();
|
|
49
|
+
const current = () => !owner.signal.aborted && latest.current === identity && domainSession() === session;
|
|
50
|
+
launching.current = true; setRepairing(true);
|
|
51
|
+
try {
|
|
52
|
+
await requireDomainManager(session); if (!current()) return;
|
|
53
|
+
await DnsCredentialModel.invalidate(qc); if (!current()) return;
|
|
54
|
+
const [row, caps] = await Promise.all([DnsCredentialModel.fetchOne(qc, credential.id), fetchDnsCapabilities(groupId(domain))]);
|
|
55
|
+
if (!current()) return;
|
|
56
|
+
const group = typeof row.group === 'number' ? row.group : row.group?.id;
|
|
57
|
+
if (group !== groupId(domain) || row.id !== credential.id || row.provider !== 'godaddy') throw new Error('Credential ownership changed. Reopen this domain.');
|
|
58
|
+
const { showCredentialEditor } = await import('./ProviderCredentialsPage'); if (!current()) return;
|
|
59
|
+
await showCredentialEditor(caps, row, undefined, { signal: owner.signal, onBusyChange: value => { if (current()) pending.current?.(value); } });
|
|
60
|
+
if (current()) await DomainModel.invalidate(qc);
|
|
61
|
+
} catch (error) { if (current()) toast.error(errorMessage(error)); }
|
|
62
|
+
finally { if (current()) { launching.current = false; setRepairing(false); pending.current?.(false); } }
|
|
63
|
+
};
|
|
28
64
|
|
|
29
65
|
if (!active) return <Blocked title="DNS changes are unavailable">This domain is {domain.status}. Live records are available only after activation completes.</Blocked>;
|
|
30
66
|
if (domain.provider === 'mojo') return <Blocked title="Certificate-only domain">Mojo domains mark delegated certificate coverage and do not support general DNS record management.</Blocked>;
|
|
31
67
|
if (!generalProvider) return <Blocked title="Unsupported DNS provider">The {domain.provider || 'unknown'} provider does not expose general DNS through this administration surface.</Blocked>;
|
|
32
|
-
if (!credentialUsable) return <Blocked title="Provider credential unavailable">This GoDaddy domain needs an active, verified provider credential. {credential?.is_active === false ? 'The linked credential is inactive.' : credential?.verified === false ? 'The linked credential is unverified.' : 'No usable credential is linked.'}</Blocked>;
|
|
68
|
+
if (!credentialUsable) return <Blocked title="Provider credential unavailable">This GoDaddy domain needs an active, verified provider credential. {credential?.is_active === false ? 'The linked credential is inactive.' : credential?.verified === false ? 'The linked credential is unverified.' : 'No usable credential is linked.'}{canManage && credential && <button className="btn" type="button" disabled={repairing} onClick={() => void repair()}>{repairing ? 'Opening credential…' : 'Repair linked credential'}</button>}</Blocked>;
|
|
33
69
|
if (capabilities.isLoading) return <div className="dns-state dim">Loading DNS capabilities…</div>;
|
|
34
70
|
if (capabilities.isError) return <Retry error={capabilities.error} retry={() => void capabilities.refetch()} />;
|
|
35
71
|
if (!providerKnown) return <Blocked title="Provider unavailable">This deployment did not advertise {domain.provider} in its DNS capabilities.</Blocked>;
|
|
@@ -55,8 +91,8 @@ export function DnsRecordsPanel({ domain, recordType = '', onRecordTypeChange }:
|
|
|
55
91
|
};
|
|
56
92
|
|
|
57
93
|
return <div className="dns-records-panel">
|
|
58
|
-
<div className="dns-record-toolbar"><label className="field dns-record-filter"><span className="field-label">Record type</span><select className="input" value={recordType} onChange={(event) =>
|
|
59
|
-
{domain.provider === 'godaddy' && <
|
|
94
|
+
<div className="dns-record-toolbar"><label className="field dns-record-filter"><span className="field-label">Record type</span><select className="input" value={recordType} onChange={(event) => changeType(event.target.value)}><option value="">All record types</option>{choices.map((type) => <option key={type} value={type}>{type}</option>)}</select></label><div className="dns-toolbar-actions"><button type="button" className="btn" onClick={() => void records.refetch()}><i className={`bi ${records.isFetching ? 'bi-arrow-repeat spin' : 'bi-arrow-clockwise'}`} /> Refresh</button>{canManage && <button type="button" className="btn btn-primary" onClick={() => openEditor()}><i className="bi bi-plus-lg" /> Add record set</button>}</div></div>
|
|
95
|
+
{domain.provider === 'godaddy' && <p className="dim domains-task-help">GoDaddy DNS <OperationsHelp label="GoDaddy DNS limits">Registration, renewal, privacy, and WHOIS remain in your GoDaddy account. TTLs have a 600-second minimum. Deleting whole record sets is unavailable.</OperationsHelp></p>}
|
|
60
96
|
{visible.length === 0 ? <div className="dns-state dim">{rows.length ? 'No records match this type.' : 'The provider returned an empty zone.'}</div> : <div className="dns-table-wrap"><table className="tbl dns-record-table"><thead><tr><th>Type</th><th>Name</th><th>Values</th><th>TTL</th><th><span className="sr-only">Actions</span></th></tr></thead><tbody>{visible.map((record) => {
|
|
61
97
|
const type = record.type.toUpperCase();
|
|
62
98
|
const supported = !!capabilities.data?.allowed_record_types.map((entry) => entry.toUpperCase()).includes(type) && DNS_RECORD_TYPES.includes(type as typeof DNS_RECORD_TYPES[number]);
|