portal-mojo 0.2.1 → 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 (71) 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 +20 -4
  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 +91 -55
  28. package/src/admin/credentials/models.ts +64 -0
  29. package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
  30. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  31. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  32. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  33. package/src/admin/dns/setup.ts +1 -1
  34. package/src/admin/domains/observability.ts +1 -1
  35. package/src/admin/identity/users/UserDetail.tsx +36 -20
  36. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  37. package/src/admin/identity/users/sections/actions.tsx +20 -9
  38. package/src/admin/incidents/EventDetail.tsx +147 -5
  39. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  40. package/src/admin/incidents/models.ts +1 -1
  41. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  42. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  43. package/src/admin/jobs/index.ts +1 -1
  44. package/src/admin/jobs/purge-preview.ts +25 -0
  45. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  46. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  47. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  48. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  49. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  50. package/src/admin/messaging/push/PushPage.tsx +20 -8
  51. package/src/admin/monitoring/LogInspector.tsx +3 -19
  52. package/src/admin/network/IPSetDetail.tsx +32 -63
  53. package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
  54. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  55. package/src/admin/phonehub/api.ts +6 -0
  56. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  57. package/src/admin/rules/condition-removal.ts +7 -0
  58. package/src/admin/security/geoip/models.ts +5 -0
  59. package/src/admin/security/tickets.tsx +40 -17
  60. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  61. package/src/admin/storage/BucketDetail.tsx +28 -9
  62. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  63. package/src/admin/storage/FileView.tsx +28 -16
  64. package/src/admin/storage/FilesPage.tsx +4 -5
  65. package/src/client/auth.ts +10 -1
  66. package/src/client/client.ts +14 -6
  67. package/src/client/mock.ts +89 -5
  68. package/src/client/upload.ts +1 -0
  69. package/src/ui/DetailView.tsx +22 -5
  70. package/src/ui/Popover.tsx +13 -12
  71. package/src/ui/modal.tsx +8 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,7 +1,7 @@
1
+ import type { FileReference } from '../../client/record-feed';
1
2
  import { mojoCall, mojoDelete, mojoList, type Params } from '../../client/runtime';
2
3
  import { projectConversation, projectConversationSummary, projectReply, projectSkill } from './data';
3
4
  import type { AssistantConversation, AssistantConversationSummary, AssistantReply, AssistantSkill } from './types';
4
- import type { FileReference } from '../../client/record-feed';
5
5
 
6
6
  const malformed = (label: string): never => { throw new Error(`${label} returned an invalid response`); };
7
7
  export async function sendAssistantMessage(message: string, conversationId?: number, attachments: readonly FileReference[] = []): Promise<AssistantReply> { const ids = attachments.map((file) => file.id); if (ids.length > 5 || new Set(ids).size !== ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) throw new Error('Assistant attachments must be 1–5 unique completed File references'); const body = await mojoCall('/api/assistant', { method: 'POST', body: { message, ...(conversationId != null ? { conversation_id: conversationId } : {}), ...(ids.length ? { attachments: ids } : {}) } }); return projectReply(body.data) ?? malformed('Assistant'); }
@@ -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,23 +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, fetchApiKeyToken,
15
- getGroupApiKeyPermissions, grantedPermissions,
16
- groupApiKeyPermissionsVersion, subscribeGroupApiKeyPermissions,
17
- readApiKeyRateLimits, useCreateGroupApiKey,
18
- type ApiKeyPermissionDef, type CredentialGroup, type GroupApiKeyRow,
26
+ GLOBAL_CREDENTIAL_PERMS,
27
+ GROUP_CREDENTIAL_PERMS,
28
+ GroupApiKeyModel,
29
+ buildApiKeyPermissionChanges,
30
+ customApiKeyPermissionNames,
31
+ fetchApiKeyToken,
32
+ getGroupApiKeyPermissions,
33
+ grantedPermissions,
34
+ groupApiKeyPermissionsVersion,
35
+ normalizeApiKeyPermissionNames,
36
+ readApiKeyRateLimits,
37
+ subscribeGroupApiKeyPermissions,
38
+ useCreateGroupApiKey,
39
+ type ApiKeyPermissionDef,
40
+ type CredentialGroup,
41
+ type GroupApiKeyRow,
19
42
  } from './models';
