portal-mojo 0.2.2 → 0.2.3

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.
Files changed (70) hide show
  1. package/docs/DetailView.md +19 -15
  2. package/docs/README.md +1 -0
  3. package/docs/admin-assistant.md +3 -1
  4. package/docs/admin-credentials.md +7 -0
  5. package/docs/admin-dashboard.md +1 -1
  6. package/docs/admin-dns.md +8 -2
  7. package/docs/admin-incidents.md +26 -3
  8. package/docs/admin-jobs.md +7 -4
  9. package/docs/admin-messaging.md +6 -5
  10. package/docs/admin-modals.md +122 -0
  11. package/docs/admin-network-security.md +4 -4
  12. package/docs/admin-phonehub.md +13 -5
  13. package/docs/admin-push.md +7 -0
  14. package/docs/admin-rules.md +8 -0
  15. package/docs/admin-shortlinks.md +2 -1
  16. package/docs/admin-storage.md +2 -1
  17. package/docs/auth.md +28 -0
  18. package/docs/client.md +12 -0
  19. package/docs/feedback.md +10 -0
  20. package/docs/forms.md +10 -0
  21. package/docs/popover.md +15 -0
  22. package/package.json +1 -1
  23. package/src/admin/assistant/api.ts +1 -1
  24. package/src/admin/assistant/pages.tsx +71 -17
  25. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  26. package/src/admin/core/index.ts +7 -2
  27. package/src/admin/credentials/group-api-keys.tsx +47 -43
  28. package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
  29. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  30. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  31. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  32. package/src/admin/dns/setup.ts +1 -1
  33. package/src/admin/domains/observability.ts +1 -1
  34. package/src/admin/identity/users/UserDetail.tsx +36 -20
  35. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  36. package/src/admin/identity/users/sections/actions.tsx +20 -9
  37. package/src/admin/incidents/EventDetail.tsx +147 -5
  38. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  39. package/src/admin/incidents/models.ts +1 -1
  40. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  41. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  42. package/src/admin/jobs/index.ts +1 -1
  43. package/src/admin/jobs/purge-preview.ts +25 -0
  44. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  45. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  46. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  47. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  48. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  49. package/src/admin/messaging/push/PushPage.tsx +20 -8
  50. package/src/admin/monitoring/LogInspector.tsx +3 -19
  51. package/src/admin/network/IPSetDetail.tsx +32 -63
  52. package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
  53. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  54. package/src/admin/phonehub/api.ts +6 -0
  55. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  56. package/src/admin/rules/condition-removal.ts +7 -0
  57. package/src/admin/security/geoip/models.ts +5 -0
  58. package/src/admin/security/tickets.tsx +40 -17
  59. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  60. package/src/admin/storage/BucketDetail.tsx +28 -9
  61. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  62. package/src/admin/storage/FileView.tsx +28 -16
  63. package/src/admin/storage/FilesPage.tsx +4 -5
  64. package/src/client/auth.ts +10 -1
  65. package/src/client/client.ts +14 -6
  66. package/src/client/mock.ts +89 -5
  67. package/src/client/upload.ts +1 -0
  68. package/src/ui/DetailView.tsx +22 -5
  69. package/src/ui/Popover.tsx +13 -12
  70. package/src/ui/modal.tsx +8 -4
