portal-mojo 0.1.0
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/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { useContext, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import {
|
|
4
|
+
GroupContext, hasPermission, useCan, useMe,
|
|
5
|
+
type PermSpec,
|
|
6
|
+
} from '../../client';
|
|
7
|
+
import {
|
|
8
|
+
ArmedButton, Badge, DetailView, Eyebrow, FlatRow, ModelTable,
|
|
9
|
+
SchemaForm, fmt, modal, toast,
|
|
10
|
+
type Column, type Field, type FilterDef,
|
|
11
|
+
} from '../../ui';
|
|
12
|
+
import {
|
|
13
|
+
GLOBAL_CREDENTIAL_PERMS, GROUP_CREDENTIAL_PERMS,
|
|
14
|
+
GroupApiKeyModel, fetchApiKeyToken,
|
|
15
|
+
getGroupApiKeyPermissions, grantedPermissions,
|
|
16
|
+
groupApiKeyPermissionsVersion, subscribeGroupApiKeyPermissions,
|
|
17
|
+
useCreateGroupApiKey,
|
|
18
|
+
type ApiKeyPermissionDef, type CredentialGroup, type GroupApiKeyRow,
|
|
19
|
+
} from './models';
|
|
20
|
+
import { showSecretDialog } from './secret-dialog';
|
|
21
|
+
|
|
22
|
+
function groupLabel(group: GroupApiKeyRow['group']): string {
|
|
23
|
+
if (group && typeof group === 'object') return group.name;
|
|
24
|
+
return group == null ? '—' : `Group #${group}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolve the injectable registry on every update and apply grant gates. */
|
|
28
|
+
function useRenderedPermissions(): ApiKeyPermissionDef[] {
|
|
29
|
+
useSyncExternalStore(
|
|
30
|
+
subscribeGroupApiKeyPermissions,
|
|
31
|
+
groupApiKeyPermissionsVersion,
|
|
32
|
+
groupApiKeyPermissionsVersion,
|
|
33
|
+
);
|
|
34
|
+
const { data: me } = useMe();
|
|
35
|
+
const member = useContext(GroupContext)?.member ?? null;
|
|
36
|
+
return getGroupApiKeyPermissions().filter((permission) =>
|
|
37
|
+
!permission.grantPermissions
|
|
38
|
+
|| hasPermission(me ?? null, permission.grantPermissions, member));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function permissionFields(defs: ApiKeyPermissionDef[]): Field[] {
|
|
42
|
+
return defs.map((permission) => ({
|
|
43
|
+
name: `permissions.${permission.name}`,
|
|
44
|
+
type: 'switch',
|
|
45
|
+
label: permission.label,
|
|
46
|
+
columns: 6,
|
|
47
|
+
...(permission.tooltip ? { help: permission.tooltip } : {}),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function permissionSeed(
|
|
52
|
+
dict: Record<string, unknown> | null | undefined,
|
|
53
|
+
defs: ApiKeyPermissionDef[],
|
|
54
|
+
): Record<string, boolean> {
|
|
55
|
+
const granted = new Set(grantedPermissions(dict));
|
|
56
|
+
return Object.fromEntries(defs.map((permission) => [
|
|
57
|
+
`permissions.${permission.name}`,
|
|
58
|
+
granted.has(permission.name),
|
|
59
|
+
]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function TokenFooter({ permissions }: { permissions: string[] }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="ga-secret-footnote">
|
|
65
|
+
{permissions.length > 0 ? (
|
|
66
|
+
<span className="chip-row">
|
|
67
|
+
<span className="dim">Permissions:</span>
|
|
68
|
+
{permissions.map((permission) => (
|
|
69
|
+
<Badge key={permission} tone="info">{permission}</Badge>
|
|
70
|
+
))}
|
|
71
|
+
</span>
|
|
72
|
+
) : (
|
|
73
|
+
<span className="dim">
|
|
74
|
+
<i className="bi bi-info-circle" /> No permissions granted.
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
<div className="dim" style={{ marginTop: 8 }}>
|
|
78
|
+
Treat this token like a password. It authenticates as this group-scoped key.
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function useGroupApiKeyActions(permission: PermSpec) {
|
|
85
|
+
const renderedPermissions = useRenderedPermissions();
|
|
86
|
+
const create = useCreateGroupApiKey();
|
|
87
|
+
const save = GroupApiKeyModel.useSave();
|
|
88
|
+
const destroy = GroupApiKeyModel.useDelete();
|
|
89
|
+
const { can } = useCan(permission);
|
|
90
|
+
|
|
91
|
+
const createKey = async (fixedGroup?: CredentialGroup) => {
|
|
92
|
+
if (!can) return;
|
|
93
|
+
const result = await modal.open<Record<string, unknown> | null>((close) => (
|
|
94
|
+
<div className="modal-pad">
|
|
95
|
+
<h2 className="modal-title">Create API key</h2>
|
|
96
|
+
<SchemaForm
|
|
97
|
+
fields={[
|
|
98
|
+
{
|
|
99
|
+
name: 'name', type: 'text', label: 'Name', required: true,
|
|
100
|
+
placeholder: 'Mobile App v2', help: 'A descriptive name for this key.',
|
|
101
|
+
},
|
|
102
|
+
...(!fixedGroup ? [{
|
|
103
|
+
name: 'group', type: 'collection', label: 'Group', required: true,
|
|
104
|
+
endpoint: '/api/group', labelField: 'name', valueField: 'id',
|
|
105
|
+
placeholder: 'Search groups…',
|
|
106
|
+
} satisfies Field] : []),
|
|
107
|
+
...permissionFields(renderedPermissions),
|
|
108
|
+
]}
|
|
109
|
+
submitText="Create key"
|
|
110
|
+
onCancel={() => close(null)}
|
|
111
|
+
onSubmit={(form) => close(form)}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
), { size: 'md' });
|
|
115
|
+
if (!result) return;
|
|
116
|
+
|
|
117
|
+
const permissions: Record<string, true> = {};
|
|
118
|
+
for (const def of renderedPermissions) {
|
|
119
|
+
if (result[`permissions.${def.name}`] === true) permissions[def.name] = true;
|
|
120
|
+
}
|
|
121
|
+
const group = fixedGroup?.id ?? Number(result.group);
|
|
122
|
+
if (!Number.isFinite(group) || group <= 0) {
|
|
123
|
+
toast.error('Choose a group');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
let tokenShown = false;
|
|
128
|
+
await create.mutateAsync({
|
|
129
|
+
changes: {
|
|
130
|
+
name: String(result.name ?? ''),
|
|
131
|
+
group,
|
|
132
|
+
...(Object.keys(permissions).length ? { permissions } : {}),
|
|
133
|
+
},
|
|
134
|
+
onToken: async (token, row) => {
|
|
135
|
+
tokenShown = true;
|
|
136
|
+
await showSecretDialog({
|
|
137
|
+
title: 'API key created — save your token',
|
|
138
|
+
intro: <><i className="bi bi-check-circle-fill text-ok" /> API key <b>{row.name}</b> created.</>,
|
|
139
|
+
warning: 'Save this token now — creation reveals it automatically only once.',
|
|
140
|
+
secret: token,
|
|
141
|
+
ariaLabel: 'API token',
|
|
142
|
+
footer: <TokenFooter permissions={Object.keys(permissions)} />,
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
if (!tokenShown) toast.success('API key created');
|
|
147
|
+
} catch (error) {
|
|
148
|
+
toast.error(error instanceof Error ? error.message : 'Failed to create API key');
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const editKey = async (row: GroupApiKeyRow) => {
|
|
153
|
+
if (!can) return;
|
|
154
|
+
const renderedFields = permissionFields(renderedPermissions);
|
|
155
|
+
const before = permissionSeed(row.permissions, renderedPermissions);
|
|
156
|
+
const result = await modal.open<Record<string, unknown> | null>((close) => (
|
|
157
|
+
<div className="modal-pad">
|
|
158
|
+
<h2 className="modal-title">Edit API key</h2>
|
|
159
|
+
<SchemaForm
|
|
160
|
+
fields={[
|
|
161
|
+
{ name: 'name', type: 'text', label: 'Name', required: true, columns: 6 },
|
|
162
|
+
{ name: 'is_active', type: 'switch', label: 'Active', columns: 6 },
|
|
163
|
+
...renderedFields,
|
|
164
|
+
]}
|
|
165
|
+
initial={{ name: row.name, is_active: row.is_active, ...before }}
|
|
166
|
+
submitText="Save changes"
|
|
167
|
+
onCancel={() => close(null)}
|
|
168
|
+
onSubmit={(form) => close(form)}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
), { size: 'md' });
|
|
172
|
+
if (!result) return;
|
|
173
|
+
|
|
174
|
+
const changes: Record<string, unknown> = {};
|
|
175
|
+
const nextName = String(result.name ?? '');
|
|
176
|
+
if (nextName !== row.name) changes.name = nextName;
|
|
177
|
+
const nextActive = result.is_active === true;
|
|
178
|
+
if (nextActive !== row.is_active) changes.is_active = nextActive;
|
|
179
|
+
|
|
180
|
+
// Diff only controls that were actually rendered. Unknown and
|
|
181
|
+
// protected grants remain absent from the partial JSONField update.
|
|
182
|
+
const permissionChanges: Record<string, boolean> = {};
|
|
183
|
+
for (const def of renderedPermissions) {
|
|
184
|
+
const key = `permissions.${def.name}`;
|
|
185
|
+
const was = before[key] === true;
|
|
186
|
+
const now = result[key] === true;
|
|
187
|
+
if (was !== now) permissionChanges[def.name] = now;
|
|
188
|
+
}
|
|
189
|
+
if (Object.keys(permissionChanges).length) changes.permissions = permissionChanges;
|
|
190
|
+
if (!Object.keys(changes).length) return;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await save.mutateAsync({ id: row.id, changes });
|
|
194
|
+
toast.success('API key updated');
|
|
195
|
+
} catch (error) {
|
|
196
|
+
toast.error(error instanceof Error ? error.message : 'Failed to update API key');
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const revealToken = async (row: GroupApiKeyRow) => {
|
|
201
|
+
if (!can) return;
|
|
202
|
+
try {
|
|
203
|
+
const token = await fetchApiKeyToken(row.id);
|
|
204
|
+
await showSecretDialog({
|
|
205
|
+
title: `API key token — ${row.name}`,
|
|
206
|
+
warning: 'This explicit retrieval is audited. Treat the returned token like a password.',
|
|
207
|
+
secret: token,
|
|
208
|
+
ariaLabel: 'API token',
|
|
209
|
+
footer: <TokenFooter permissions={grantedPermissions(row.permissions)} />,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
toast.error(error instanceof Error ? error.message : 'Failed to reveal the token');
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const deleteKey = async (row: GroupApiKeyRow) => {
|
|
217
|
+
if (!can) return false;
|
|
218
|
+
try {
|
|
219
|
+
await destroy.mutateAsync({ id: row.id });
|
|
220
|
+
toast.success('API key deleted');
|
|
221
|
+
return true;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
toast.error(error instanceof Error ? error.message : 'Failed to delete API key');
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
return { canManage: can, createKey, editKey, revealToken, deleteKey };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function ApiKeyCard({ row, actions }: {
|
|
232
|
+
row: GroupApiKeyRow;
|
|
233
|
+
actions: ReturnType<typeof useGroupApiKeyActions>;
|
|
234
|
+
}) {
|
|
235
|
+
const permissions = grantedPermissions(row.permissions);
|
|
236
|
+
return (
|
|
237
|
+
<div
|
|
238
|
+
className={`ga-card-row${actions.canManage ? ' ga-click-row' : ''}`}
|
|
239
|
+
role={actions.canManage ? 'button' : undefined}
|
|
240
|
+
tabIndex={actions.canManage ? 0 : undefined}
|
|
241
|
+
onClick={actions.canManage ? () => void actions.editKey(row) : undefined}
|
|
242
|
+
onKeyDown={actions.canManage ? (event) => {
|
|
243
|
+
if (event.key === 'Enter') {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
void actions.editKey(row);
|
|
246
|
+
}
|
|
247
|
+
} : undefined}
|
|
248
|
+
>
|
|
249
|
+
<i className="bi bi-key-fill ga-card-icon" />
|
|
250
|
+
<div className="ga-card-main">
|
|
251
|
+
<div className="ga-card-title">
|
|
252
|
+
<b>{row.name || 'Unnamed key'}</b>
|
|
253
|
+
<Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge>
|
|
254
|
+
</div>
|
|
255
|
+
<div className="chip-row">
|
|
256
|
+
{permissions.length
|
|
257
|
+
? permissions.map((permission) => <Badge key={permission} tone="info">{permission}</Badge>)
|
|
258
|
+
: <span className="dim-italic">No permissions granted</span>}
|
|
259
|
+
</div>
|
|
260
|
+
<div className="dim ga-card-meta">
|
|
261
|
+
Last used <b>{fmt.relative(row.last_used, 'never')}</b> · Created {fmt.date(row.created)}
|
|
262
|
+
{row.expires_at != null && <> · Expires {fmt.date(row.expires_at)}</>}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
{actions.canManage && (
|
|
266
|
+
<div className="ga-card-actions" onClick={(event) => event.stopPropagation()}>
|
|
267
|
+
<button className="btn btn-compact" title="Reveal the current token" onClick={() => void actions.revealToken(row)}>
|
|
268
|
+
<i className="bi bi-eye" /> Token
|
|
269
|
+
</button>
|
|
270
|
+
<ArmedButton
|
|
271
|
+
className="btn-compact"
|
|
272
|
+
label={<i className="bi bi-trash" aria-label="Delete this key" />}
|
|
273
|
+
armedLabel="Click again — services using this key lose access"
|
|
274
|
+
title="Delete this key"
|
|
275
|
+
onConfirm={async () => { await actions.deleteKey(row); }}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function GroupApiKeysContent({ group, permission }: { group: CredentialGroup; permission: PermSpec }) {
|
|
284
|
+
const { data, isPending } = GroupApiKeyModel.useList({ group: group.id, size: 10, sort: '-created' });
|
|
285
|
+
const actions = useGroupApiKeyActions(permission);
|
|
286
|
+
const rows = data?.rows ?? [];
|
|
287
|
+
return (
|
|
288
|
+
<>
|
|
289
|
+
<Eyebrow>API keys</Eyebrow>
|
|
290
|
+
<p className="dim" style={{ margin: '0 0 12px' }}>
|
|
291
|
+
Group-scoped integration keys using <code>Authorization: apikey <token></code>.
|
|
292
|
+
</p>
|
|
293
|
+
{actions.canManage && (
|
|
294
|
+
<div className="ga-toolbar">
|
|
295
|
+
<button className="btn btn-primary btn-compact" onClick={() => void actions.createKey(group)}>
|
|
296
|
+
<i className="bi bi-key" /> Create Key
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
{!isPending && rows.length === 0 && <p className="dim-italic">No API keys yet.</p>}
|
|
301
|
+
{rows.map((row) => <ApiKeyCard key={row.id} row={row} actions={actions} />)}
|
|
302
|
+
{(data?.count ?? 0) > rows.length && (
|
|
303
|
+
<p className="dim" style={{ marginTop: 8 }}>{data!.count - rows.length} more keys not shown.</p>
|
|
304
|
+
)}
|
|
305
|
+
</>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function GroupApiKeysSection({ group, permission = GROUP_CREDENTIAL_PERMS }: {
|
|
310
|
+
group: CredentialGroup;
|
|
311
|
+
permission?: PermSpec;
|
|
312
|
+
}) {
|
|
313
|
+
const { can } = useCan(permission);
|
|
314
|
+
if (!can) return <p className="dim-italic">API keys are unavailable for this account.</p>;
|
|
315
|
+
return <GroupApiKeysContent group={group} permission={permission} />;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function GroupApiKeyDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
319
|
+
const { data: row, isPending, error } = GroupApiKeyModel.useOne(id);
|
|
320
|
+
const actions = useGroupApiKeyActions(GLOBAL_CREDENTIAL_PERMS);
|
|
321
|
+
if (isPending) return <div className="modal-pad dim">Loading API key…</div>;
|
|
322
|
+
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'API key not found'}</div>;
|
|
323
|
+
const permissions = grantedPermissions(row.permissions);
|
|
324
|
+
return (
|
|
325
|
+
<DetailView
|
|
326
|
+
icon="bi-key"
|
|
327
|
+
title={row.name || `API key #${row.id}`}
|
|
328
|
+
subtitle={`${groupLabel(row.group)} · group-scoped credential`}
|
|
329
|
+
chips={[
|
|
330
|
+
{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' },
|
|
331
|
+
{ text: `${permissions.length} grants`, tone: 'info' },
|
|
332
|
+
]}
|
|
333
|
+
sections={[
|
|
334
|
+
{
|
|
335
|
+
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
336
|
+
<>
|
|
337
|
+
<Eyebrow>Key</Eyebrow>
|
|
338
|
+
<FlatRow label="Group">{groupLabel(row.group)}</FlatRow>
|
|
339
|
+
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
340
|
+
<FlatRow label="Last used">{fmt.relative(row.last_used, 'never')}</FlatRow>
|
|
341
|
+
<FlatRow label="Expires">{row.expires_at ? fmt.datetime(row.expires_at) : 'Never'}</FlatRow>
|
|
342
|
+
<div className="ga-toolbar" style={{ marginTop: 16 }}>
|
|
343
|
+
<button className="btn btn-compact" onClick={() => void actions.editKey(row)}>
|
|
344
|
+
<i className="bi bi-pencil" /> Edit
|
|
345
|
+
</button>
|
|
346
|
+
<button className="btn btn-compact" onClick={() => void actions.revealToken(row)}>
|
|
347
|
+
<i className="bi bi-eye" /> Reveal audited token
|
|
348
|
+
</button>
|
|
349
|
+
<ArmedButton
|
|
350
|
+
className="btn-compact"
|
|
351
|
+
label="Delete"
|
|
352
|
+
armedLabel="Click again — delete now"
|
|
353
|
+
onConfirm={async () => {
|
|
354
|
+
if (await actions.deleteKey(row)) onClose();
|
|
355
|
+
}}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
</>
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
key: 'permissions', label: 'Permissions', icon: 'bi-shield-lock', render: () => (
|
|
363
|
+
<>
|
|
364
|
+
<Eyebrow>Granted permissions</Eyebrow>
|
|
365
|
+
<div className="chip-row">
|
|
366
|
+
{permissions.length
|
|
367
|
+
? permissions.map((permission) => <Badge key={permission} tone="info">{permission}</Badge>)
|
|
368
|
+
: <span className="dim-italic">No permissions granted</span>}
|
|
369
|
+
</div>
|
|
370
|
+
<p className="dim">Only controls visible to the operator are diffed when editing; unknown grants are preserved.</p>
|
|
371
|
+
</>
|
|
372
|
+
),
|
|
373
|
+
},
|
|
374
|
+
]}
|
|
375
|
+
initialSection="permissions"
|
|
376
|
+
onClose={onClose}
|
|
377
|
+
/>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const API_KEY_COLUMNS: Column<GroupApiKeyRow>[] = [
|
|
382
|
+
{
|
|
383
|
+
key: 'name', label: 'Key', sortable: true, hideable: false, render: (row) => (
|
|
384
|
+
<div className="cell-user">
|
|
385
|
+
<span className="cell-avatar"><i className="bi bi-key" /></span>
|
|
386
|
+
<span><span className="cell-name">{row.name}</span><span className="cell-sub">#{row.id}</span></span>
|
|
387
|
+
</div>
|
|
388
|
+
),
|
|
389
|
+
},
|
|
390
|
+
{ key: 'group', label: 'Group', render: (row) => groupLabel(row.group) },
|
|
391
|
+
{ key: 'is_active', label: 'Status', render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge> },
|
|
392
|
+
{ key: 'permissions', label: 'Grants', align: 'center', render: (row) => String(grantedPermissions(row.permissions).length) },
|
|
393
|
+
{ key: 'last_used', label: 'Last used', sortable: true, render: (row) => fmt.relative(row.last_used, 'never') },
|
|
394
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
const API_KEY_FILTERS: FilterDef[] = [
|
|
398
|
+
{ key: 'name', label: 'Name', type: 'text', placeholder: 'Contains…' },
|
|
399
|
+
{ key: 'group', label: 'Group ID', type: 'number', lookup: 'exact' },
|
|
400
|
+
{ key: 'is_active', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
|
|
401
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
402
|
+
{ key: 'last_used', label: 'Last used', type: 'daterange' },
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
export function GroupApiKeysPage() {
|
|
406
|
+
const queryClient = useQueryClient();
|
|
407
|
+
const actions = useGroupApiKeyActions(GLOBAL_CREDENTIAL_PERMS);
|
|
408
|
+
const openDetail = (row: GroupApiKeyRow) => {
|
|
409
|
+
void GroupApiKeyModel.fetchOne(queryClient, row.id).catch(() => undefined);
|
|
410
|
+
void modal.detail((close) => <GroupApiKeyDetail id={row.id} onClose={() => close(null)} />);
|
|
411
|
+
};
|
|
412
|
+
return (
|
|
413
|
+
<ModelTable<GroupApiKeyRow>
|
|
414
|
+
model={GroupApiKeyModel}
|
|
415
|
+
eyebrow="Account"
|
|
416
|
+
title="Group API Keys"
|
|
417
|
+
searchable={false}
|
|
418
|
+
columns={API_KEY_COLUMNS}
|
|
419
|
+
filters={API_KEY_FILTERS}
|
|
420
|
+
presets={[
|
|
421
|
+
{ key: 'all', label: 'All', params: {} },
|
|
422
|
+
{ key: 'active', label: 'Active', params: { is_active: 'true' } },
|
|
423
|
+
{ key: 'inactive', label: 'Inactive', params: { is_active: 'false' } },
|
|
424
|
+
]}
|
|
425
|
+
defaultSort="-created"
|
|
426
|
+
columnChooser
|
|
427
|
+
persistState
|
|
428
|
+
onRowClick={openDetail}
|
|
429
|
+
addLabel="New API Key"
|
|
430
|
+
onAdd={actions.canManage ? () => void actions.createKey() : undefined}
|
|
431
|
+
/>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
defineModel, mojoCall, mojoSave, withFreshAuth,
|
|
4
|
+
type Params, type PermSpec,
|
|
5
|
+
} from '../../client';
|
|
6
|
+
|
|
7
|
+
/** Global operator gate for the cross-group credential pages. */
|
|
8
|
+
export const GLOBAL_CREDENTIAL_PERMS = ['sys.manage_groups', 'sys.groups'];
|
|
9
|
+
|
|
10
|
+
/** Group-member gate for credentials inside GroupDetail. */
|
|
11
|
+
export const GROUP_CREDENTIAL_PERMS = ['manage_group', 'manage_groups', 'groups'];
|
|
12
|
+
|
|
13
|
+
/** Who may grant the built-in fleet-wide federation permission. */
|
|
14
|
+
export const APIKEY_FEDERATION_GRANT_PERMS = ['manage_users', 'manage_groups', 'sys.geoip_sync'];
|
|
15
|
+
|
|
16
|
+
export interface CredentialGroup {
|
|
17
|
+
id: number;
|
|
18
|
+
name: string;
|
|
19
|
+
kind?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GroupApiKeyRow {
|
|
23
|
+
id: number;
|
|
24
|
+
created: number;
|
|
25
|
+
modified: number;
|
|
26
|
+
name: string;
|
|
27
|
+
is_active: boolean;
|
|
28
|
+
permissions: Record<string, unknown>;
|
|
29
|
+
limits: Record<string, unknown>;
|
|
30
|
+
last_used: number | null;
|
|
31
|
+
expires_at: number | null;
|
|
32
|
+
metadata: Record<string, unknown>;
|
|
33
|
+
override_user: boolean;
|
|
34
|
+
group: CredentialGroup | number | null;
|
|
35
|
+
user: unknown | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface GroupApiKeyCreateResponse extends GroupApiKeyRow {
|
|
39
|
+
/** Present on the create echo only. Never written to Query cache. */
|
|
40
|
+
token?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Defense in depth for every Query/Mutation cache write. */
|
|
44
|
+
function sanitizeGroupApiKeyRow(row: GroupApiKeyRow): GroupApiKeyRow {
|
|
45
|
+
const safe = { ...row } as GroupApiKeyRow & Record<string, unknown>;
|
|
46
|
+
delete safe.token;
|
|
47
|
+
delete safe.token_hash;
|
|
48
|
+
return safe;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ordinary lists are always the safe default graph. URL/persisted state may
|
|
53
|
+
* carry arbitrary params, so remove graph/search and reject unsupported sort
|
|
54
|
+
* fields before this object becomes either the query key or wire request.
|
|
55
|
+
*/
|
|
56
|
+
function normalizeGroupApiKeyListParams(params: Params): Params {
|
|
57
|
+
const safe: Params = { ...params };
|
|
58
|
+
delete safe.graph;
|
|
59
|
+
delete safe.search;
|
|
60
|
+
if (typeof safe.sort === 'string') {
|
|
61
|
+
const field = safe.sort.replace(/^-/, '');
|
|
62
|
+
if (!['name', 'is_active', 'last_used', 'created'].includes(field)) delete safe.sort;
|
|
63
|
+
}
|
|
64
|
+
return { ...safe, graph: 'default' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const GroupApiKeyModel = defineModel<GroupApiKeyRow>({
|
|
68
|
+
name: 'group_api_key',
|
|
69
|
+
endpoint: '/api/group/apikey',
|
|
70
|
+
permissions: {
|
|
71
|
+
view: GROUP_CREDENTIAL_PERMS,
|
|
72
|
+
manage: GROUP_CREDENTIAL_PERMS,
|
|
73
|
+
},
|
|
74
|
+
normalizeListParams: normalizeGroupApiKeyListParams,
|
|
75
|
+
sanitizeRow: sanitizeGroupApiKeyRow,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export interface CreateGroupApiKeyVariables {
|
|
79
|
+
changes: Record<string, unknown>;
|
|
80
|
+
/** Uncached one-shot channel invoked before the mutation resolves. */
|
|
81
|
+
onToken?: (token: string, row: GroupApiKeyRow) => void | Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create is deliberately separate from ModelDef.useSave(): the backend adds
|
|
86
|
+
* the raw token to the create echo. Split it before any cache write and invoke
|
|
87
|
+
* the transient callback while the mutation is still pending. MutationCache
|
|
88
|
+
* resolves with only the safe row and never retains the raw token as data.
|
|
89
|
+
*/
|
|
90
|
+
export function useCreateGroupApiKey() {
|
|
91
|
+
const qc = useQueryClient();
|
|
92
|
+
return useMutation<GroupApiKeyRow, Error, CreateGroupApiKeyVariables>({
|
|
93
|
+
mutationFn: async ({ changes, onToken }) => {
|
|
94
|
+
const created = await withFreshAuth(() =>
|
|
95
|
+
mojoSave<GroupApiKeyCreateResponse>(GroupApiKeyModel.endpoint, null, changes));
|
|
96
|
+
const safeRow = sanitizeGroupApiKeyRow(created);
|
|
97
|
+
let token = typeof created.token === 'string' && created.token ? created.token : null;
|
|
98
|
+
try {
|
|
99
|
+
if (token && onToken) await onToken(token, safeRow);
|
|
100
|
+
} finally {
|
|
101
|
+
// Drop both references before the safe result resolves into
|
|
102
|
+
// MutationCache. The dialog has unmounted when its promise ends.
|
|
103
|
+
token = null;
|
|
104
|
+
delete created.token;
|
|
105
|
+
}
|
|
106
|
+
return safeRow;
|
|
107
|
+
},
|
|
108
|
+
onSuccess: (row) => {
|
|
109
|
+
qc.setQueryData(GroupApiKeyModel.keys.one(row.id), row);
|
|
110
|
+
void qc.invalidateQueries({ queryKey: GroupApiKeyModel.keys.root });
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Explicit opt-in credential read. django-mojo's `token` graph audits every
|
|
117
|
+
* serialization as `api_key:token_read`; this helper never uses Query.
|
|
118
|
+
*/
|
|
119
|
+
export async function fetchApiKeyToken(id: number): Promise<string> {
|
|
120
|
+
const body = await mojoCall(`${GroupApiKeyModel.endpoint}/${id}`, {
|
|
121
|
+
params: { graph: 'token' },
|
|
122
|
+
});
|
|
123
|
+
const token = (body.data as { token?: unknown } | undefined)?.token;
|
|
124
|
+
if (typeof token !== 'string' || !token) throw new Error('The server did not return a token');
|
|
125
|
+
return token;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ApiKeyPermissionDef {
|
|
129
|
+
name: string;
|
|
130
|
+
label: string;
|
|
131
|
+
tooltip?: string;
|
|
132
|
+
/** When present, the control renders only when the operator has one. */
|
|
133
|
+
grantPermissions?: PermSpec;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const permissionRegistry = new Map<string, ApiKeyPermissionDef>();
|
|
137
|
+
let permissionVersion = 0;
|
|
138
|
+
const permissionListeners = new Set<() => void>();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Add or replace API-key permission controls. Mounted editors subscribe to
|
|
142
|
+
* this registry, so product permissions registered after boot appear live.
|
|
143
|
+
*/
|
|
144
|
+
export function registerGroupApiKeyPermissions(defs: ApiKeyPermissionDef[]): void {
|
|
145
|
+
for (const def of defs) permissionRegistry.set(def.name, { ...def });
|
|
146
|
+
permissionVersion += 1;
|
|
147
|
+
for (const listener of permissionListeners) listener();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getGroupApiKeyPermissions(): ApiKeyPermissionDef[] {
|
|
151
|
+
return [...permissionRegistry.values()].map((def) => ({ ...def }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function subscribeGroupApiKeyPermissions(listener: () => void): () => void {
|
|
155
|
+
permissionListeners.add(listener);
|
|
156
|
+
return () => permissionListeners.delete(listener);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function groupApiKeyPermissionsVersion(): number {
|
|
160
|
+
return permissionVersion;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
registerGroupApiKeyPermissions([
|
|
164
|
+
{ name: 'admin', label: 'Group Admin', tooltip: 'Full access within this group' },
|
|
165
|
+
{ name: 'manage_group', label: 'Manage Group' },
|
|
166
|
+
{ name: 'view_metrics', label: 'View Metrics' },
|
|
167
|
+
{ name: 'view_logs', label: 'View Logs' },
|
|
168
|
+
{ name: 'view_tickets', label: 'View Tickets' },
|
|
169
|
+
{ name: 'view_members', label: 'View Members' },
|
|
170
|
+
{ name: 'manage_members', label: 'Manage Members' },
|
|
171
|
+
{ name: 'view_billing', label: 'View Billing' },
|
|
172
|
+
{
|
|
173
|
+
name: 'geoip_sync',
|
|
174
|
+
label: 'GeoIP Federation Sync',
|
|
175
|
+
tooltip: 'Lets this key push abuse signals into the fleet shared GeoIP threat intelligence.',
|
|
176
|
+
grantPermissions: APIKEY_FEDERATION_GRANT_PERMS,
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
/** Loose-truthy grants, matching django-mojo's JSON permission semantics. */
|
|
181
|
+
export function grantedPermissions(dict: Record<string, unknown> | null | undefined): string[] {
|
|
182
|
+
return Object.entries(dict ?? {})
|
|
183
|
+
.filter(([, value]) => value === true || value === 1)
|
|
184
|
+
.map(([name]) => name);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface WebhookSubscriptionRow {
|
|
188
|
+
id: number;
|
|
189
|
+
created: number;
|
|
190
|
+
modified: number;
|
|
191
|
+
url: string;
|
|
192
|
+
events: string[];
|
|
193
|
+
is_active: boolean;
|
|
194
|
+
group: CredentialGroup | number | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeWebhookListParams(params: Params): Params {
|
|
198
|
+
const safe: Params = { ...params };
|
|
199
|
+
delete safe.search;
|
|
200
|
+
if (typeof safe.sort === 'string') {
|
|
201
|
+
const field = safe.sort.replace(/^-/, '');
|
|
202
|
+
if (!['url', 'is_active', 'created'].includes(field)) delete safe.sort;
|
|
203
|
+
}
|
|
204
|
+
return safe;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const WebhookSubscriptionModel = defineModel<WebhookSubscriptionRow>({
|
|
208
|
+
name: 'webhook_subscription',
|
|
209
|
+
endpoint: '/api/group/webhook_subscriptions',
|
|
210
|
+
permissions: {
|
|
211
|
+
view: GROUP_CREDENTIAL_PERMS,
|
|
212
|
+
manage: GROUP_CREDENTIAL_PERMS,
|
|
213
|
+
},
|
|
214
|
+
normalizeListParams: normalizeWebhookListParams,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/** TagInput CSV or an existing array to the backend JSON-list contract. */
|
|
218
|
+
export function normalizeWebhookEvents(raw: unknown): string[] {
|
|
219
|
+
const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
|
|
220
|
+
return values.map((value) => String(value).trim()).filter(Boolean);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface WebhookSecretInfo {
|
|
224
|
+
secret: string;
|
|
225
|
+
created_at: string | null;
|
|
226
|
+
last_rotated_at: string | null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Explicit reveal/rotate only: the backend auto-mints on first reveal. */
|
|
230
|
+
export async function fetchWebhookSecret(groupId: number, rotate = false): Promise<WebhookSecretInfo> {
|
|
231
|
+
const body = await mojoCall('/api/group/webhook_secret', {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
body: rotate ? { group: groupId, rotate: true } : { group: groupId },
|
|
234
|
+
});
|
|
235
|
+
const data = body.data as Partial<WebhookSecretInfo> | undefined;
|
|
236
|
+
if (typeof data?.secret !== 'string' || !data.secret) {
|
|
237
|
+
throw new Error('The server did not return a secret');
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
secret: data.secret,
|
|
241
|
+
created_at: data.created_at ?? null,
|
|
242
|
+
last_rotated_at: data.last_rotated_at ?? null,
|
|
243
|
+
};
|
|
244
|
+
}
|