20
- import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
21
43
  import { showSecretDialog } from './secret-dialog';
22
44
 
23
45
  function groupLabel(group: GroupApiKeyRow['group']): string {
@@ -60,6 +82,14 @@ function permissionSeed(
60
82
  ]));
61
83
  }
62
84
 
85
+ const CUSTOM_PERMISSION_FIELD: Field = {
86
+ name: 'custom_permissions',
87
+ type: 'tags',
88
+ label: 'Additional permission names',
89
+ placeholder: 'Type a permission and press Enter',
90
+ help: 'Add permission strings that are not listed above. Remove a tag to revoke it when editing; the server authorizes every change.',
91
+ };
92
+
63
93
  function TokenFooter({ permissions }: { permissions: string[] }) {
64
94
  return (
65
95
  <div className="ga-secret-footnote">
@@ -86,7 +116,7 @@ function useGroupApiKeyActions(permission: PermSpec) {
86
116
  const renderedPermissions = useRenderedPermissions();
87
117
  const create = useCreateGroupApiKey();
88
118
  const save = GroupApiKeyModel.useSave();
89
- const destroy = GroupApiKeyModel.useDelete();
119
+
90
120
  const { can } = useCan(permission);
91
121
 
92
122
  const createKey = async (fixedGroup?: CredentialGroup) => {
@@ -106,6 +136,7 @@ function useGroupApiKeyActions(permission: PermSpec) {
106
136
  placeholder: 'Search groups…',
107
137
  } satisfies Field] : []),
108
138
  ...permissionFields(renderedPermissions),
139
+ CUSTOM_PERMISSION_FIELD,
109
140
  ]}
110
141
  submitText="Create key"
111
142
  onCancel={() => close(null)}