@@ -1,12 +1,15 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import type { MemberLike } from '../../client/me';
1
3
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
3
- import { Badge, fmt, modal, toast } from '../../ui';
4
- import { AssistantFeed } from './AssistantFeed';
5
- import { deleteAssistantConversation, deleteAssistantMemory, deleteAssistantSkill, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
6
- import type { AssistantConversation, AssistantConversationSummary, AssistantSkill } from './types';
7
4
  import type { FileReference } from '../../client/record-feed';
5
+ import { memberHasPermission, mojoCall, mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
6
+ import { Badge, DetailView, FlatRow, fmt, modal } from '../../ui';
7
+ import { AssistantFeed } from './AssistantFeed';
8
8
  import { ASSISTANT_PERMISSIONS } from './AssistantPanel';
9
+ import { deleteAssistantMemory, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
10
+ import { setAssistantSkillActive } from './skill-lifecycle';
9
11
  import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeTurn } from './streaming';
12
+ import type { AssistantConversation, AssistantConversationSummary, AssistantSkill } from './types';
10
13
 
11
14
  function PageState({ loading, error, retry }: { loading: boolean; error: string; retry(): void }) { if (loading) return <div className="panel panel-pad dim">Loading…</div>; if (error) return <div className="panel panel-pad"><div className="form-alert" role="alert">{error}</div><button type="button" className="btn" onClick={retry}>Retry</button></div>; return null; }
12
15
  function Heading({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) { return <header className="admin-page-heading"><div><div className="eyebrow">{eyebrow}</div><h1>{title}</h1><p>{description}</p></div></header>; }
@@ -49,30 +52,81 @@ function ConversationInspect({ id, close }: { id: number; close(): void }) {
49
52
  throw cause;
50
53
  } finally { stream.current = null; setResponding(false); }
51
54
  };
52
- return <div className="modal-pad assistant-inspect"><header><div><div className="eyebrow">Conversation #{data.id}</div><h2>{data.title}</h2><p className="dim">Owner: {data.user.display_name} · updated {fmt.datetime(data.modified)}</p></div><button className="btn-icon" type="button" onClick={close} aria-label="Close"><i className="bi bi-x-lg" /></button></header><AssistantFeed messages={data.messages} user={{ id: me.id, display_name: me.display_name ?? 'You' }} onSend={send} responding={responding} disabled={!owner} attachmentKey={data.id} /></div>;
55
+ return <DetailView title={data.title} subtitle={`Conversation #${data.id} · ${data.user.display_name} · updated ${fmt.datetime(data.modified)}`} icon="bi-chat-dots" onClose={close} sections={[{ key: 'conversation', label: 'Conversation', icon: 'bi-chat-text', render: () => <AssistantFeed messages={data.messages} user={{ id: me.id, display_name: me.display_name ?? 'You' }} onSend={send} responding={responding} disabled={!owner} attachmentKey={data.id} /> }]} />;
53
56
  }
54
57
 
55
58
  export function ConversationsPage() {
56
- const { data: me } = useMe(); const { can: isAdmin } = useCan('sys.view_admin'); const [rows, setRows] = useState<AssistantConversationSummary[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
59
+ const { data: me } = useMe(); const [rows, setRows] = useState<AssistantConversationSummary[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
57
60
  const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantConversations()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Conversations unavailable'); } finally { setLoading(false); } }, []);
58
61
  useEffect(() => { void load(); }, [load]);
59
62
  useEffect(() => { const refresh = () => { void load(); }; window.addEventListener('portal-mojo:assistant-list-refresh', refresh); return () => window.removeEventListener('portal-mojo:assistant-list-refresh', refresh); }, [load]);
60
- const remove = async (row: AssistantConversationSummary) => { const confirmed = await modal.confirm({ title: 'Delete conversation?', message: `Delete “${row.title}”? This cannot be undone.`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantConversation(row.id); setRows((current) => current.filter((item) => item.id !== row.id)); toast.success('Conversation deleted'); } catch (cause) { toast.error(cause instanceof Error ? cause.message : 'Delete failed'); } };
61
- return <div><Heading eyebrow="Assistant" title="Conversations" description="Inspect Assistant history. Only the owner may continue a conversation." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Conversation</th><th>Owner</th><th>Updated</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" type="button" onClick={() => void modal.detail((close) => <ConversationInspect id={row.id} close={() => close(null)} />)}>{row.title}</button><small>#{row.id}</small></td><td>{row.user.display_name}{row.user.id === me?.id && <Badge tone="info">You</Badge>}</td><td>{fmt.datetime(row.modified)}</td><td><button className="btn-icon" type="button" aria-label={`Delete ${row.title}`} disabled={!isAdmin && row.user.id !== me?.id} onClick={() => void remove(row)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No conversations.</p>}</div>}</div>;
63
+
64
+ return <div><Heading eyebrow="Assistant" title="Conversations" description="Inspect Assistant history. Only the owner may continue a conversation." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Conversation</th><th>Owner</th><th>Updated</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" type="button" onClick={() => void modal.detail((close) => <ConversationInspect id={row.id} close={() => close(null)} />)}>{row.title}</button><small>#{row.id}</small></td><td>{row.user.display_name}{row.user.id === me?.id && <Badge tone="info">You</Badge>}</td><td>{fmt.datetime(row.modified)}</td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No conversations.</p>}</div>}</div>;
62
65
  }
63
66
 
64
- function SkillInspect({ id, close }: { id: number; close(): void }) { const [skill, setSkill] = useState<AssistantSkill | null>(null); const [error, setError] = useState(''); useEffect(() => { void getAssistantSkill(id).then(setSkill, (cause) => setError(cause instanceof Error ? cause.message : 'Skill unavailable')); }, [id]); return <div className="modal-pad assistant-skill-detail"><header><h2>{skill?.name ?? 'Skill'}</h2><button className="btn-icon" onClick={close} aria-label="Close"><i className="bi bi-x-lg" /></button></header>{error ? <div className="form-alert">{error}</div> : !skill ? <p className="dim">Loading…</p> : <><p>{skill.description || 'No description.'}</p><dl><div><dt>Tier</dt><dd>{skill.tier}</dd></div><div><dt>Active</dt><dd>{skill.is_active ? 'Yes' : 'No'}</dd></div><div><dt>Auto execute</dt><dd>{skill.auto_execute ? 'Yes' : 'No'}</dd></div></dl><h3>Triggers</h3>{skill.triggers.length ? <ul>{skill.triggers.map((trigger) => <li key={trigger}>{trigger}</li>)}</ul> : <p className="dim">No triggers.</p>}<h3>Steps</h3>{skill.steps.length ? <ol>{skill.steps.map((step, index) => <li key={index}><code>{JSON.stringify(step)}</code></li>)}</ol> : <p className="dim">No steps.</p>}</>}</div>; }
67
+ function SkillInspect({ id, close }: { id: number; close(): void }) {
68
+ const [skill, setSkill] = useState<AssistantSkill | null>(null);
69
+ const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
70
+ const canManage = useCan('sys.view_admin').can;
71
+ useEffect(() => { void getAssistantSkill(id).then(setSkill, cause => setError(cause instanceof Error ? cause.message : 'Skill unavailable')); }, [id]);
72
+ const toggle = async (next: boolean) => { if (!canManage || busy) return; setBusy(true); setError(''); try { setSkill(await setAssistantSkillActive(id, next)); } catch (cause) { setError(cause instanceof Error ? cause.message : 'State change failed'); } finally { setBusy(false); } };
73
+ return <DetailView title={skill?.name ?? 'Skill'} subtitle={skill?.description || `Skill #${id}`} icon="bi-stars" onClose={close}
74
+ active={canManage && skill ? { value: skill.is_active, disabled: busy, onChange: next => void toggle(next) } : undefined}
75
+ contextMenu={canManage && skill ? [{ label: skill.is_active ? 'Deactivate' : 'Reactivate', disabled: busy, onSelect: () => void toggle(!skill.is_active) }] : []}
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> }]} />;
77
+ }
65
78
 
66
- export function SkillsPage() { const [rows, setRows] = useState<AssistantSkill[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantSkills()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Skills unavailable'); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); const remove = async (row: AssistantSkill) => { const confirmed = await modal.confirm({ title: 'Delete skill?', message: `Delete “${row.name}”?`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantSkill(row.id); setRows((current) => current.filter((item) => item.id !== row.id)); toast.success('Skill deleted'); } catch (cause) { toast.error(cause instanceof Error ? cause.message : 'Delete failed'); } }; return <div><Heading eyebrow="Assistant" title="Skills" description="Inspect the bounded skill catalog exposed by django-mojo." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Skill</th><th>Tier</th><th>Status</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" onClick={() => void modal.detail((close) => <SkillInspect id={row.id} close={() => close(null)} />)}>{row.name}</button><small>{row.description}</small></td><td>{row.tier}</td><td><Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge></td><td><button className="btn-icon" aria-label={`Delete ${row.name}`} onClick={() => void remove(row)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No skills.</p>}</div>}</div>; }
79
+ export function SkillsPage() {
80
+ const [rows, setRows] = useState<AssistantSkill[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
81
+ const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantSkills()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Skills unavailable'); } finally { setLoading(false); } }, []);
82
+ useEffect(() => { void load(); }, [load]);
83
+ return <div><Heading eyebrow="Assistant" title="Skills" description="Inspect and manage the skill catalog without removing its history." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Skill</th><th>Tier</th><th>Status</th></tr></thead><tbody>{rows.map(row => <tr key={row.id}><td><button className="link-button" onClick={() => void modal.detail(close => <SkillInspect id={row.id} close={() => { close(null); void load(); }} />)}>{row.name}</button><small>{row.description}</small></td><td>{row.tier}</td><td><Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No skills.</p>}</div>}</div>;
84
+ }
67
85
 
68
86
  interface GroupChoice { id: number; name: string }
69
87
  type MemoryTier = 'global' | 'user' | 'group';
70
88
  export function MemoriesPage() {
71
- const [tier, setTier] = useState<MemoryTier>('user'); const [groups, setGroups] = useState<GroupChoice[]>([]); const [group, setGroup] = useState<number | null>(null); const [entries, setEntries] = useState<Record<string, unknown>>({}); const [key, setKey] = useState(''); const [value, setValue] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState('');
72
- useEffect(() => { void mojoList<GroupChoice>('/api/group', { start: 0, size: 50, sort: 'name' }).then((page) => { setGroups(page.rows.map((row) => ({ id: Number(row.id), name: String(row.name) }))); setGroup((current) => current ?? page.rows[0]?.id ?? null); }, () => setGroups([])); }, []);
73
- const load = useCallback(async () => { if (tier === 'group' && group == null) { setEntries({}); return; } setLoading(true); setError(''); try { setEntries(await getAssistantMemory(tier, tier === 'group' ? group! : undefined)); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Memory unavailable'); } finally { setLoading(false); } }, [tier, group]); useEffect(() => { void load(); }, [load]);
89
+ const { can: systemAllowed, me } = useCan('sys.assistant');
90
+ const [tier, setTier] = useState<MemoryTier>('user');
91
+ const [groups, setGroups] = useState<GroupChoice[]>([]); const [group, setGroup] = useState<number | null>(null);
92
+ const [entries, setEntries] = useState<Record<string, unknown>>({}); const [key, setKey] = useState(''); const [value, setValue] = useState('');
93
+ const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState('');
94
+ const member = useQuery({ queryKey: ['assistant-memory-member', me?.id, group], enabled: systemAllowed && tier === 'group' && group != null && !me?.is_superuser, queryFn: async () => (await mojoCall(`/api/group/${group}/member`)).data as MemberLike });
95
+ const canManage = systemAllowed && (tier !== 'group' || group != null && (Boolean(me?.is_superuser) || memberHasPermission(member.data ?? null, 'assistant')));
96
+ const scopeKey = `${me?.id}:${tier}:${group ?? ''}`;
97
+ const scope = useRef({ key: scopeKey, allowed: canManage, mounted: true }); scope.current.key = scopeKey; scope.current.allowed = canManage;
98
+ const generation = useRef(0); const lock = useRef(false);
99
+ useEffect(() => { scope.current.mounted = true; return () => { scope.current.mounted = false; generation.current++; }; }, []);
100
+ useEffect(() => { if (!systemAllowed) { setGroups([]); return; } let cancelled = false; void mojoList<GroupChoice>('/api/group', { start: 0, size: 50, sort: 'name' }).then(page => { if (!cancelled) setGroups(page.rows.map(row => ({ id: Number(row.id), name: String(row.name) }))); }, () => { if (!cancelled) setGroups([]); }); return () => { cancelled = true; }; }, [systemAllowed]);
101
+ const load = useCallback(async () => {
102
+ const request = ++generation.current; const captured = scopeKey;
103
+ setEntries({}); setError('');
104
+ if (!systemAllowed || tier === 'group' && group == null) { setLoading(false); return; }
105
+ setLoading(true);
106
+ try { const next = await getAssistantMemory(tier, tier === 'group' ? group! : undefined); if (scope.current.mounted && request === generation.current && scope.current.key === captured) setEntries(next); }
107
+ catch (cause) { if (scope.current.mounted && request === generation.current && scope.current.key === captured) setError(cause instanceof Error ? cause.message : 'Memory unavailable'); }
108
+ finally { if (scope.current.mounted && request === generation.current && scope.current.key === captured) setLoading(false); }
109
+ }, [scopeKey, systemAllowed, tier, group]);
110
+ useEffect(() => { void load(); }, [load]);
74
111
  const validKey = /^[a-z0-9:_-]{1,64}$/.test(key); const validValue = value.length > 0 && value.length <= 500;
75
- const save = async () => { if (!validKey || !validValue || (tier === 'group' && group == null)) return; try { await saveAssistantMemory(tier, key, value, tier === 'group' ? group! : undefined); setKey(''); setValue(''); await load(); toast.success('Memory saved'); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Save failed'); } };
76
- const remove = async (entryKey: string) => { const confirmed = await modal.confirm({ title: 'Delete memory?', message: `Delete “${entryKey}” from ${tier} memory?`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantMemory(tier, entryKey, tier === 'group' ? group! : undefined); await load(); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Delete failed'); } };
77
- return <div><Heading eyebrow="Assistant" title="Memory" description="Manage global, personal, or explicitly selected group memory without ambient group state." /><div className="panel panel-pad assistant-memory-controls"><div className="seg">{(['global', 'user', 'group'] as MemoryTier[]).map((item) => <button key={item} className={`seg-btn${tier === item ? ' seg-active' : ''}`} onClick={() => setTier(item)}>{item}</button>)}</div>{tier === 'group' && <label className="field"><span className="field-label">Group (bounded first 50)</span><select className="input" value={group ?? ''} 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>}<div className="assistant-memory-form"><label className="field"><span className="field-label">Key</span><input className="input" 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" maxLength={500} rows={2} value={value} onChange={(event) => setValue(event.target.value)} /></label><button className="btn btn-primary" disabled={!validKey || !validValue || loading || (tier === 'group' && group == null)} onClick={() => void save()}>Save</button></div>{key && !validKey && <p className="field-error">Use 1–64 lowercase letters, numbers, colon, underscore, or hyphen.</p>}{error && <div className="form-alert" role="alert">{error}</div>}</div><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><button className="btn-icon" aria-label={`Delete ${entryKey}`} onClick={() => void remove(entryKey)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table> : <p className="empty">No {tier} memory.</p>}</div></div>;
112
+ const mutate = async (operation: () => Promise<void>, captured: string) => {
113
+ if (lock.current || !scope.current.mounted || !scope.current.allowed || scope.current.key !== captured) return;
114
+ lock.current = true; setBusy(true); setError('');
115
+ try { await operation(); if (scope.current.mounted && scope.current.key === captured) await load(); }
116
+ catch (cause) { if (scope.current.mounted && scope.current.key === captured) setError(cause instanceof Error ? cause.message : 'Memory change failed'); }
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>;
78
132
  }
@@ -0,0 +1,9 @@
1
+ import { mojoCall } from '../../client/runtime';
2
+ import { getAssistantSkill } from './api';
3
+ import type { AssistantSkill } from './types';
4
+
5
+ /** 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);
9
+ }
@@ -55,7 +55,8 @@ export interface AdminSection {
55
55
  icon: string;
56
56
  permissions: string[];
57
57
  capability?: string;
58
- navigationGroup?: AdminNavigationGroup;
58
+ /** Set to null to keep this section's routes at the top level of a grouped menu. */
59
+ navigationGroup?: AdminNavigationGroup | null;
59
60
  routes: AdminRoute[];
60
61
  }
61
62
 
@@ -202,13 +203,17 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
202
203
  if (opts.grouped) {
203
204
  const groups = new Map<AdminNavigationGroup, MenuItem[]>();
204
205
  for (const section of sections) {
205
- const group = section.navigationGroup ?? 'other';
206
206
  const base = relativePath(mount, section.basePath ?? section.id);
207
207
  const labeled = section.routes.filter((route) => route.label);
208
208
  const destinations: MenuItem[] = labeled.length > 0 ? labeled.map((route) => ({
209
209
  id: `admin:${section.id}:${route.path || 'index'}`, label: route.label!, keywords: [section.title],
210
210
  route: absolutePath(base, route.path), permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
211
211
  })) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), permissionClauses: [section.permissions] }];
212
+ if (section.navigationGroup === null) {
213
+ items.push(...destinations.map((destination) => ({ ...destination, icon: section.icon })));
214
+ continue;
215
+ }
216
+ const group = section.navigationGroup ?? 'other';
212
217
  groups.set(group, [...(groups.get(group) ?? []), ...destinations]);
213
218
  }
214
219
  for (const [group, children] of [...groups.entries()].sort((a, b) => ADMIN_NAVIGATION_GROUPS[a[0]].order - ADMIN_NAVIGATION_GROUPS[b[0]].order)) {
@@ -1,24 +1,45 @@
1
- import { useContext, useSyncExternalStore } from 'react';
2
1
  import { useQueryClient } from '@tanstack/react-query';
2
+ import { useContext, useSyncExternalStore } from 'react';
3
3
  import {
4
- GroupContext, hasPermission, useCan, useMe,
4
+ GroupContext,
5
+ hasPermission,
6
+ useCan,
7
+ useMe,
5
8
  type PermSpec,
6
9
  } from '../../client/runtime';
7
10
  import {
8
- ArmedButton, Badge, DetailView, Eyebrow, FlatRow, ModelTable,
9
- SchemaForm, fmt, modal, toast,
10
- type Column, type Field, type FilterDef,
11
+ Badge,
12
+ DetailView,
13
+ Eyebrow,
14
+ FlatRow,
15
+ ModelTable,
16
+ SchemaForm,
17
+ fmt,
18
+ modal,
19
+ toast,
20
+ type Column,
21
+ type Field,
22
+ type FilterDef,
11
23
  } from '../../ui';
24
+ import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
12
25
  import {
13
- GLOBAL_CREDENTIAL_PERMS, GROUP_CREDENTIAL_PERMS,
14
- GroupApiKeyModel, buildApiKeyPermissionChanges, customApiKeyPermissionNames,
26
+ GLOBAL_CREDENTIAL_PERMS,
27
+ GROUP_CREDENTIAL_PERMS,
28
+ GroupApiKeyModel,
29
+ buildApiKeyPermissionChanges,
30
+ customApiKeyPermissionNames,
15
31
  fetchApiKeyToken,
16
- getGroupApiKeyPermissions, grantedPermissions,
17
- groupApiKeyPermissionsVersion, subscribeGroupApiKeyPermissions,
18
- normalizeApiKeyPermissionNames, readApiKeyRateLimits, useCreateGroupApiKey,
19
- type ApiKeyPermissionDef, type CredentialGroup, type GroupApiKeyRow,
32
+ getGroupApiKeyPermissions,
33
+ grantedPermissions,
34
+ groupApiKeyPermissionsVersion,
35
+ normalizeApiKeyPermissionNames,
36
+ readApiKeyRateLimits,
37
+ subscribeGroupApiKeyPermissions,
38
+ useCreateGroupApiKey,
39
+ type ApiKeyPermissionDef,
40
+ type CredentialGroup,
41
+ type GroupApiKeyRow,
20
42
  } from './models';
21
- import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
22
43
  import { showSecretDialog } from './secret-dialog';
23
44
 
24
45
  function groupLabel(group: GroupApiKeyRow['group']): string {
@@ -95,7 +116,7 @@ function useGroupApiKeyActions(permission: PermSpec) {
95
116
  const renderedPermissions = useRenderedPermissions();
96
117
  const create = useCreateGroupApiKey();
97
118
  const save = GroupApiKeyModel.useSave();
98
- const destroy = GroupApiKeyModel.useDelete();
119
+
99
120
  const { can } = useCan(permission);
100
121
 
101
122
  const createKey = async (fixedGroup?: CredentialGroup) => {
@@ -244,19 +265,19 @@ function useGroupApiKeyActions(permission: PermSpec) {
244
265
  }
245
266
  };
246
267
 
247
- const deleteKey = async (row: GroupApiKeyRow) => {
268
+ const toggleKey = async (row: GroupApiKeyRow) => {
248
269
  if (!can) return false;
249
270
  try {
250
- await destroy.mutateAsync({ id: row.id });
251
- toast.success('API key deleted');
271
+ await save.mutateAsync({ id: row.id, changes: { is_active: !row.is_active } });
272
+ toast.success(row.is_active ? 'API key deactivated' : 'API key reactivated');
252
273
  return true;
253
274
  } catch (error) {
254
- toast.error(error instanceof Error ? error.message : 'Failed to delete API key');
275
+ toast.error(error instanceof Error ? error.message : 'Failed to update API key');
255
276
  return false;
256
277
  }
257
278
  };
258
279
 
259
- return { canManage: can, createKey, editKey, revealToken, deleteKey };
280
+ return { canManage: can, createKey, editKey, revealToken, toggleKey, pending: save.isPending };
260
281
  }
261
282
 
262
283
  function ApiKeyCard({ row, actions }: {
@@ -299,13 +320,7 @@ function ApiKeyCard({ row, actions }: {
299
320
  <button className="btn btn-compact" title="Reveal the current token" onClick={() => void actions.revealToken(row)}>
300
321
  <i className="bi bi-eye" /> Token
301
322
  </button>
302
- <ArmedButton
303
- className="btn-compact"
304
- label={<i className="bi bi-trash" aria-label="Delete this key" />}
305
- armedLabel="Click again — services using this key lose access"
306
- title="Delete this key"
307
- onConfirm={async () => { await actions.deleteKey(row); }}
308
- />
323
+ <button className="btn btn-compact" disabled={actions.pending} onClick={() => void actions.toggleKey(row)}>{row.is_active ? 'Deactivate' : 'Reactivate'}</button>
309
324
  </div>
310
325
  )}
311
326
  </div>
@@ -369,6 +384,12 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
369
384
  { text: `${permissions.length} grants`, tone: 'info' },
370
385
  limitsChip,
371
386
  ]}
387
+ active={actions.canManage ? { value: row.is_active, disabled: actions.pending, onChange: () => void actions.toggleKey(row) } : undefined}
388
+ contextMenu={actions.canManage ? [
389
+ { label: 'Edit key', onSelect: () => void actions.editKey(row) },
390
+ { label: 'Reveal audited token', onSelect: () => void actions.revealToken(row) },
391
+ { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: actions.pending, onSelect: () => void actions.toggleKey(row) },
392
+ ] : []}
372
393
  sections={[
373
394
  {
374
395
  key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
@@ -378,24 +399,7 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
378
399
  <FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
379
400
  <FlatRow label="Last used">{fmt.relative(row.last_used, 'never')}</FlatRow>
380
401
  <FlatRow label="Expires">{row.expires_at ? fmt.datetime(row.expires_at) : 'Never'}</FlatRow>
381
- {actions.canManage && (
382
- <div className="ga-toolbar" style={{ marginTop: 16 }}>
383
- <button className="btn btn-compact" onClick={() => void actions.editKey(row)}>
384
- <i className="bi bi-pencil" /> Edit
385
- </button>
386
- <button className="btn btn-compact" onClick={() => void actions.revealToken(row)}>
387
- <i className="bi bi-eye" /> Reveal audited token
388
- </button>
389
- <ArmedButton
390
- className="btn-compact"
391
- label="Delete"
392
- armedLabel="Click again — delete now"
393
- onConfirm={async () => {
394
- if (await actions.deleteKey(row)) onClose();
395
- }}
396
- />
397
- </div>
398
- )}
402
+
399
403
  </>
400
404
  ),
401
405
  },
@@ -1,15 +1,29 @@
1
- import { useState } from 'react';
2
1
  import { useQueryClient } from '@tanstack/react-query';
2
+ import { useState } from 'react';
3
3
  import { useCan, type PermSpec } from '../../client/runtime';
4
4
  import {
5
- ArmedButton, Badge, DetailView, Eyebrow, FlatRow, ModelTable,
6
- SchemaForm, fmt, modal, toast,
7
- type Column, type Field, type FilterDef,
5
+ Badge,
6
+ DetailView,
7
+ Eyebrow,
8
+ FlatRow,
9
+ ModelTable,
10
+ SchemaForm,
11
+ fmt,
12
+ modal,
13
+ toast,
14
+ type Column,
15
+ type Field,
16
+ type FilterDef,
8
17
  } from '../../ui';
9
18
  import {
10
- GLOBAL_CREDENTIAL_PERMS, GROUP_CREDENTIAL_PERMS,
11
- WebhookSubscriptionModel, fetchWebhookSecret, normalizeWebhookEvents,
12
- type CredentialGroup, type WebhookSecretInfo, type WebhookSubscriptionRow,
19
+ GLOBAL_CREDENTIAL_PERMS,
20
+ GROUP_CREDENTIAL_PERMS,
21
+ WebhookSubscriptionModel,
22
+ fetchWebhookSecret,
23
+ normalizeWebhookEvents,
24
+ type CredentialGroup,
25
+ type WebhookSecretInfo,
26
+ type WebhookSubscriptionRow,
13
27
  } from './models';
14
28
  import { showSecretDialog } from './secret-dialog';
15
29
 
@@ -123,7 +137,7 @@ const WEBHOOK_FIELDS: Field[] = [
123
137
 
124
138
  function useWebhookActions(permission: PermSpec) {
125
139
  const save = WebhookSubscriptionModel.useSave();
126
- const destroy = WebhookSubscriptionModel.useDelete();
140
+
127
141
  const { can } = useCan(permission);
128
142
 
129
143
  const createSubscription = async (fixedGroup?: CredentialGroup) => {
@@ -209,19 +223,9 @@ function useWebhookActions(permission: PermSpec) {
209
223
  }
210
224
  };
211
225
 
212
- const deleteSubscription = async (row: WebhookSubscriptionRow) => {
213
- if (!can) return false;
214
- try {
215
- await destroy.mutateAsync({ id: row.id });
216
- toast.success('Webhook subscription deleted');
217
- return true;
218
- } catch (error) {
219
- toast.error(error instanceof Error ? error.message : 'Failed to delete subscription');
220
- return false;
221
- }
222
- };
223
226
 
224
- return { canManage: can, createSubscription, editSubscription, toggleSubscription, deleteSubscription };
227
+
228
+ return { canManage: can, createSubscription, editSubscription, toggleSubscription, pending: save.isPending };
225
229
  }
226
230
 
227
231
  function WebhookCard({ row, actions }: {
@@ -271,13 +275,7 @@ function WebhookCard({ row, actions }: {
271
275
  }}
272
276
  />
273
277
  </label>
274
- <ArmedButton
275
- className="btn-compact"
276
- label={<i className="bi bi-trash" aria-label="Delete this subscription" />}
277
- armedLabel="Click again — deliveries stop"
278
- title="Delete this subscription"
279
- onConfirm={async () => { await actions.deleteSubscription(row); }}
280
- />
278
+
281
279
  </div>
282
280
  )}
283
281
  </div>
@@ -333,6 +331,8 @@ export function WebhookSubscriptionDetail({ id, onClose }: { id: number; onClose
333
331
  title={row.url}
334
332
  subtitle={`${groupLabel(row.group)} · webhook subscription`}
335
333
  chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }]}
334
+ active={actions.canManage ? { value: row.is_active, disabled: actions.pending, onChange: next => void actions.toggleSubscription(row, next) } : undefined}
335
+ contextMenu={actions.canManage ? [{ label: 'Edit subscription', onSelect: () => void actions.editSubscription(row) }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: actions.pending, onSelect: () => void actions.toggleSubscription(row, !row.is_active) }] : []}
336
336
  sections={[
337
337
  {
338
338
  key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
@@ -342,19 +342,7 @@ export function WebhookSubscriptionDetail({ id, onClose }: { id: number; onClose
342
342
  <FlatRow label="URL"><code>{row.url}</code></FlatRow>
343
343
  <FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
344
344
  <FlatRow label="Modified">{fmt.datetime(row.modified)}</FlatRow>
345
- <div className="ga-toolbar" style={{ marginTop: 16 }}>
346
- <button className="btn btn-compact" onClick={() => void actions.editSubscription(row)}>
347
- <i className="bi bi-pencil" /> Edit URL, events, and status
348
- </button>
349
- <ArmedButton
350
- className="btn-compact"
351
- label="Delete"
352
- armedLabel="Click again — delete now"
353
- onConfirm={async () => {
354
- if (await actions.deleteSubscription(row)) onClose();
355
- }}
356
- />
357
- </div>
345
+
358
346
  </>
359
347
  ),
360
348
  },
@@ -84,7 +84,7 @@ function LoginOverview() {
84
84
 
85
85
  export function AdminDashboardPage() {
86
86
  return <div className="admin-dashboard">
87
- <header className="admin-dashboard-header"><div className="eyebrow">Overview</div><h1>Admin dashboard</h1><p className="dim">Authoritative operational signals for this django-mojo deployment.</p></header>
87
+ <header className="admin-dashboard-header"><h1>Dashboard</h1><p className="dim">Authoritative operational signals for this django-mojo deployment.</p></header>
88
88
  <MetricOverview />
89
89
  <AttentionOverview />
90
90
  <LoginOverview />
@@ -49,9 +49,21 @@ export function CertificateLifecyclePoller({ domain, intervalMs = CERTIFICATE_PO
49
49
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
50
50
  const [version, setVersion] = useState(0);
51
51
 
52
- useEffect(() => queryClient.getQueryCache().subscribe((event) => {
53
- if (event.query.queryKey[0] === CertificateModel.endpoint) setVersion((value) => value + 1);
54
- }), [queryClient]);
52
+ useEffect(() => {
53
+ let mounted = true;
54
+ let queued = false;
55
+ const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
56
+ if (event.query.queryKey[0] !== CertificateModel.endpoint || queued) return;
57
+ // Observer registration can notify while a detail is rendering.
58
+ // Coalesce cache notifications after that render, including StrictMode replay.
59
+ queued = true;
60
+ queueMicrotask(() => {
61
+ queued = false;
62
+ if (mounted) setVersion((value) => value + 1);
63
+ });
64
+ });
65
+ return () => { mounted = false; unsubscribe(); };
66
+ }, [queryClient]);
55
67
 
56
68
  useEffect(() => {
57
69
  const rowsById = new Map<number, CertificateRow>();
@@ -1,18 +1,34 @@
1
- import { useRef, useState } from 'react';
2
1
  import { useQueryClient } from '@tanstack/react-query';
2
+ import { useRef, useState } from 'react';
3
3
  import { useCan } from '../../client/runtime';
4
4
  import {
5
- ArmedButton, Badge, CollectionSelect, FlatRow, ModelTable, fmt, modal, toast,
6
- type Column, type FilterDef, type Tone,
5
+ Badge,
6
+ CollectionSelect,
7
+ DetailView,
8
+ FlatRow,
9
+ ModelTable,
10
+ fmt,
11
+ modal,
12
+ toast,
13
+ type Column,
14
+ type FilterDef,
15
+ type Tone,
7
16
  } from '../../ui';
8
17
  import {
9
- DNS_GROUP_CHOICE_ENDPOINT, invalidateDnsCredentials, linkDnsCredential,
10
- rotateDnsCredential, useDnsCapabilities,
18
+ DNS_GROUP_CHOICE_ENDPOINT,
19
+ invalidateDnsCredentials,
20
+ linkDnsCredential,
21
+ rotateDnsCredential,
22
+ useDnsCapabilities,
11
23
  } from './api';
12
24
  import { linkableProviders, providerLabel } from './data';
13
25
  import {
14
- DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS, DnsCredentialModel,
15
- type DnsCapabilities, type DnsCredentialRow, type DnsGroupChoice,
26
+ DNS_MANAGE_PERMISSIONS,
27
+ DNS_VIEW_PERMISSIONS,
28
+ DnsCredentialModel,
29
+ type DnsCapabilities,
30
+ type DnsCredentialRow,
31
+ type DnsGroupChoice,
16
32
  } from './models';
17
33
 
18
34
  function groupId(group: DnsCredentialRow['group']): number | null {
@@ -154,7 +170,7 @@ function CredentialDetail({ id, caps, close }: { id: number; caps: DnsCapabiliti
154
170
  const { data: row, isPending, error } = DnsCredentialModel.useOne(id);
155
171
  const { can: canManage } = useCan(DNS_MANAGE_PERMISSIONS);
156
172
  const save = DnsCredentialModel.useSave();
157
- const destroy = DnsCredentialModel.useDelete();
173
+
158
174
  if (isPending) return <div className="modal-pad dim">Loading credential…</div>;
159
175
  if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'Credential not found'}</div>;
160
176
 
@@ -167,26 +183,13 @@ function CredentialDetail({ id, caps, close }: { id: number; caps: DnsCapabiliti
167
183
  }
168
184
  };
169
185
 
170
- const remove = async () => {
171
- try {
172
- await destroy.mutateAsync({ id: row.id });
173
- toast.success('Credential deleted');
174
- close();
175
- } catch (reason) {
176
- toast.error(reason instanceof Error ? reason.message : 'Credential could not be deleted');
177
- }
178
- };
179
186
 
180
- return (
181
- <div className="modal-pad">
182
- <div className="eyebrow">Provider credential</div>
183
- <h2 className="modal-title">{row.name || `Credential #${row.id}`}</h2>
184
- <div className="chip-row" style={{ marginBottom: 14 }}>
185
- <Badge tone={verificationTone(row)}>{row.verified ? 'Verified' : 'Unverified'}</Badge>
186
- <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge>
187
- <Badge tone="warning">{providerLabel(row.provider)}</Badge>
188
- </div>
189
- <FlatRow label="Group">{groupLabel(row.group)}</FlatRow>
187
+
188
+ return <DetailView title={row.name || `Credential #${row.id}`} subtitle={providerLabel(row.provider)} icon="bi-key" onClose={close}
189
+ chips={[{ text: row.verified ? 'Verified' : 'Unverified', tone: verificationTone(row) }, { text: row.is_active ? 'Active' : 'Retired', tone: row.is_active ? 'success' : 'muted' }]}
190
+ active={canManage ? { value: row.is_active, disabled: save.isPending, onChange: () => void toggle() } : undefined}
191
+ contextMenu={canManage ? [{ label: 'Rotate credentials…', onSelect: () => void showCredentialEditor(caps, row) }, { label: row.is_active ? 'Retire' : 'Reactivate', disabled: save.isPending, onSelect: () => void toggle() }] : []}
192
+ sections={[{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"> <FlatRow label="Group">{groupLabel(row.group)}</FlatRow>
190
193
  <FlatRow label="API key"><code>{row.api_key_masked || '—'}</code></FlatRow>
191
194
  <FlatRow label="API secret"><code>{row.api_secret_masked || '—'}</code></FlatRow>
192
195
  <FlatRow label="Domains at verification">{row.domain_count}</FlatRow>
@@ -196,26 +199,7 @@ function CredentialDetail({ id, caps, close }: { id: number; caps: DnsCapabiliti
196
199
  <p className="dim">
197
200
  The linked-domain count is a health signal only. Provider account domains are never enumerated here.
198
201
  </p>
199
- {canManage && (
200
- <div className="modal-actions">
201
- <button type="button" className="btn" onClick={() => void showCredentialEditor(caps, row)}>
202
- <i className="bi bi-arrow-repeat" /> Rotate
203
- </button>
204
- <button type="button" className="btn" disabled={save.isPending} onClick={() => void toggle()}>
205
- <i className={`bi ${row.is_active ? 'bi-pause-circle' : 'bi-play-circle'}`} /> {row.is_active ? 'Retire' : 'Activate'}
206
- </button>
207
- <ArmedButton
208
- label="Delete"
209
- armedLabel="Click again — linked domains lose provider-backed DNS"
210
- disabled={destroy.isPending}
211
- onConfirm={remove}
212
- />
213
- <button type="button" className="btn" onClick={close}>Close</button>
214
- </div>
215
- )}
216
- {!canManage && <div className="modal-actions"><button type="button" className="btn" onClick={close}>Close</button></div>}
217
- </div>
218
- );
202
+ </div> }]} />;
219
203
  }
220
204
 
221
205
  const COLUMNS: Column<DnsCredentialRow>[] = [
@@ -248,9 +232,9 @@ function CredentialsTable({ caps }: { caps: DnsCapabilities }) {
248
232
  ];
249
233
  const openDetail = (row: DnsCredentialRow) => {
250
234
  void DnsCredentialModel.fetchOne(queryClient, row.id).catch(() => undefined);
251
- void modal.open((done) => (
235
+ void modal.detail((done) => (
252
236
  <CredentialDetail id={row.id} caps={caps} close={() => done(null)} />
253
- ), { size: 'md' });
237
+ ));
254
238
  };
255
239
  const canLink = canManage && linkableProviders(caps).length > 0;
256
240
  return (
@@ -10,7 +10,7 @@ registerDnsAdminIntegration({
10
10
  return resolveDnsDomainByName(normalizedName);
11
11
  },
12
12
  recordsHref: (domainId) => `dns/records?domain=${encodeURIComponent(domainId)}`,
13
- ...(usingMockTransport() ? {
13
+ ...(import.meta.env.VITE_MOJO_PACKAGED_ADMIN !== '1' && usingMockTransport() ? {
14
14
  applyManagedDnsRecords: async (domainId: number, records: readonly ManagedDnsRecordInput[]) => {
15
15
  const { applyMockManagedDnsRecords } = await import('../../client/mock');
16
16
  applyMockManagedDnsRecords(domainId, records);