portal-mojo 0.2.7 → 0.2.9
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/README.md +59 -11
- package/docs/DetailView.md +3 -2
- package/docs/README.md +7 -2
- package/docs/account.md +261 -0
- package/docs/admin-bouncer.md +3 -3
- package/docs/admin-credentials.md +4 -3
- package/docs/admin-devices-geoip.md +1 -1
- package/docs/admin-dns-certificates.md +2 -2
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-edge.md +210 -0
- package/docs/admin-identity-directories-sources.json +2 -2
- package/docs/admin-identity-directories.md +9 -0
- package/docs/admin-identity-users.md +2 -2
- package/docs/admin-jobs.md +4 -4
- package/docs/admin-media-rendering.md +142 -0
- package/docs/admin-modals.md +7 -0
- package/docs/admin-monitoring.md +4 -4
- package/docs/admin-network-security.md +2 -2
- package/docs/admin-phonehub.md +4 -1
- package/docs/admin-storage.md +5 -2
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/calendar.md +1 -1
- package/docs/client.md +159 -26
- package/docs/collection-multiselect.md +2 -2
- package/docs/combobox.md +2 -2
- package/docs/dataview.md +1 -1
- package/docs/datepicker.md +3 -3
- package/docs/daterange.md +1 -1
- package/docs/datetimepicker.md +1 -1
- package/docs/defineModel.md +40 -7
- package/docs/detail-primitives.md +1 -1
- package/docs/feedback.md +18 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +13 -1
- package/docs/loading.md +2 -2
- package/docs/location-address.md +2 -1
- package/docs/markdown.md +3 -3
- package/docs/menus-and-access.md +21 -1
- package/docs/multiselect-dropdown.md +1 -1
- package/docs/popover.md +1 -1
- package/docs/record-feed.md +2 -2
- package/docs/taginput.md +2 -1
- package/docs/timepicker.md +1 -1
- package/docs/timezone-select.md +4 -3
- package/docs/user-menu.md +4 -3
- package/docs/worldmap.md +2 -2
- package/package.json +4 -2
- package/src/account/AccountModal.tsx +276 -0
- package/src/account/AccountSections.tsx +370 -0
- package/src/account/NotificationPreferences.tsx +152 -0
- package/src/account/PasskeyList.tsx +144 -0
- package/src/account/api.ts +236 -0
- package/src/account/credential-models.ts +153 -0
- package/src/account/dialogs.tsx +822 -0
- package/src/account/index.ts +16 -0
- package/src/account/models.ts +76 -0
- package/src/account/sections/ApiKeysSection.tsx +138 -0
- package/src/admin/core/index.ts +1 -1
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/domains/infrastructure.ts +12 -2
- package/src/admin/domains/security.ts +3 -1
- package/src/admin/edge/BlocklistPage.tsx +110 -0
- package/src/admin/edge/UpstreamsPage.tsx +113 -0
- package/src/admin/edge/VhostDetail.tsx +166 -0
- package/src/admin/edge/VhostWizard.tsx +273 -0
- package/src/admin/edge/VhostsPage.tsx +47 -0
- package/src/admin/edge/api.ts +56 -0
- package/src/admin/edge/index.ts +15 -0
- package/src/admin/edge/models.ts +395 -0
- package/src/admin/edge/permissions.ts +13 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
- package/src/admin/identity/users/models.ts +16 -128
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
- package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
- package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
- package/src/admin/identity/users/sections/actions.tsx +10 -103
- package/src/admin/index.ts +1 -0
- package/src/admin/phonehub/api.ts +5 -3
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/registry.ts +2 -1
- package/src/admin/storage/MediaRenderingPage.tsx +258 -0
- package/src/admin/storage/file-renditions.ts +12 -0
- package/src/admin/storage/index.ts +2 -0
- package/src/admin/storage/rendition-settings.ts +264 -0
- package/src/charts/exportChart.ts +1 -1
- package/src/client/action-result.ts +23 -61
- package/src/client/active-group.ts +9 -3
- package/src/client/auth.ts +207 -6
- package/src/client/client.ts +49 -10
- package/src/client/errors.ts +57 -2
- package/src/client/group-context.ts +2 -0
- package/src/client/group.tsx +112 -20
- package/src/client/hooks.ts +7 -3
- package/src/client/mock.ts +1429 -20
- package/src/client/model.ts +15 -3
- package/src/client/renditions-mock.ts +212 -0
- package/src/client/scoped.ts +19 -5
- package/src/styles/components/account.css +304 -0
- package/src/styles/components/admin-assistant.css +28 -0
- package/src/styles/components/admin-bouncer.css +53 -0
- package/src/styles/components/admin-cloudwatch.css +23 -0
- package/src/styles/components/admin-dashboard.css +16 -0
- package/src/styles/components/admin-devices.css +260 -0
- package/src/styles/components/admin-dns-certificates.css +28 -0
- package/src/styles/components/admin-dns-registrar.css +1 -0
- package/src/styles/components/admin-dns.css +43 -0
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-incidents.css +14 -0
- package/src/styles/components/admin-jobs.css +237 -0
- package/src/styles/components/admin-media-rendering.css +38 -0
- package/src/styles/components/admin-messaging.css +3 -0
- package/src/styles/components/admin-monitoring.css +152 -0
- package/src/styles/components/admin-network.css +406 -0
- package/src/styles/components/admin-phonehub.css +61 -0
- package/src/styles/components/admin-push.css +30 -0
- package/src/styles/components/admin-rules.css +23 -0
- package/src/styles/components/admin-security.css +37 -0
- package/src/styles/components/admin-shortlinks.css +8 -0
- package/src/styles/components/admin-signin.css +64 -0
- package/src/styles/components/admin-storage.css +4 -0
- package/src/styles/components/auth.css +162 -0
- package/src/styles/components/calendar.css +228 -0
- package/src/styles/components/charts-c2.css +249 -0
- package/src/styles/components/collection-multiselect.css +114 -0
- package/src/styles/components/collection-select.css +63 -0
- package/src/styles/components/combobox.css +42 -0
- package/src/styles/components/dataview.css +139 -0
- package/src/styles/components/datepicker.css +106 -0
- package/src/styles/components/daterange.css +119 -0
- package/src/styles/components/datetimepicker.css +230 -0
- package/src/styles/components/detail-primitives.css +246 -0
- package/src/styles/components/detailview.css +69 -0
- package/src/styles/components/drawer.css +111 -0
- package/src/styles/components/file-upload.css +112 -0
- package/src/styles/components/form-wizard.css +11 -0
- package/src/styles/components/formview.css +74 -0
- package/src/styles/components/group-admin.css +302 -0
- package/src/styles/components/guardrail.css +29 -0
- package/src/styles/components/idioms.css +84 -0
- package/src/styles/components/image-editor.css +80 -0
- package/src/styles/components/loading.css +200 -0
- package/src/styles/components/location-address.css +32 -0
- package/src/styles/components/markdown.css +192 -0
- package/src/styles/components/multiselect-dropdown.css +92 -0
- package/src/styles/components/popover.css +35 -0
- package/src/styles/components/record-feed.css +161 -0
- package/src/styles/components/right-panel.css +37 -0
- package/src/styles/components/tabs.css +26 -0
- package/src/styles/components/taginput.css +132 -0
- package/src/styles/components/timepicker.css +282 -0
- package/src/styles/components/user-admin.css +189 -0
- package/src/styles/components/user-menu.css +98 -0
- package/src/styles/components/worldmap.css +97 -0
- package/src/styles/core.css +807 -0
- package/src/styles/index.css +69 -0
- package/src/styles/required-tokens.json +32 -0
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +4 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +21 -3
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// portal-mojo/account — the signed-in user's self-service account surface:
|
|
2
|
+
// AccountModal (+ openAccountModal), its six sections, the shared credential
|
|
3
|
+
// models (PasskeyModel / ApiKeyModel / OAuthConnectionModel — re-exported by
|
|
4
|
+
// portal-mojo/admin unchanged), MeSaveModel, and the self-service wire
|
|
5
|
+
// functions. A page bundle bound to django-mojo contracts like an admin
|
|
6
|
+
// domain, but it never imports the admin barrel and runs no setup at import.
|
|
7
|
+
// Its styles ship in portal-mojo/styles.css: account.css, plus user-admin.css
|
|
8
|
+
// and group-admin.css for the reused API-key rows and secret reveal.
|
|
9
|
+
export * from './AccountModal';
|
|
10
|
+
export * from './AccountSections';
|
|
11
|
+
export * from './api';
|
|
12
|
+
export * from './dialogs';
|
|
13
|
+
export * from './models';
|
|
14
|
+
export * from './NotificationPreferences';
|
|
15
|
+
export * from './PasskeyList';
|
|
16
|
+
export * from './sections/ApiKeysSection';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// account/models — the caller-owned credential models shared by the
|
|
2
|
+
// self-service AccountModal and the admin User detail (defined in
|
|
3
|
+
// credential-models.ts, re-exported here and by admin/identity/users/models
|
|
4
|
+
// unchanged) so portal-mojo/account never imports an admin domain. ONE
|
|
5
|
+
// defineModel per endpoint: both surfaces share these cache keys.
|
|
6
|
+
//
|
|
7
|
+
// Plus MeSaveModel — the `/api/user/me` save the account forms autosave
|
|
8
|
+
// through — and the account-only query keys.
|
|
9
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
|
+
import {
|
|
11
|
+
mojoSave, sanitizeMe, useAuthSnapshot, withFreshAuth,
|
|
12
|
+
type Me, type SaveVars,
|
|
13
|
+
} from '../client/runtime';
|
|
14
|
+
import { getNotificationPreferences, getRecoveryCodeStatus, type NotificationPreferencesResponse, type RecoveryCodeStatus } from './api';
|
|
15
|
+
|
|
16
|
+
// ── /api/user/me ──────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* FormView-compatible save for the signed-in user's own row: POSTs ONLY the
|
|
20
|
+
* changed fields to `/api/user/me` (never the `me` row back — the server
|
|
21
|
+
* 403s email/username/is_superuser/permissions even unchanged), sanitizes
|
|
22
|
+
* the answer with sanitizeMe, writes `['me', uid]` and refreshes every
|
|
23
|
+
* `/api/user` cache (UserModel.useSave never touches `me`, and vice versa).
|
|
24
|
+
* The `id` in SaveVars is ignored: the endpoint is always the caller.
|
|
25
|
+
*/
|
|
26
|
+
export const MeSaveModel = {
|
|
27
|
+
useSave: () => {
|
|
28
|
+
const qc = useQueryClient();
|
|
29
|
+
const { uid } = useAuthSnapshot();
|
|
30
|
+
return useMutation<Me, Error, SaveVars>({
|
|
31
|
+
mutationFn: async ({ changes }: SaveVars) => sanitizeMe(await withFreshAuth(() => mojoSave<Me>('/api/user/me', null, changes))),
|
|
32
|
+
onSuccess: (row) => {
|
|
33
|
+
qc.setQueryData(['me', uid], row);
|
|
34
|
+
void qc.invalidateQueries({ queryKey: ['/api/user'] });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Refresh `me` (e.g. after a verify/change flow that the server committed). */
|
|
41
|
+
export function invalidateMe(qc: ReturnType<typeof useQueryClient>): Promise<void> {
|
|
42
|
+
return qc.invalidateQueries({ queryKey: ['me'] });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Credential models (credential-models.ts) ───────────────────────────
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
ApiKeyModel, OAuthConnectionModel, PasskeyModel, useGenerateUserApiKey,
|
|
49
|
+
type ApiKeyRow, type GeneratedKeyReceipt, type GenerateUserApiKeyVariables,
|
|
50
|
+
type OAuthConnectionRow, type PasskeyRow,
|
|
51
|
+
} from './credential-models';
|
|
52
|
+
|
|
53
|
+
// ── Account-only queries ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export const accountKeys = {
|
|
56
|
+
/** `{enrolled, remaining}` ONLY — never the masked hints (4 of 12 hex chars each). */
|
|
57
|
+
recoveryStatus: (uid: number | string | null) => ['/api/account/totp/recovery-codes', 'status', uid] as const,
|
|
58
|
+
/** Shared with the admin User detail's Notifications section. */
|
|
59
|
+
notificationPreferences: (uid: number | string | null) => ['/api/account/notification/preferences', uid] as const,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function useRecoveryCodeStatus(uid: number | null) {
|
|
63
|
+
return useQuery<RecoveryCodeStatus>({
|
|
64
|
+
queryKey: accountKeys.recoveryStatus(uid),
|
|
65
|
+
queryFn: getRecoveryCodeStatus,
|
|
66
|
+
enabled: uid != null,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useNotificationPreferences(uid: number | null, enabled = true) {
|
|
71
|
+
return useQuery<NotificationPreferencesResponse>({
|
|
72
|
+
queryKey: accountKeys.notificationPreferences(uid),
|
|
73
|
+
queryFn: getNotificationPreferences,
|
|
74
|
+
enabled: uid != null && enabled,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// API Keys — UserApiKeysSection port (read in full 2026-08-05): the user's
|
|
2
|
+
// key list + caller-only "Generate key" reveal + revoke.
|
|
3
|
+
//
|
|
4
|
+
// Wire (live-measured):
|
|
5
|
+
// · list — GET /api/account/api_keys?user=<id> (label/allowed_ips/expires/
|
|
6
|
+
// is_active/last_used/created; the owner FK filters but never serializes)
|
|
7
|
+
// · generate — caller-only POST /api/auth/generate_api_key. Another user's
|
|
8
|
+
// detail renders no Generate action because no admin-targetable route exists.
|
|
9
|
+
// · revoke — the `revoke` POST_SAVE_ACTION (POST <id> {revoke:{}} → its
|
|
10
|
+
// own {status:true} payload). Deviation from web-mojo's DELETE: the live
|
|
11
|
+
// model is CAN_DELETE=false (DELETE answers 403, measured) — the action
|
|
12
|
+
// IS the kill switch.
|
|
13
|
+
//
|
|
14
|
+
// Shared by the admin User detail (re-exported from
|
|
15
|
+
// admin/identity/users/sections/ApiKeysSection) and the self-service
|
|
16
|
+
// AccountModal (user = me, isSelf). Styled by the app's user-admin.css.
|
|
17
|
+
import { Badge, Eyebrow, fmt, formModal, modal, showSecretDialog, toast } from '../../ui';
|
|
18
|
+
import { ApiKeyModel, useGenerateUserApiKey } from '../credential-models';
|
|
19
|
+
|
|
20
|
+
/** The identity fields the section names a key's owner by. */
|
|
21
|
+
export interface ApiKeysOwner {
|
|
22
|
+
id: number;
|
|
23
|
+
display_name?: string | null;
|
|
24
|
+
email?: string | null;
|
|
25
|
+
username?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ApiKeysSectionProps {
|
|
29
|
+
user: ApiKeysOwner;
|
|
30
|
+
/** Offer Revoke on active keys. */
|
|
31
|
+
canManage: boolean;
|
|
32
|
+
/** Offer Generate — the generate route is caller-only. */
|
|
33
|
+
isSelf: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ApiKeysSection({ user, canManage, isSelf }: ApiKeysSectionProps) {
|
|
37
|
+
const { data, isPending } = ApiKeyModel.useList({ user: user.id, size: 25, sort: '-id' });
|
|
38
|
+
const revoke = ApiKeyModel.useAction('revoke');
|
|
39
|
+
const generateKey = useGenerateUserApiKey();
|
|
40
|
+
const rows = data?.rows ?? [];
|
|
41
|
+
|
|
42
|
+
const generate = async () => {
|
|
43
|
+
const form = ApiKeyModel.forms.generate!;
|
|
44
|
+
const dataIn = await formModal({
|
|
45
|
+
...form,
|
|
46
|
+
title: isSelf ? 'Generate API key' : `Generate API key for ${user.display_name || user.email || user.username}`,
|
|
47
|
+
});
|
|
48
|
+
if (!dataIn) return;
|
|
49
|
+
const body: Record<string, unknown> = {
|
|
50
|
+
label: dataIn.label,
|
|
51
|
+
expire_days: parseInt(String(dataIn.expire_days || '90'), 10),
|
|
52
|
+
};
|
|
53
|
+
const ips = String(dataIn.allowed_ips ?? '').trim();
|
|
54
|
+
if (ips) body.allowed_ips = ips.split(',').map((s) => s.trim()).filter(Boolean);
|
|
55
|
+
try {
|
|
56
|
+
await generateKey.mutateAsync({
|
|
57
|
+
changes: body,
|
|
58
|
+
onToken: async (token) => {
|
|
59
|
+
await showSecretDialog({
|
|
60
|
+
title: 'API key created',
|
|
61
|
+
intro: <><b>{String(dataIn.label || 'Unlabeled key')}</b> is ready.</>,
|
|
62
|
+
warning: 'Save this token now. It will not be shown again.',
|
|
63
|
+
secret: token,
|
|
64
|
+
ariaLabel: 'Generated API key',
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
toast.success('API key generated');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
toast.error(err instanceof Error ? err.message : 'Failed to generate API key');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const revokeKey = async (id: number, label: string) => {
|
|
75
|
+
const ok = await modal.confirm({
|
|
76
|
+
title: 'Revoke API key',
|
|
77
|
+
message: <>Revoke <b>{label || 'this API key'}</b>? Any applications using it will lose access immediately.</>,
|
|
78
|
+
confirmText: 'Revoke',
|
|
79
|
+
danger: true,
|
|
80
|
+
});
|
|
81
|
+
if (!ok) return;
|
|
82
|
+
try {
|
|
83
|
+
await revoke.mutateAsync({ id });
|
|
84
|
+
toast.success('API key revoked');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
toast.error(err instanceof Error ? err.message : 'Failed to revoke API key');
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<Eyebrow>
|
|
95
|
+
API keys
|
|
96
|
+
{isSelf && (
|
|
97
|
+
<button className="btn btn-primary btn-compact us-eyebrow-action" onClick={() => void generate()}>
|
|
98
|
+
<i className="bi bi-plus-lg" /> Generate key
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
</Eyebrow>
|
|
102
|
+
|
|
103
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
104
|
+
{!isPending && rows.length === 0 && (
|
|
105
|
+
<div className="us-empty">
|
|
106
|
+
<i className="bi bi-key" />
|
|
107
|
+
<div>{isSelf ? 'You have no API keys.' : 'No API keys for this user.'}</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
{rows.map((k) => {
|
|
111
|
+
const expired = k.expires != null && k.expires < nowSec;
|
|
112
|
+
return (
|
|
113
|
+
<div key={k.id} className="us-key-row">
|
|
114
|
+
<div className="us-row-icon"><i className="bi bi-key" /></div>
|
|
115
|
+
<div className="us-row-info">
|
|
116
|
+
<div className="us-row-title">{k.label || 'Unlabeled key'}</div>
|
|
117
|
+
<div className="us-row-meta">
|
|
118
|
+
Created {fmt.date(k.created)} · Expires {fmt.date(k.expires, 'never')} · Last used {fmt.relative(k.last_used, 'never')}
|
|
119
|
+
{' · '}
|
|
120
|
+
{k.allowed_ips.length > 0 ? <>IPs <code>{k.allowed_ips.join(', ')}</code></> : 'Any IP'}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="us-row-actions">
|
|
124
|
+
<Badge tone={!k.is_active ? 'muted' : expired ? 'warning' : 'success'}>
|
|
125
|
+
{!k.is_active ? 'Revoked' : expired ? 'Expired' : 'Active'}
|
|
126
|
+
</Badge>
|
|
127
|
+
{canManage && k.is_active && (
|
|
128
|
+
<button className="btn btn-compact us-danger" onClick={() => void revokeKey(k.id, k.label)}>
|
|
129
|
+
Revoke
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
package/src/admin/core/index.ts
CHANGED
|
@@ -146,7 +146,7 @@ function guardedElement(section: AdminSection, route: AdminRoute, sections: read
|
|
|
146
146
|
export function AdminGlobalScope({ children }: { children: ReactNode }) {
|
|
147
147
|
const outer = useContext(GroupContext);
|
|
148
148
|
if (!outer) return children;
|
|
149
|
-
return createElement(GroupContext.Provider, { value: { ...outer, group: null, member: null, loading: false }, children });
|
|
149
|
+
return createElement(GroupContext.Provider, { value: { ...outer, group: null, member: null, loading: false, accessDenied: false }, children });
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function routeVisible(section: AdminSection, route: AdminRoute, me: ReturnType<typeof useMe>['data']): boolean {
|
|
@@ -1,77 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
async function copyText(value: string): Promise<boolean> {
|
|
5
|
-
try {
|
|
6
|
-
if (navigator.clipboard?.writeText) {
|
|
7
|
-
await navigator.clipboard.writeText(value);
|
|
8
|
-
return true;
|
|
9
|
-
}
|
|
10
|
-
} catch {
|
|
11
|
-
// Fall through for insecure contexts.
|
|
12
|
-
}
|
|
13
|
-
try {
|
|
14
|
-
const input = document.createElement('textarea');
|
|
15
|
-
input.value = value;
|
|
16
|
-
input.style.position = 'fixed';
|
|
17
|
-
input.style.opacity = '0';
|
|
18
|
-
document.body.appendChild(input);
|
|
19
|
-
input.select();
|
|
20
|
-
const copied = document.execCommand('copy');
|
|
21
|
-
input.remove();
|
|
22
|
-
return copied;
|
|
23
|
-
} catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function SecretBox({ secret, ariaLabel }: { secret: string; ariaLabel: string }) {
|
|
29
|
-
const [copied, setCopied] = useState(false);
|
|
30
|
-
return (
|
|
31
|
-
<div className="ga-secret-box">
|
|
32
|
-
<code className="ga-secret-value" aria-label={ariaLabel}>{secret}</code>
|
|
33
|
-
<button
|
|
34
|
-
type="button"
|
|
35
|
-
className="btn btn-compact"
|
|
36
|
-
title="Copy to clipboard"
|
|
37
|
-
aria-label="Copy to clipboard"
|
|
38
|
-
onClick={() => {
|
|
39
|
-
void copyText(secret).then((ok) => {
|
|
40
|
-
if (!ok) return;
|
|
41
|
-
setCopied(true);
|
|
42
|
-
setTimeout(() => setCopied(false), 1600);
|
|
43
|
-
});
|
|
44
|
-
}}
|
|
45
|
-
>
|
|
46
|
-
<i className={`bi ${copied ? 'bi-check-lg' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
|
|
47
|
-
</button>
|
|
48
|
-
</div>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface SecretDialogOptions {
|
|
53
|
-
title: string;
|
|
54
|
-
warning: ReactNode;
|
|
55
|
-
intro?: ReactNode;
|
|
56
|
-
secret: string;
|
|
57
|
-
ariaLabel: string;
|
|
58
|
-
footer?: ReactNode;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function showSecretDialog(options: SecretDialogOptions): Promise<unknown> {
|
|
62
|
-
return modal.open((close) => (
|
|
63
|
-
<div className="modal-pad">
|
|
64
|
-
<h2 className="modal-title">{options.title}</h2>
|
|
65
|
-
{options.intro && <div className="ga-secret-intro">{options.intro}</div>}
|
|
66
|
-
<div className="ga-secret-warning" role="alert">
|
|
67
|
-
<i className="bi bi-exclamation-triangle-fill" />
|
|
68
|
-
<span>{options.warning}</span>
|
|
69
|
-
</div>
|
|
70
|
-
<SecretBox secret={options.secret} ariaLabel={options.ariaLabel} />
|
|
71
|
-
{options.footer}
|
|
72
|
-
<div className="modal-actions">
|
|
73
|
-
<button className="btn" onClick={() => close(null)}>Close</button>
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
), { size: 'lg' });
|
|
77
|
-
}
|
|
1
|
+
// Moved to ui/secret-dialog.tsx (shared with portal-mojo/account); the admin
|
|
2
|
+
// credentials surface keeps its import path and export names unchanged.
|
|
3
|
+
export { SecretBox, showSecretDialog, type SecretDialogOptions } from '../../ui/secret-dialog';
|
package/src/admin/dns/models.ts
CHANGED
|
@@ -355,7 +355,7 @@ export function normalizeCredentialListParams(params: Params): Params {
|
|
|
355
355
|
export function normalizeDomainListParams(params: Params): Params {
|
|
356
356
|
return normalizeListParams(
|
|
357
357
|
params,
|
|
358
|
-
new Set(['group', 'provider', 'status']),
|
|
358
|
+
new Set(['group', 'group__isnull', 'provider', 'status']),
|
|
359
359
|
new Set(['name', 'provider', 'status', 'expires', 'created']),
|
|
360
360
|
'list',
|
|
361
361
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AdminSection } from '../core';
|
|
2
2
|
import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS } from '../dns/models';
|
|
3
3
|
import { BUCKET_MANAGE_PERMS, STORAGE_VIEW_PERMS } from '../storage/permissions';
|
|
4
|
+
import { SETTINGS_PERMISSIONS } from '../settings/model';
|
|
4
5
|
|
|
5
6
|
export const DNS_ADMIN_SECTION: AdminSection = {
|
|
6
7
|
id: 'dns', title: 'DNS', icon: 'bi-globe2', navigationGroup: 'infrastructure', basePath: 'dns', permissions: DNS_VIEW_PERMISSIONS,
|
|
@@ -14,11 +15,20 @@ export const DNS_ADMIN_SECTION: AdminSection = {
|
|
|
14
15
|
],
|
|
15
16
|
};
|
|
16
17
|
export const STORAGE_ADMIN_SECTION: AdminSection = {
|
|
17
|
-
id: 'storage', basePath: 'storage', title: 'Storage', icon: 'bi-hdd-stack', navigationGroup: 'infrastructure', permissions: ['sys.view_fileman', 'sys.manage_files', 'sys.manage_aws', 'sys.files'],
|
|
18
|
+
id: 'storage', basePath: 'storage', title: 'Storage', icon: 'bi-hdd-stack', navigationGroup: 'infrastructure', permissions: ['sys.view_fileman', 'sys.manage_files', 'sys.manage_aws', 'sys.files', ...SETTINGS_PERMISSIONS],
|
|
18
19
|
routes: [
|
|
19
20
|
{ path: 'buckets', label: 'Buckets', loadComponent: () => import('../storage/BucketsPage').then(({ BucketsPage }) => ({ default: BucketsPage })), permissions: BUCKET_MANAGE_PERMS },
|
|
20
21
|
{ path: 'backends', label: 'Backends', loadComponent: () => import('../storage/BackendsPage').then(({ BackendsPage }) => ({ default: BackendsPage })), permissions: STORAGE_VIEW_PERMS },
|
|
21
22
|
{ path: 'files', label: 'Files', loadComponent: () => import('../storage/FilesPage').then(({ FilesPage }) => ({ default: FilesPage })), permissions: STORAGE_VIEW_PERMS },
|
|
23
|
+
// Media rendering edits three Setting rows, so it carries the Settings page's gate, not the storage one.
|
|
24
|
+
{ path: 'rendering', label: 'Media rendering', loadComponent: () => import('../storage/MediaRenderingPage').then(({ MediaRenderingPage }) => ({ default: MediaRenderingPage })), permissions: SETTINGS_PERMISSIONS },
|
|
22
25
|
],
|
|
23
26
|
};
|
|
24
|
-
export const
|
|
27
|
+
export const EDGE_ADMIN_SECTION: AdminSection = {
|
|
28
|
+
id: 'edge', basePath: 'edge', title: 'Edge', icon: 'bi-hdd-network', navigationGroup: 'infrastructure', permissions: DNS_VIEW_PERMISSIONS,
|
|
29
|
+
routes: [
|
|
30
|
+
{ path: 'vhosts', label: 'Vhosts', loadComponent: () => import('../edge/VhostsPage').then(({ VhostsPage }) => ({ default: VhostsPage })), permissions: DNS_VIEW_PERMISSIONS },
|
|
31
|
+
{ path: 'upstreams', label: 'Upstreams', loadComponent: () => import('../edge/UpstreamsPage').then(({ UpstreamsPage }) => ({ default: UpstreamsPage })), permissions: DNS_VIEW_PERMISSIONS },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
export const INFRASTRUCTURE_ADMIN_SECTIONS = [DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION, EDGE_ADMIN_SECTION] as const;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AdminRoute, AdminSection } from '../core';
|
|
2
2
|
import { BOUNCER_VIEW_PERMS } from '../bouncer/models';
|
|
3
|
+
import { EDGE_BLOCKLIST_VIEW_PERMS } from '../edge/permissions';
|
|
3
4
|
import { LOGS_ADMIN_PERMISSIONS } from '../monitoring/models';
|
|
4
5
|
import { GEOIP_VIEW_PERMS } from '../security/geoip/models';
|
|
5
6
|
import { LOGIN_EVENT_VIEW_PERMS, USER_DEVICE_VIEW_PERMS } from '../security/devices/models';
|
|
@@ -43,12 +44,13 @@ export const GEOIP_ADMIN_SECTION: AdminSection = {
|
|
|
43
44
|
};
|
|
44
45
|
export const NETWORK_SECURITY_ADMIN_SECTION: AdminSection = {
|
|
45
46
|
id: 'network-security', basePath: 'security/network', title: 'Network Security', icon: 'bi-hdd-network', navigationGroup: 'security',
|
|
46
|
-
permissions: [...new Set([...GEOIP_VIEW_PERMS, ...LOGS_ADMIN_PERMISSIONS, ...IPSET_VIEW_PERMS, ...GEOFENCE_VIEW_PERMS])],
|
|
47
|
+
permissions: [...new Set([...GEOIP_VIEW_PERMS, ...LOGS_ADMIN_PERMISSIONS, ...IPSET_VIEW_PERMS, ...GEOFENCE_VIEW_PERMS, ...EDGE_BLOCKLIST_VIEW_PERMS])],
|
|
47
48
|
routes: [
|
|
48
49
|
{ path: 'blocked-ips', label: 'Blocked IPs', loadComponent: () => import('../network/BlockedIPsPage').then(({ BlockedIPsPage }) => ({ default: BlockedIPsPage })), permissions: GEOIP_VIEW_PERMS },
|
|
49
50
|
{ path: 'firewall-log', label: 'Firewall Log', loadComponent: () => import('../network/FirewallLogPage').then(({ FirewallLogPage }) => ({ default: FirewallLogPage })), permissions: LOGS_ADMIN_PERMISSIONS },
|
|
50
51
|
{ path: 'ip-sets', label: 'IP Sets', loadComponent: () => import('../network/IPSetsPage').then(({ IPSetsPage }) => ({ default: IPSetsPage })), permissions: IPSET_VIEW_PERMS },
|
|
51
52
|
{ path: 'geofencing', label: 'Geofencing', loadComponent: () => import('../network/geofence/GeofencingPage').then(({ GeofencingPage }) => ({ default: GeofencingPage })), permissions: GEOFENCE_VIEW_PERMS },
|
|
53
|
+
{ path: 'edge-blocklist', label: 'Edge Blocklist', loadComponent: () => import('../edge/BlocklistPage').then(({ BlocklistPage }) => ({ default: BlocklistPage })), permissions: EDGE_BLOCKLIST_VIEW_PERMS },
|
|
52
54
|
],
|
|
53
55
|
};
|
|
54
56
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { mojoDelete, mojoSave, useCan, withFreshAuth } from '../../client/runtime';
|
|
4
|
+
import { ArmedButton, Badge, ModelTable, fmt, modal, toast, type Column, type FilterDef, type Tone } from '../../ui';
|
|
5
|
+
import { EdgeBlocklistModel, sanitizeEdgeBlocklistRow, validateBlocklistValue, type EdgeBlocklistRow } from './models';
|
|
6
|
+
import { EDGE_BLOCKLIST_MANAGE_PERMS } from './permissions';
|
|
7
|
+
|
|
8
|
+
export const BLOCKLIST_MODES: readonly { value: string; label: string; tone: Tone; help: string }[] = [
|
|
9
|
+
{ value: 'log', label: 'Log', tone: 'info', help: 'Matches are recorded and still served. Start here.' },
|
|
10
|
+
{ value: 'enforce', label: 'Enforce', tone: 'danger', help: 'Matches are refused at the edge.' },
|
|
11
|
+
{ value: 'allow', label: 'Allow', tone: 'success', help: 'An exemption: a match is never blocked by another entry.' },
|
|
12
|
+
{ value: 'off', label: 'Off', tone: 'muted', help: 'Kept on file, does nothing.' },
|
|
13
|
+
];
|
|
14
|
+
const KIND_LABELS: Record<string, string> = { ip: 'IP or network', ua: 'User agent' };
|
|
15
|
+
|
|
16
|
+
function modeMeta(mode: string) {
|
|
17
|
+
return BLOCKLIST_MODES.find((entry) => entry.value === mode) ?? { value: mode, label: mode, tone: 'muted' as Tone, help: '' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function BlocklistEditor({ row, pending, close }: { row: EdgeBlocklistRow | null; pending: { current: boolean }; close: (result: 'saved' | 'deleted' | null) => void }) {
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const [kind, setKind] = useState(row?.kind ?? 'ip');
|
|
23
|
+
const [value, setValue] = useState(row?.value ?? '');
|
|
24
|
+
const [mode, setMode] = useState(row?.mode ?? 'log');
|
|
25
|
+
const [note, setNote] = useState(row?.note ?? '');
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [error, setError] = useState('');
|
|
28
|
+
|
|
29
|
+
const submit = async (event: React.FormEvent) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
if (busy) return;
|
|
32
|
+
const refusal = validateBlocklistValue(kind, value.trim());
|
|
33
|
+
if (refusal) { setError(refusal); return; }
|
|
34
|
+
setBusy(true); setError(''); pending.current = true;
|
|
35
|
+
try {
|
|
36
|
+
// The response row is authoritative: an address comes back as its network.
|
|
37
|
+
const saved = sanitizeEdgeBlocklistRow(await withFreshAuth(() => mojoSave<EdgeBlocklistRow>(EdgeBlocklistModel.endpoint, row?.id ?? null, { kind, value: value.trim(), mode, note: note.trim() })));
|
|
38
|
+
await EdgeBlocklistModel.invalidate(queryClient);
|
|
39
|
+
toast.success(saved.value === value.trim() ? `${saved.value} saved as ${modeMeta(saved.mode).label.toLowerCase()}.` : `Saved as ${saved.value} (${modeMeta(saved.mode).label.toLowerCase()}): the address was stored as its network.`);
|
|
40
|
+
close('saved');
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
setError(cause instanceof Error ? cause.message : 'The entry was not saved');
|
|
43
|
+
} finally {
|
|
44
|
+
pending.current = false; setBusy(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const remove = async () => {
|
|
48
|
+
if (!row || busy) return;
|
|
49
|
+
// One pending state for save and delete: neither can start over the other.
|
|
50
|
+
setBusy(true); setError(''); pending.current = true;
|
|
51
|
+
try {
|
|
52
|
+
await withFreshAuth(() => mojoDelete(EdgeBlocklistModel.endpoint, row.id));
|
|
53
|
+
await EdgeBlocklistModel.invalidate(queryClient);
|
|
54
|
+
toast.success(`${row.value} removed from the blocklist.`);
|
|
55
|
+
close('deleted');
|
|
56
|
+
} catch (cause) {
|
|
57
|
+
setError(cause instanceof Error ? cause.message : 'The entry was not deleted');
|
|
58
|
+
} finally {
|
|
59
|
+
pending.current = false; setBusy(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return <div className="modal-pad edge-blocklist-editor"><h2 className="modal-title">{row ? 'Edit blocklist entry' : 'New blocklist entry'}</h2>
|
|
64
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
65
|
+
<form onSubmit={(event) => void submit(event)}><div className="form-grid">
|
|
66
|
+
<label className="field"><span className="field-label">Matches on</span><select className="input" value={kind} disabled={busy} onChange={(event) => setKind(event.target.value)}><option value="ip">IP address or network</option><option value="ua">User agent pattern</option></select></label>
|
|
67
|
+
<label className="field"><span className="field-label">Value <em>*</em></span><input className="input mono" value={value} disabled={busy} onChange={(event) => setValue(event.target.value)} required autoComplete="off" placeholder={kind === 'ip' ? '203.0.113.0/24' : 'sqlmap'} /><span className="field-help">{kind === 'ip' ? 'One address or a CIDR network. An address inside a network is stored as the network.' : 'A pattern matched anywhere in the user agent, ignoring case. No spaces or quotes.'}</span></label>
|
|
68
|
+
<div className="field edge-mode-field"><span className="field-label">Mode</span>
|
|
69
|
+
<div className="edge-mode-control" role="radiogroup" aria-label="Mode">{BLOCKLIST_MODES.map((entry) => <label key={entry.value} className={`edge-mode-option${mode === entry.value ? ' is-selected' : ''}`}><input type="radio" name="edge-blocklist-mode" checked={mode === entry.value} disabled={busy} onChange={() => setMode(entry.value)} /> {entry.label}</label>)}</div>
|
|
70
|
+
<span className="field-help">{modeMeta(mode).help} Log first, watch the edge watch log for this entry, then switch to Enforce. A change reaches the whole fleet in about 10 minutes.</span>
|
|
71
|
+
</div>
|
|
72
|
+
<label className="field"><span className="field-label">Note</span><input className="input" value={note} disabled={busy} maxLength={255} onChange={(event) => setNote(event.target.value)} placeholder="Why this entry exists" /></label>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="modal-actions">
|
|
75
|
+
{row && <ArmedButton icon="bi-trash" label="Delete" armedLabel="Click again to remove this entry from every node" disabled={busy} onConfirm={remove} />}
|
|
76
|
+
<button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
|
|
77
|
+
<button className="btn btn-primary" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
|
78
|
+
</div></form>
|
|
79
|
+
</div>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function openBlocklistEditor(row: EdgeBlocklistRow | null = null): Promise<'saved' | 'deleted' | null> {
|
|
83
|
+
// Escape and the backdrop are refused while a save or delete is in flight.
|
|
84
|
+
const pending = { current: false };
|
|
85
|
+
return modal.open<'saved' | 'deleted' | null>((close) => <BlocklistEditor row={row} pending={pending} close={close} />, { canDismiss: () => !pending.current }).then((value) => value ?? null);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const COLUMNS: Column<EdgeBlocklistRow>[] = [
|
|
89
|
+
{ key: 'kind', label: 'Matches on', sortable: true, render: (row) => KIND_LABELS[row.kind] ?? row.kind },
|
|
90
|
+
{ key: 'value', label: 'Value', sortable: true, hideable: false, render: (row) => <code>{row.value}</code> },
|
|
91
|
+
{ key: 'mode', label: 'Mode', sortable: true, render: (row) => <Badge tone={modeMeta(row.mode).tone}>{modeMeta(row.mode).label}</Badge> },
|
|
92
|
+
{ key: 'note', label: 'Note', sortable: false, render: (row) => row.note ? <span title={row.note}>{fmt.truncate(row.note, 60)}</span> : <span className="dim">—</span> },
|
|
93
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const FILTERS: FilterDef[] = [
|
|
97
|
+
{ key: 'kind', label: 'Matches on', type: 'select', options: [{ value: 'ip', label: 'IP or network' }, { value: 'ua', label: 'User agent' }] },
|
|
98
|
+
{ key: 'mode', label: 'Mode', type: 'select', options: BLOCKLIST_MODES.map((entry) => ({ value: entry.value, label: entry.label })) },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
export function BlocklistPage() {
|
|
102
|
+
const canManage = useCan(EDGE_BLOCKLIST_MANAGE_PERMS).can;
|
|
103
|
+
return <ModelTable<EdgeBlocklistRow>
|
|
104
|
+
model={EdgeBlocklistModel} eyebrow="Security · Network" title="Edge Blocklist"
|
|
105
|
+
searchable searchPlaceholder="Search value or note"
|
|
106
|
+
columns={COLUMNS} filters={FILTERS} defaultSort="kind"
|
|
107
|
+
columnChooser persistState persistKey="admin:edge:blocklist"
|
|
108
|
+
{...(canManage ? { addLabel: 'New entry', onAdd: () => void openBlocklistEditor(), onRowClick: (row: EdgeBlocklistRow) => void openBlocklistEditor(row) } : {})}
|
|
109
|
+
/>;
|
|
110
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { ArmedButton, Badge, CollectionSelect, ModelTable, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
|
|
5
|
+
import { declareUpstream, retireUpstream, setUpstreamEnabled, type DeclareUpstreamInput } from './api';
|
|
6
|
+
import { EDGE_MANAGE_PERMS, EdgeUpstreamModel, upstreamTarget, type EdgeUpstreamRow } from './models';
|
|
7
|
+
|
|
8
|
+
const RETIRE_CONSEQUENCE = 'Vhosts and routes that reach this upstream stop being served. They are not repointed. The upstream stays listed, disabled, so its history is kept.';
|
|
9
|
+
|
|
10
|
+
function DeclareUpstreamDialog({ pending, close }: { pending: { current: boolean }; close: (row: EdgeUpstreamRow | null) => void }) {
|
|
11
|
+
const [name, setName] = useState('');
|
|
12
|
+
const [kind, setKind] = useState<'http' | 'unix'>('http');
|
|
13
|
+
const [host, setHost] = useState('');
|
|
14
|
+
const [port, setPort] = useState('');
|
|
15
|
+
const [socketPath, setSocketPath] = useState('');
|
|
16
|
+
const [group, setGroup] = useState<number | null>(null);
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
const [error, setError] = useState('');
|
|
19
|
+
|
|
20
|
+
const submit = async (event: React.FormEvent) => {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
if (busy) return;
|
|
23
|
+
if (kind === 'http' && !/^\d+$/.test(port)) { setError('Enter the port as a number.'); return; }
|
|
24
|
+
// Only the chosen kind's destination is sent; the other branch's values vanish.
|
|
25
|
+
const input: DeclareUpstreamInput = kind === 'http'
|
|
26
|
+
? { name: name.trim(), kind, host: host.trim().toLowerCase(), port: Number(port), group }
|
|
27
|
+
: { name: name.trim(), kind, socket_path: socketPath.trim(), group };
|
|
28
|
+
setBusy(true); setError(''); pending.current = true;
|
|
29
|
+
try {
|
|
30
|
+
close(await declareUpstream(input));
|
|
31
|
+
} catch (cause) {
|
|
32
|
+
setError(cause instanceof Error ? cause.message : 'The upstream was not declared');
|
|
33
|
+
} finally {
|
|
34
|
+
pending.current = false; setBusy(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return <div className="modal-pad edge-upstream-dialog"><h2 className="modal-title">Declare upstream</h2>
|
|
39
|
+
<p className="dim">An upstream is a destination vhosts may proxy to. Only platform administrators can declare one, and its destination cannot be changed afterwards.</p>
|
|
40
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
41
|
+
<form onSubmit={(event) => void submit(event)}><div className="form-grid">
|
|
42
|
+
<label className="field"><span className="field-label">Name <em>*</em></span><input className="input" value={name} disabled={busy} onChange={(event) => setName(event.target.value)} required autoComplete="off" placeholder="orders-api" /><span className="field-help">Lowercase letters, digits, '-' or '_'.</span></label>
|
|
43
|
+
<label className="field"><span className="field-label">Reached over</span><select className="input" value={kind} disabled={busy} onChange={(event) => setKind(event.target.value === 'unix' ? 'unix' : 'http')}><option value="http">HTTP: host and port</option><option value="unix">Unix socket</option></select></label>
|
|
44
|
+
{kind === 'http' && <label className="field"><span className="field-label">Host <em>*</em></span><input className="input" value={host} disabled={busy} onChange={(event) => setHost(event.target.value)} required autoComplete="off" placeholder="10.0.4.12" /></label>}
|
|
45
|
+
{kind === 'http' && <label className="field"><span className="field-label">Port <em>*</em></span><input className="input" inputMode="numeric" value={port} disabled={busy} onChange={(event) => setPort(event.target.value.trim())} required placeholder="8080" /></label>}
|
|
46
|
+
{kind === 'unix' && <label className="field"><span className="field-label">Socket path <em>*</em></span><input className="input" value={socketPath} disabled={busy} onChange={(event) => setSocketPath(event.target.value)} required autoComplete="off" placeholder="/run/mojo/app.sock" /><span className="field-help">Must sit under the deployment's socket directory.</span></label>}
|
|
47
|
+
<CollectionSelect<{ id: number; name: string }> endpoint="/api/group" value={group} onChange={(id) => setGroup(id == null ? null : Number(id))} label="Owning group" disabled={busy} placeholder="Shared with every group" help="Leave empty for a shared upstream. Choose a group to keep it to that group's vhosts." />
|
|
48
|
+
</div>
|
|
49
|
+
<div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy}>{busy ? 'Declaring…' : 'Declare upstream'}</button></div></form>
|
|
50
|
+
</div>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function openDeclareUpstream(): Promise<EdgeUpstreamRow | null> {
|
|
54
|
+
// Escape and the backdrop are refused while the declare is in flight.
|
|
55
|
+
const pending = { current: false };
|
|
56
|
+
return modal.open<EdgeUpstreamRow | null>((close) => <DeclareUpstreamDialog pending={pending} close={close} />, { canDismiss: () => !pending.current }).then((value) => value ?? null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const FILTERS: FilterDef[] = [
|
|
60
|
+
{ key: 'is_enabled', label: 'State', type: 'boolean', trueLabel: 'Enabled', falseLabel: 'Disabled' },
|
|
61
|
+
{ key: 'kind', label: 'Reached over', type: 'select', options: [{ value: 'http', label: 'HTTP' }, { value: 'unix', label: 'Unix socket' }] },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export function UpstreamsPage() {
|
|
65
|
+
const queryClient = useQueryClient();
|
|
66
|
+
const { can: canManage, me } = useCan(EDGE_MANAGE_PERMS);
|
|
67
|
+
// Declare and retire are `require_platform_admin` on the server.
|
|
68
|
+
const superuser = me?.is_superuser === true;
|
|
69
|
+
|
|
70
|
+
const run = async (work: () => Promise<EdgeUpstreamRow>, done: (row: EdgeUpstreamRow) => string) => {
|
|
71
|
+
try {
|
|
72
|
+
toast.success(done(await work()));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
toast.error(error instanceof Error ? error.message : 'The change was refused');
|
|
75
|
+
} finally {
|
|
76
|
+
await EdgeUpstreamModel.invalidate(queryClient);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const retire = async (row: EdgeUpstreamRow) => {
|
|
80
|
+
const ok = await modal.confirm({ title: `Retire ${row.name}?`, message: RETIRE_CONSEQUENCE, confirmText: 'Retire upstream', danger: true });
|
|
81
|
+
if (ok) await run(() => retireUpstream(row.id), (saved) => `${saved.name} retired.`);
|
|
82
|
+
};
|
|
83
|
+
const declare = async () => {
|
|
84
|
+
const row = await openDeclareUpstream();
|
|
85
|
+
if (!row) return;
|
|
86
|
+
await EdgeUpstreamModel.invalidate(queryClient);
|
|
87
|
+
toast.success(`${row.name} declared.`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const columns: Column<EdgeUpstreamRow>[] = [
|
|
91
|
+
{ key: 'name', label: 'Name', sortable: true, hideable: false, render: (row) => <code>{row.name}</code> },
|
|
92
|
+
{ key: 'kind', label: 'Reached over', sortable: true, render: (row) => row.kind === 'unix' ? 'Unix socket' : 'HTTP' },
|
|
93
|
+
{ key: 'target', label: 'Destination', sortable: false, render: (row) => <code>{upstreamTarget(row)}</code> },
|
|
94
|
+
{ key: 'group', label: 'Scope', sortable: false, render: (row) => row.group ? <Badge tone="info">{row.group.name}</Badge> : <Badge tone="primary">Shared</Badge> },
|
|
95
|
+
{ key: 'is_enabled', label: 'State', sortable: true, render: (row) => row.is_enabled ? <Badge tone="success">Enabled</Badge> : <Badge tone="muted">Disabled</Badge> },
|
|
96
|
+
{ key: 'created', label: 'Declared', sortable: true, render: (row) => fmt.date(row.created) },
|
|
97
|
+
...(canManage ? [{
|
|
98
|
+
key: 'actions', label: '', sortable: false, hideable: false, align: 'end' as const,
|
|
99
|
+
render: (row: EdgeUpstreamRow) => <span className="edge-row-actions" onClick={(event) => event.stopPropagation()}>
|
|
100
|
+
<button type="button" className="btn btn-compact" onClick={() => void run(() => setUpstreamEnabled(row.id, !row.is_enabled), (saved) => `${saved.name} ${saved.is_enabled ? 'enabled' : 'disabled'}.`)}>{row.is_enabled ? 'Disable' : 'Enable'}</button>
|
|
101
|
+
{superuser && row.is_enabled && <ArmedButton className="btn-compact" label="Retire" armedLabel="Click again to review" onConfirm={() => retire(row)} />}
|
|
102
|
+
</span>,
|
|
103
|
+
}] : []),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
return <ModelTable<EdgeUpstreamRow>
|
|
107
|
+
model={EdgeUpstreamModel} eyebrow="Infrastructure · Edge" title="Upstreams"
|
|
108
|
+
searchable searchPlaceholder="Search name or kind"
|
|
109
|
+
columns={columns} filters={FILTERS} defaultSort="name"
|
|
110
|
+
columnChooser persistState persistKey="admin:edge:upstreams"
|
|
111
|
+
{...(superuser ? { addLabel: 'Declare upstream', onAdd: () => void declare() } : {})}
|
|
112
|
+
/>;
|
|
113
|
+
}
|