@@ -116,8 +147,16 @@ function useGroupApiKeyActions(permission: PermSpec) {
116
147
  if (!result) return;
117
148
 
118
149
  const permissions: Record<string, true> = {};
119
- for (const def of renderedPermissions) {
120
- if (result[`permissions.${def.name}`] === true) permissions[def.name] = true;
150
+ try {
151
+ for (const def of renderedPermissions) {
152
+ if (result[`permissions.${def.name}`] === true) permissions[def.name] = true;
153
+ }
154
+ for (const name of normalizeApiKeyPermissionNames(result.custom_permissions)) {
155
+ permissions[name] = true;
156
+ }
157
+ } catch (error) {
158
+ toast.error(error instanceof Error ? error.message : 'Invalid permission name');
159
+ return;
121
160
  }
122
161
  const group = fixedGroup?.id ?? Number(result.group);
123
162
  if (!Number.isFinite(group) || group <= 0) {
@@ -162,8 +201,14 @@ function useGroupApiKeyActions(permission: PermSpec) {
162
201
  { name: 'name', type: 'text', label: 'Name', required: true, columns: 6 },
163
202
  { name: 'is_active', type: 'switch', label: 'Active', columns: 6 },
164
203
  ...renderedFields,
204
+ CUSTOM_PERMISSION_FIELD,
165
205
  ]}
166
- initial={{ name: row.name, is_active: row.is_active, ...before }}
206
+ initial={{
207
+ name: row.name,
208
+ is_active: row.is_active,
209
+ ...before,
210
+ custom_permissions: customApiKeyPermissionNames(row.permissions).join(','),
211
+ }}
167
212
  submitText="Save changes"
168
213
  onCancel={() => close(null)}
169
214
  onSubmit={(form) => close(form)}
@@ -178,14 +223,20 @@ function useGroupApiKeyActions(permission: PermSpec) {
178
223
  const nextActive = result.is_active === true;
179
224
  if (nextActive !== row.is_active) changes.is_active = nextActive;
180
225
 
181
- // Diff only controls that were actually rendered. Unknown and
182
- // protected grants remain absent from the partial JSONField update.
183
- const permissionChanges: Record<string, boolean> = {};
184
- for (const def of renderedPermissions) {
185
- const key = `permissions.${def.name}`;
186
- const was = before[key] === true;
187
- const now = result[key] === true;
188
- if (was !== now) permissionChanges[def.name] = now;
226
+ // Diff only guided controls that were actually rendered, plus the
227
+ // explicit freeform set. Protected hidden grants remain absent unless
228
+ // the operator deliberately types one (the server is authoritative).
229
+ let permissionChanges: Record<string, boolean>;
230
+ try {
231
+ permissionChanges = buildApiKeyPermissionChanges(
232
+ row.permissions,
233
+ renderedPermissions,
234
+ result,
235
+ result.custom_permissions,
236
+ );
237
+ } catch (error) {
238
+ toast.error(error instanceof Error ? error.message : 'Invalid permission name');
239
+ return;
189
240
  }
190
241
  if (Object.keys(permissionChanges).length) changes.permissions = permissionChanges;
191
242
  if (!Object.keys(changes).length) return;
@@ -214,19 +265,19 @@ function useGroupApiKeyActions(permission: PermSpec) {
214
265
  }
215
266
  };
216
267
 
217
- const deleteKey = async (row: GroupApiKeyRow) => {
268
+ const toggleKey = async (row: GroupApiKeyRow) => {
218
269
  if (!can) return false;
219
270
  try {
220
- await destroy.mutateAsync({ id: row.id });
221
- 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');
222
273
  return true;
223
274
  } catch (error) {
224
- 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');
225
276
  return false;
226
277
  }
227
278
  };
228
279
 
229
- return { canManage: can, createKey, editKey, revealToken, deleteKey };
280
+ return { canManage: can, createKey, editKey, revealToken, toggleKey, pending: save.isPending };
230
281
  }
231
282
 
232
283
  function ApiKeyCard({ row, actions }: {
@@ -269,13 +320,7 @@ function ApiKeyCard({ row, actions }: {
269
320
  <button className="btn btn-compact" title="Reveal the current token" onClick={() => void actions.revealToken(row)}>
270
321
  <i className="bi bi-eye" /> Token
271
322
  </button>
272
- <ArmedButton
273
- className="btn-compact"
274
- label={<i className="bi bi-trash" aria-label="Delete this key" />}
275
- armedLabel="Click again — services using this key lose access"
276
- title="Delete this key"
277
- onConfirm={async () => { await actions.deleteKey(row); }}
278
- />
323
+ <button className="btn btn-compact" disabled={actions.pending} onClick={() => void actions.toggleKey(row)}>{row.is_active ? 'Deactivate' : 'Reactivate'}</button>
279
324
  </div>
280
325
  )}
281
326
  </div>
@@ -339,6 +384,12 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
339
384
  { text: `${permissions.length} grants`, tone: 'info' },
340
385
  limitsChip,
341
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
+ ] : []}
342
393
  sections={[
343
394
  {
344
395
  key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
@@ -348,24 +399,7 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
348
399
  <FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
349
400
  <FlatRow label="Last used">{fmt.relative(row.last_used, 'never')}</FlatRow>
350
401
  <FlatRow label="Expires">{row.expires_at ? fmt.datetime(row.expires_at) : 'Never'}</FlatRow>
351
- {actions.canManage && (
352
- <div className="ga-toolbar" style={{ marginTop: 16 }}>
353
- <button className="btn btn-compact" onClick={() => void actions.editKey(row)}>
354
- <i className="bi bi-pencil" /> Edit
355
- </button>
356
- <button className="btn btn-compact" onClick={() => void actions.revealToken(row)}>
357
- <i className="bi bi-eye" /> Reveal audited token
358
- </button>
359
- <ArmedButton
360
- className="btn-compact"
361
- label="Delete"
362
- armedLabel="Click again — delete now"
363
- onConfirm={async () => {
364
- if (await actions.deleteKey(row)) onClose();
365
- }}
366
- />
367
- </div>
368
- )}
402
+
369
403
  </>
370
404
  ),
371
405
  },
@@ -378,7 +412,9 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
378
412
  ? permissions.map((permission) => <Badge key={permission} tone="info">{permission}</Badge>)
379
413
  : <span className="dim-italic">No permissions granted</span>}
380
414
  </div>
381
- <p className="dim">Only controls visible to the operator are diffed when editing; unknown grants are preserved.</p>
415
+ <p className="dim">
416
+ Use Edit to add or remove arbitrary permission names. Hidden protected controls are never changed implicitly.
417
+ </p>
382
418
  </>
383
419
  ),
384
420
  },
@@ -329,6 +329,16 @@ registerGroupApiKeyPermissions([
329
329
  { name: 'view_members', label: 'View Members' },
330
330
  { name: 'manage_members', label: 'Manage Members' },
331
331
  { name: 'view_billing', label: 'View Billing' },
332
+ {
333
+ name: 'send_sms',
334
+ label: 'Send SMS',
335
+ tooltip: 'Send outbound SMS messages for this key\'s group.',
336
+ },
337
+ {
338
+ name: 'comms',
339
+ label: 'Communications',
340
+ tooltip: 'Broad communications access, including SMS. Prefer Send SMS when that is all the integration needs.',
341
+ },
332
342
  {
333
343
  name: 'geoip_sync',
334
344
  label: 'GeoIP Federation Sync',
@@ -344,6 +354,60 @@ export function grantedPermissions(dict: Record<string, unknown> | null | undefi
344
354
  .map(([name]) => name);
345
355
  }
346
356
 
357
+ /** TagInput CSV or array to unique permission names. */
358
+ export function normalizeApiKeyPermissionNames(raw: unknown): string[] {
359
+ const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
360
+ const names: string[] = [];
361
+ for (const value of values) {
362
+ let name = String(value ?? '').trim();
363
+ if (name.startsWith('permissions.')) name = name.slice('permissions.'.length).trim();
364
+ if (!name) continue;
365
+ if (name === '__replace') {
366
+ throw new Error('Permission "__replace" is reserved and cannot be granted.');
367
+ }
368
+ if (!names.includes(name)) names.push(name);
369
+ }
370
+ return names;
371
+ }
372
+
373
+ /** Truthy stored grants that have no registered guided control. */
374
+ export function customApiKeyPermissionNames(
375
+ dict: Record<string, unknown> | null | undefined,
376
+ ): string[] {
377
+ const registered = new Set(getGroupApiKeyPermissions().map((permission) => permission.name));
378
+ return grantedPermissions(dict).filter((name) => !registered.has(name)).sort();
379
+ }
380
+
381
+ /**
382
+ * Build a merge-safe permissions patch from rendered switches and freeform
383
+ * tags. Only rendered switches are diffed, so protected controls hidden from
384
+ * this operator remain untouched. Missing previously-custom tags are revoked.
385
+ */
386
+ export function buildApiKeyPermissionChanges(
387
+ current: Record<string, unknown> | null | undefined,
388
+ rendered: ApiKeyPermissionDef[],
389
+ form: Record<string, unknown>,
390
+ customRaw: unknown,
391
+ ): Record<string, boolean> {
392
+ const currentGranted = new Set(grantedPermissions(current));
393
+ const changes: Record<string, boolean> = {};
394
+
395
+ for (const permission of rendered) {
396
+ const next = form[`permissions.${permission.name}`] === true;
397
+ if (currentGranted.has(permission.name) !== next) changes[permission.name] = next;
398
+ }
399
+
400
+ const beforeCustom = new Set(customApiKeyPermissionNames(current));
401
+ const afterCustom = normalizeApiKeyPermissionNames(customRaw);
402
+ for (const name of afterCustom) {
403
+ if (!currentGranted.has(name)) changes[name] = true;
404
+ }
405
+ for (const name of beforeCustom) {
406
+ if (!afterCustom.includes(name)) changes[name] = false;
407
+ }
408
+ return changes;
409
+ }
410
+
347
411
  export interface WebhookSubscriptionRow {
348
412
  id: number;
349
413
  created: number;