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.
- package/docs/DetailView.md +19 -15
- package/docs/README.md +1 -0
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +20 -4
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +13 -5
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/feedback.md +10 -0
- package/docs/forms.md +10 -0
- package/docs/popover.md +15 -0
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +91 -55
- package/src/admin/credentials/models.ts +64 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +6 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +89 -5
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/Popover.tsx +13 -12
- 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.
|
|
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 <
|
|
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
|
|
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
|
-
|
|
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
|
|
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 }) {
|
|
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() {
|
|
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
|
|
72
|
-
|
|
73
|
-
const
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
package/src/admin/core/index.ts
CHANGED
|
@@ -55,7 +55,8 @@ export interface AdminSection {
|
|
|
55
55
|
icon: string;
|
|
56
56
|
permissions: string[];
|
|
57
57
|
capability?: string;
|
|
58
|
-
|
|
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,
|
|
4
|
+
GroupContext,
|
|
5
|
+
hasPermission,
|
|
6
|
+
useCan,
|
|
7
|
+
useMe,
|
|
5
8
|
type PermSpec,
|
|
6
9
|
} from '../../client/runtime';
|
|
7
10
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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={{
|
|
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
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
268
|
+
const toggleKey = async (row: GroupApiKeyRow) => {
|
|
218
269
|
if (!can) return false;
|
|
219
270
|
try {
|
|
220
|
-
await
|
|
221
|
-
toast.success('API key
|
|
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
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
|
|
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">
|
|
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;
|