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.
- package/docs/DetailView.md +19 -15
- package/docs/README.md +1 -0
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- 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 +47 -43
- 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
|
@@ -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,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,
|
|
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
|
-
|
|
26
|
+
GLOBAL_CREDENTIAL_PERMS,
|
|
27
|
+
GROUP_CREDENTIAL_PERMS,
|
|
28
|
+
GroupApiKeyModel,
|
|
29
|
+
buildApiKeyPermissionChanges,
|
|
30
|
+
customApiKeyPermissionNames,
|
|
15
31
|
fetchApiKeyToken,
|
|
16
|
-
getGroupApiKeyPermissions,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
268
|
+
const toggleKey = async (row: GroupApiKeyRow) => {
|
|
248
269
|
if (!can) return false;
|
|
249
270
|
try {
|
|
250
|
-
await
|
|
251
|
-
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');
|
|
252
273
|
return true;
|
|
253
274
|
} catch (error) {
|
|
254
|
-
toast.error(error instanceof Error ? error.message : 'Failed to
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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,
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"><
|
|
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(() =>
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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,
|
|
10
|
-
|
|
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,
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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.
|
|
235
|
+
void modal.detail((done) => (
|
|
252
236
|
<CredentialDetail id={row.id} caps={caps} close={() => done(null)} />
|
|
253
|
-
)
|
|
237
|
+
));
|
|
254
238
|
};
|
|
255
239
|
const canLink = canManage && linkableProviders(caps).length > 0;
|
|
256
240
|
return (
|
package/src/admin/dns/setup.ts
CHANGED
|
@@ -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);
|