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,157 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useCan } from '../../client';
|
|
4
|
+
import {
|
|
5
|
+
CollectionSelect, modal, toast, type CollectionSelectValue,
|
|
6
|
+
} from '../../ui';
|
|
7
|
+
import {
|
|
8
|
+
SettingModel, saveSettingAtomic, settingGroupLabel,
|
|
9
|
+
type SettingDraft, type SettingGroup, type SettingRow,
|
|
10
|
+
} from './model';
|
|
11
|
+
|
|
12
|
+
function initialGroup(row: SettingRow | null): number | null {
|
|
13
|
+
if (row?.group == null) return null;
|
|
14
|
+
return typeof row.group === 'number' ? row.group : row.group.id;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function SettingEditor({ row, close }: {
|
|
18
|
+
row: SettingRow | null;
|
|
19
|
+
close: (result: SettingRow | null) => void;
|
|
20
|
+
}) {
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const { can: canChooseGroup } = useCan('sys.groups');
|
|
23
|
+
const valueRef = useRef<HTMLTextAreaElement>(null);
|
|
24
|
+
const valueTouched = useRef(false);
|
|
25
|
+
const [key, setKey] = useState(row?.key ?? '');
|
|
26
|
+
const [isSecret, setIsSecret] = useState(row?.is_secret ?? false);
|
|
27
|
+
const [group, setGroup] = useState<number | null>(initialGroup(row));
|
|
28
|
+
const [busy, setBusy] = useState(false);
|
|
29
|
+
const [error, setError] = useState('');
|
|
30
|
+
|
|
31
|
+
const changeSecrecy = (next: boolean) => {
|
|
32
|
+
setIsSecret(next);
|
|
33
|
+
valueTouched.current = false;
|
|
34
|
+
if (valueRef.current) valueRef.current.value = '';
|
|
35
|
+
setError('');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const submit = async (event: React.FormEvent) => {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
const draft: SettingDraft = {
|
|
41
|
+
key,
|
|
42
|
+
value: valueRef.current?.value ?? '',
|
|
43
|
+
valueTouched: valueTouched.current,
|
|
44
|
+
is_secret: isSecret,
|
|
45
|
+
group,
|
|
46
|
+
};
|
|
47
|
+
setBusy(true);
|
|
48
|
+
setError('');
|
|
49
|
+
try {
|
|
50
|
+
const saved = await saveSettingAtomic(row, draft);
|
|
51
|
+
if (saved) {
|
|
52
|
+
queryClient.setQueryData(SettingModel.keys.one(saved.id), saved);
|
|
53
|
+
await queryClient.invalidateQueries({ queryKey: SettingModel.keys.root });
|
|
54
|
+
toast.success(row ? 'Setting updated' : 'Setting created');
|
|
55
|
+
close(saved);
|
|
56
|
+
} else {
|
|
57
|
+
close(row);
|
|
58
|
+
}
|
|
59
|
+
} catch (reason) {
|
|
60
|
+
setError(reason instanceof Error ? reason.message : 'Setting could not be saved');
|
|
61
|
+
} finally {
|
|
62
|
+
setBusy(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const valueHelp = row?.is_secret && isSecret
|
|
67
|
+
? 'Leave blank to preserve the existing secret. Enter a value only to replace it.'
|
|
68
|
+
: isSecret
|
|
69
|
+
? 'The value becomes write-only after save and will not be returned by the API.'
|
|
70
|
+
: 'Plain settings may intentionally contain an empty string.';
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<form className="modal-pad" onSubmit={submit} noValidate>
|
|
74
|
+
<h2 className="modal-title">{row ? `Edit setting — ${row.key}` : 'Create setting'}</h2>
|
|
75
|
+
{error && <div className="form-alert">{error}</div>}
|
|
76
|
+
<div className="form-grid">
|
|
77
|
+
<div className="col-12">
|
|
78
|
+
<label className="field">
|
|
79
|
+
<span className="field-label">Key <em>*</em></span>
|
|
80
|
+
<input
|
|
81
|
+
className="input"
|
|
82
|
+
value={key}
|
|
83
|
+
disabled={row != null}
|
|
84
|
+
placeholder="WEBHOOK_SECRET"
|
|
85
|
+
onChange={(event) => setKey(event.target.value)}
|
|
86
|
+
/>
|
|
87
|
+
<span className="field-help">Keys are immutable after creation.</span>
|
|
88
|
+
</label>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="col-12">
|
|
91
|
+
<label className="switch-row">
|
|
92
|
+
<input
|
|
93
|
+
type="checkbox"
|
|
94
|
+
role="switch"
|
|
95
|
+
className="switch"
|
|
96
|
+
checked={isSecret}
|
|
97
|
+
onChange={(event) => changeSecrecy(event.target.checked)}
|
|
98
|
+
/>
|
|
99
|
+
<span className="field-label">Secret</span>
|
|
100
|
+
</label>
|
|
101
|
+
{row && row.is_secret !== isSecret && (
|
|
102
|
+
<span className="field-help">Changing secret status requires an explicit replacement value.</span>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="col-12">
|
|
106
|
+
<label className="field">
|
|
107
|
+
<span className="field-label">{isSecret ? 'Secret replacement' : 'Value'}</span>
|
|
108
|
+
<textarea
|
|
109
|
+
ref={valueRef}
|
|
110
|
+
className="input"
|
|
111
|
+
rows={4}
|
|
112
|
+
defaultValue={row && !row.is_secret ? (row.value ?? row.display_value ?? '') : ''}
|
|
113
|
+
autoComplete="off"
|
|
114
|
+
spellCheck={false}
|
|
115
|
+
onChange={() => { valueTouched.current = true; setError(''); }}
|
|
116
|
+
/>
|
|
117
|
+
<span className="field-help">{valueHelp}</span>
|
|
118
|
+
</label>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="col-12">
|
|
121
|
+
{canChooseGroup ? (
|
|
122
|
+
<CollectionSelect<SettingGroup>
|
|
123
|
+
endpoint="/api/group"
|
|
124
|
+
value={group as CollectionSelectValue}
|
|
125
|
+
onChange={(id) => setGroup(id == null ? null : Number(id))}
|
|
126
|
+
label="Group scope"
|
|
127
|
+
placeholder="Search groups…"
|
|
128
|
+
help="Clear the selection for a global setting."
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="field">
|
|
132
|
+
<span className="field-label">Group scope</span>
|
|
133
|
+
<div className="input" aria-readonly="true">
|
|
134
|
+
{row ? settingGroupLabel(row.group) : 'Global'}
|
|
135
|
+
</div>
|
|
136
|
+
<span className="field-help">
|
|
137
|
+
Scope changes require the system Groups permission.
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<div className="modal-actions">
|
|
144
|
+
<button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
|
|
145
|
+
<button type="submit" className="btn btn-primary" disabled={busy || !key.trim()}>
|
|
146
|
+
{busy ? 'Saving…' : row ? 'Save changes' : 'Create setting'}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
</form>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function showSettingEditor(row: SettingRow | null = null): Promise<SettingRow | null> {
|
|
154
|
+
return modal.open<SettingRow | null>((close) => (
|
|
155
|
+
<SettingEditor row={row} close={close} />
|
|
156
|
+
), { size: 'md' });
|
|
157
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
Badge, ModelTable, fmt, modal, type Column, type FilterDef,
|
|
4
|
+
} from '../../ui';
|
|
5
|
+
import {
|
|
6
|
+
SettingModel, settingGroupLabel, type SettingRow,
|
|
7
|
+
} from './model';
|
|
8
|
+
import { SettingDetail } from './SettingDetail';
|
|
9
|
+
import { showSettingEditor } from './SettingEditor';
|
|
10
|
+
|
|
11
|
+
const COLUMNS: Column<SettingRow>[] = [
|
|
12
|
+
{
|
|
13
|
+
key: 'key', label: 'Key', sortable: true, hideable: false, render: (row) => (
|
|
14
|
+
<div className="cell-user">
|
|
15
|
+
<span className="cell-avatar"><i className="bi bi-gear" /></span>
|
|
16
|
+
<span><span className="cell-name"><code>{row.key}</code></span><span className="cell-sub">#{row.id}</span></span>
|
|
17
|
+
</div>
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
key: 'display_value', label: 'Value', render: (row) => (
|
|
22
|
+
<code>{row.is_secret ? row.display_value || '******' : row.display_value || '—'}</code>
|
|
23
|
+
),
|
|
24
|
+
},
|
|
25
|
+
{ key: 'group', label: 'Scope', render: (row) => settingGroupLabel(row.group) },
|
|
26
|
+
{
|
|
27
|
+
key: 'is_secret', label: 'Storage', sortable: true, align: 'center', render: (row) => (
|
|
28
|
+
<Badge tone={row.is_secret ? 'warning' : 'muted'}>{row.is_secret ? 'Secret' : 'Plain'}</Badge>
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const FILTERS: FilterDef[] = [
|
|
35
|
+
{ key: 'is_secret', label: 'Storage', type: 'boolean', trueLabel: 'Secret', falseLabel: 'Plain' },
|
|
36
|
+
{
|
|
37
|
+
key: 'group__isnull', label: 'Scope', type: 'select', options: [
|
|
38
|
+
{ value: 'true', label: 'Global' },
|
|
39
|
+
{ value: 'false', label: 'Group scoped' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export function SettingsPage() {
|
|
45
|
+
const queryClient = useQueryClient();
|
|
46
|
+
const openDetail = (row: SettingRow) => {
|
|
47
|
+
void SettingModel.fetchOne(queryClient, row.id).catch(() => undefined);
|
|
48
|
+
void modal.detail((close) => <SettingDetail id={row.id} onClose={() => close(null)} />);
|
|
49
|
+
};
|
|
50
|
+
return (
|
|
51
|
+
<ModelTable<SettingRow>
|
|
52
|
+
model={SettingModel}
|
|
53
|
+
eyebrow="Account"
|
|
54
|
+
title="Settings"
|
|
55
|
+
searchPlaceholder="Search setting keys…"
|
|
56
|
+
columns={COLUMNS}
|
|
57
|
+
filters={FILTERS}
|
|
58
|
+
presets={[
|
|
59
|
+
{ key: 'all', label: 'All', params: {} },
|
|
60
|
+
{ key: 'global', label: 'Global', params: { group__isnull: 'true' } },
|
|
61
|
+
{ key: 'group', label: 'Group scoped', params: { group__isnull: 'false' } },
|
|
62
|
+
{ key: 'secret', label: 'Secrets', params: { is_secret: 'true' } },
|
|
63
|
+
]}
|
|
64
|
+
defaultSort="key"
|
|
65
|
+
columnChooser
|
|
66
|
+
persistState
|
|
67
|
+
onRowClick={openDetail}
|
|
68
|
+
addLabel="New Setting"
|
|
69
|
+
onAdd={() => void showSettingEditor()}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SETTINGS_PERMISSIONS } from './model';
|
|
2
|
+
import { SettingsPage } from './SettingsPage';
|
|
3
|
+
|
|
4
|
+
export * from './model';
|
|
5
|
+
export * from './SettingEditor';
|
|
6
|
+
export * from './SettingsPage';
|
|
7
|
+
export * from './SettingDetail';
|
|
8
|
+
|
|
9
|
+
/** Structurally satisfies AdminSection without importing the parent barrel. */
|
|
10
|
+
export const SETTINGS_ADMIN_SECTION = {
|
|
11
|
+
id: 'settings',
|
|
12
|
+
title: 'Settings',
|
|
13
|
+
icon: 'bi-gear',
|
|
14
|
+
navigationGroup: 'operations' as const,
|
|
15
|
+
permissions: SETTINGS_PERMISSIONS,
|
|
16
|
+
routes: [
|
|
17
|
+
{ path: '', component: SettingsPage, permissions: SETTINGS_PERMISSIONS },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { defineModel, mojoSave, withFreshAuth, type Params } from '../../client';
|
|
2
|
+
|
|
3
|
+
/** The exact system-level ANY-of gate declared by Setting.RestMeta. */
|
|
4
|
+
export const SETTINGS_PERMISSIONS = ['sys.manage_settings', 'sys.groups'];
|
|
5
|
+
|
|
6
|
+
export interface SettingGroup {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
kind?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SettingRow {
|
|
13
|
+
id: number;
|
|
14
|
+
created: number;
|
|
15
|
+
modified: number;
|
|
16
|
+
key: string;
|
|
17
|
+
/** Absent for secret rows after the client sanitation boundary. */
|
|
18
|
+
value?: string;
|
|
19
|
+
display_value: string;
|
|
20
|
+
is_secret: boolean;
|
|
21
|
+
group: SettingGroup | number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sanitizeSettingRow(row: SettingRow): SettingRow {
|
|
25
|
+
const safe = { ...row };
|
|
26
|
+
if (safe.is_secret) delete safe.value;
|
|
27
|
+
return safe;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeSettingListParams(params: Params): Params {
|
|
31
|
+
const safe: Params = {};
|
|
32
|
+
for (const key of ['start', 'size', 'search', 'is_secret', 'group', 'group__isnull']) {
|
|
33
|
+
const value = params[key];
|
|
34
|
+
if (value !== undefined && value !== null && value !== '') safe[key] = value;
|
|
35
|
+
}
|
|
36
|
+
if (typeof params.sort === 'string') {
|
|
37
|
+
const field = params.sort.replace(/^-/, '');
|
|
38
|
+
if (['key', 'is_secret', 'created', 'modified'].includes(field)) safe.sort = params.sort;
|
|
39
|
+
}
|
|
40
|
+
return safe;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const SettingModel = defineModel<SettingRow>({
|
|
44
|
+
name: 'setting',
|
|
45
|
+
endpoint: '/api/settings',
|
|
46
|
+
permissions: {
|
|
47
|
+
view: SETTINGS_PERMISSIONS,
|
|
48
|
+
manage: SETTINGS_PERMISSIONS,
|
|
49
|
+
create: SETTINGS_PERMISSIONS,
|
|
50
|
+
},
|
|
51
|
+
normalizeListParams: normalizeSettingListParams,
|
|
52
|
+
sanitizeRow: sanitizeSettingRow,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export interface SettingDraft {
|
|
56
|
+
key: string;
|
|
57
|
+
value: string;
|
|
58
|
+
/** True only after the operator edits the replacement-value control. */
|
|
59
|
+
valueTouched: boolean;
|
|
60
|
+
is_secret: boolean;
|
|
61
|
+
group: number | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function groupId(group: SettingRow['group']): number | null {
|
|
65
|
+
if (group == null) return null;
|
|
66
|
+
return typeof group === 'number' ? group : group.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build one atomic django-mojo save body. Property insertion order is part of
|
|
71
|
+
* the wire contract: django-mojo applies each field against the model's
|
|
72
|
+
* current secrecy state. Create keeps is_secret→value. Existing transitions
|
|
73
|
+
* are directional: Plain→Secret is value→is_secret; Secret→Plain is
|
|
74
|
+
* is_secret→value. Same-secret replacement needs only value.
|
|
75
|
+
*/
|
|
76
|
+
export function buildSettingPayload(draft: SettingDraft, original: SettingRow | null): Record<string, unknown> | null {
|
|
77
|
+
const changes: Record<string, unknown> = {};
|
|
78
|
+
const key = draft.key.trim();
|
|
79
|
+
if (!key) throw new Error('Key is required');
|
|
80
|
+
|
|
81
|
+
if (original == null) {
|
|
82
|
+
if (draft.is_secret && draft.value === '') throw new Error('Enter a secret value');
|
|
83
|
+
changes.key = key;
|
|
84
|
+
changes.group = draft.group;
|
|
85
|
+
changes.is_secret = draft.is_secret;
|
|
86
|
+
// Plain settings deliberately support an empty string.
|
|
87
|
+
changes.value = draft.value;
|
|
88
|
+
return changes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (draft.group !== groupId(original.group)) changes.group = draft.group;
|
|
92
|
+
|
|
93
|
+
const secrecyChanged = draft.is_secret !== original.is_secret;
|
|
94
|
+
if (secrecyChanged) {
|
|
95
|
+
if (!draft.valueTouched) {
|
|
96
|
+
throw new Error('Enter a replacement value when changing secret status');
|
|
97
|
+
}
|
|
98
|
+
if (draft.is_secret && draft.value === '') throw new Error('Enter a secret replacement value');
|
|
99
|
+
if (draft.is_secret) {
|
|
100
|
+
// Plain → Secret: stage the plaintext while the row is still
|
|
101
|
+
// plain, then flip the flag so on_rest_pre_save encrypts it.
|
|
102
|
+
changes.value = draft.value;
|
|
103
|
+
changes.is_secret = true;
|
|
104
|
+
} else {
|
|
105
|
+
// Secret → Plain: flip first, then write the explicit plain
|
|
106
|
+
// replacement. Empty is valid in this direction.
|
|
107
|
+
changes.is_secret = false;
|
|
108
|
+
changes.value = draft.value;
|
|
109
|
+
}
|
|
110
|
+
} else if (original.is_secret) {
|
|
111
|
+
// Blank means preserve the write-only value. It never becomes a
|
|
112
|
+
// placeholder payload, even if the input was focused and cleared.
|
|
113
|
+
if (draft.valueTouched && draft.value !== '') changes.value = draft.value;
|
|
114
|
+
} else if (draft.valueTouched && draft.value !== (original.value ?? original.display_value ?? '')) {
|
|
115
|
+
changes.value = draft.value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Object.keys(changes).length > 0 ? changes : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Secret-bearing writes deliberately bypass TanStack MutationCache: mutation
|
|
123
|
+
* variables retain submitted bodies. Only the sanitized response may leave
|
|
124
|
+
* this helper and enter Query cache.
|
|
125
|
+
*/
|
|
126
|
+
export async function saveSettingAtomic(
|
|
127
|
+
original: SettingRow | null,
|
|
128
|
+
draft: SettingDraft,
|
|
129
|
+
): Promise<SettingRow | null> {
|
|
130
|
+
const changes = buildSettingPayload(draft, original);
|
|
131
|
+
if (changes == null) return null;
|
|
132
|
+
const row = await withFreshAuth(() => mojoSave<SettingRow>(
|
|
133
|
+
SettingModel.endpoint,
|
|
134
|
+
original?.id ?? null,
|
|
135
|
+
changes,
|
|
136
|
+
));
|
|
137
|
+
return sanitizeSettingRow(row);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function settingGroupLabel(group: SettingRow['group']): string {
|
|
141
|
+
if (group == null) return 'Global';
|
|
142
|
+
return typeof group === 'number' ? `Group #${group}` : group.name;
|
|
143
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { ArmedButton, DetailView, FlatRow, fmt, toast } from '../../ui';
|
|
4
|
+
import {
|
|
5
|
+
ShortlinkModel, ShortlinkReconciliationError, deleteShortlink, functionalShortlinkUrl,
|
|
6
|
+
refreshShortlinks, setShortlinkActive, useTrackedCounts,
|
|
7
|
+
} from './models';
|
|
8
|
+
|
|
9
|
+
export function ShortlinkDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
10
|
+
const queryClient = useQueryClient();
|
|
11
|
+
const query = ShortlinkModel.useOne(id);
|
|
12
|
+
const [retryBlocked, setRetryBlocked] = useState(false);
|
|
13
|
+
const counts = useTrackedCounts(query.data ?? null);
|
|
14
|
+
if (!query.data) return <div className="modal-pad"><p>{query.isLoading ? 'Loading…' : 'Shortlink unavailable.'}</p><div className="modal-actions"><button className="btn" onClick={onClose}>Close</button></div></div>;
|
|
15
|
+
const row = query.data;
|
|
16
|
+
const capability = functionalShortlinkUrl(row.code);
|
|
17
|
+
const act = async (action: () => Promise<unknown>, success: string): Promise<boolean> => {
|
|
18
|
+
if (retryBlocked) return false;
|
|
19
|
+
try { await action(); toast.success(success); await query.refetch(); return true; }
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error instanceof ShortlinkReconciliationError) setRetryBlocked(true);
|
|
22
|
+
toast.error(error instanceof Error ? error.message : 'Shortlink operation failed');
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const refresh = async () => {
|
|
27
|
+
try { await refreshShortlinks(queryClient); await query.refetch(); setRetryBlocked(false); toast.success('Authoritative shortlink state refreshed'); }
|
|
28
|
+
catch { toast.error('Refresh failed. Mutations remain blocked.'); }
|
|
29
|
+
};
|
|
30
|
+
return <DetailView title={row.code} subtitle={row.source || 'No source label'} icon="bi-link-45deg"
|
|
31
|
+
chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }]}
|
|
32
|
+
onClose={onClose} sections={[
|
|
33
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section">
|
|
34
|
+
{retryBlocked && <div className="form-alert">The last change may have persisted, but reconciliation failed. Refresh successfully before retrying.</div>}
|
|
35
|
+
<div className="shortlink-actions"><button className="btn" onClick={() => void navigator.clipboard.writeText(capability)}><i className="bi bi-copy" /> Copy short URL</button><button className="btn" onClick={() => void refresh()}><i className="bi bi-arrow-clockwise" /> Refresh</button></div>
|
|
36
|
+
<FlatRow label="Short URL"><code>{capability}</code></FlatRow>
|
|
37
|
+
<FlatRow label="Source">{row.source || '—'}</FlatRow><FlatRow label="Hits">{row.hit_count}</FlatRow>
|
|
38
|
+
<FlatRow label="Expires">{row.expires_at ? fmt.datetime(row.expires_at) : 'Never'}</FlatRow>
|
|
39
|
+
<FlatRow label="Tracking">{row.track_clicks ? 'Enabled' : 'Disabled'}</FlatRow>
|
|
40
|
+
<FlatRow label="Bot passthrough">{row.bot_passthrough ? 'Enabled' : 'Disabled'}</FlatRow>
|
|
41
|
+
<FlatRow label="Protected">{row.is_protected ? 'Yes' : 'No'}</FlatRow>
|
|
42
|
+
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
43
|
+
</div> },
|
|
44
|
+
{ key: 'traffic', label: 'Traffic', icon: 'bi-bar-chart', render: () => <div className="detail-section">
|
|
45
|
+
<h3>Tracked records</h3><p className="dim">Human and bot values count retained tracking records. Total hits may also include requests made while tracking was off.</p>
|
|
46
|
+
{counts.isLoading ? <p>Loading counts…</p> : counts.isError ? <div className="form-alert">Tracked counts unavailable.</div> : <div className="shortlink-counts"><div><b>{counts.data?.human ?? 0}</b><span>Human records</span></div><div><b>{counts.data?.bot ?? 0}</b><span>Bot records</span></div><div><b>{counts.data?.remainder ?? row.hit_count}</b><span>Hit-count remainder</span></div></div>}
|
|
47
|
+
</div> },
|
|
48
|
+
{ key: 'controls', label: 'Controls', icon: 'bi-sliders', render: () => <div className="detail-section">
|
|
49
|
+
<h3>{row.is_active ? 'Deactivate link' : 'Reactivate link'}</h3><p>Changes use a single non-retrying request followed by an authoritative refresh.</p>
|
|
50
|
+
<button className="btn" disabled={retryBlocked} onClick={() => void act(() => setShortlinkActive(queryClient, row, !row.is_active), row.is_active ? 'Shortlink deactivated' : 'Shortlink reactivated')}>{row.is_active ? 'Deactivate' : 'Reactivate'}</button>
|
|
51
|
+
<h3>Delete link</h3><p>Deletion is permanent. Click history is removed by the backend cascade.</p>
|
|
52
|
+
<ArmedButton label="Delete shortlink" armedLabel={`Click again to delete ${row.code}`} disabled={retryBlocked} onConfirm={async () => { if (await act(() => deleteShortlink(queryClient, row), 'Shortlink deleted')) onClose(); }} />
|
|
53
|
+
</div> },
|
|
54
|
+
]} />;
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Badge, ModelTable, fmt, type Column, type FilterDef } from '../../ui';
|
|
2
|
+
import { ShortlinkHistoryModel, type ShortlinkHistoryRow } from './models';
|
|
3
|
+
|
|
4
|
+
const columns: Column<ShortlinkHistoryRow>[] = [
|
|
5
|
+
{ key: 'created', label: 'Created', sortable: true, hideable: false, render: (row) => fmt.datetime(row.created) },
|
|
6
|
+
{ key: 'shortlink', label: 'Code', render: (row) => row.code ? <code>{row.code}</code> : '—' },
|
|
7
|
+
{ key: 'is_bot', label: 'Audience', sortable: true, render: (row) => <Badge tone={row.is_bot ? 'warning' : 'info'}>{row.is_bot ? 'Bot' : 'Human'}</Badge> },
|
|
8
|
+
{ key: 'agent_summary', label: 'Agent summary' }, { key: 'referer_origin', label: 'Referrer origin', render: (row) => row.referer_origin ?? 'Direct / unknown' },
|
|
9
|
+
];
|
|
10
|
+
const filters: FilterDef[] = [
|
|
11
|
+
{ key: 'shortlink', label: 'Shortlink ID', type: 'number' }, { key: 'is_bot', label: 'Bot', type: 'boolean' },
|
|
12
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function ShortlinkHistoryPage() {
|
|
16
|
+
return <ModelTable model={ShortlinkHistoryModel} eyebrow="Communications · Redirects" title="Tracked click records"
|
|
17
|
+
columns={columns} filters={filters} searchable={false} defaultSort="-created" columnChooser persistState persistKey="admin:shortlinks:history" />;
|
|
18
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Badge, ModelTable, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
|
|
4
|
+
import {
|
|
5
|
+
ShortlinkModel, ShortlinkReconciliationError, createShortlink, functionalShortlinkUrl,
|
|
6
|
+
refreshShortlinks, type ShortlinkRow,
|
|
7
|
+
} from './models';
|
|
8
|
+
import { ShortlinkDetail } from './ShortlinkDetail';
|
|
9
|
+
|
|
10
|
+
const columns: Column<ShortlinkRow>[] = [
|
|
11
|
+
{ key: 'code', label: 'Code', sortable: true, hideable: false, render: (row) => <code>{row.code}</code> },
|
|
12
|
+
{ key: 'source', label: 'Source', sortable: true, render: (row) => row.source || '—' },
|
|
13
|
+
{ key: 'is_active', label: 'State', sortable: true, render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge> },
|
|
14
|
+
{ key: 'hit_count', label: 'Hits', sortable: true, align: 'end' },
|
|
15
|
+
{ key: 'expires_at', label: 'Expires', sortable: true, render: (row) => row.expires_at ? fmt.datetime(row.expires_at) : 'Never' },
|
|
16
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.datetime(row.created) },
|
|
17
|
+
];
|
|
18
|
+
const filters: FilterDef[] = [
|
|
19
|
+
{ key: 'source', label: 'Source', type: 'text' }, { key: 'is_active', label: 'Active', type: 'boolean' },
|
|
20
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function CreateShortlink({ close, blockRetries }: { close: (value: unknown) => void; blockRetries: () => void }) {
|
|
24
|
+
const queryClient = useQueryClient();
|
|
25
|
+
const [url, setUrl] = useState(''); const [source, setSource] = useState(''); const [days, setDays] = useState('3');
|
|
26
|
+
const [track, setTrack] = useState(false); const [botPassthrough, setBotPassthrough] = useState(false); const [busy, setBusy] = useState(false);
|
|
27
|
+
const submit = async (event: React.FormEvent) => {
|
|
28
|
+
event.preventDefault(); setBusy(true);
|
|
29
|
+
try {
|
|
30
|
+
const row = await createShortlink(queryClient, { url, source, expire_days: Math.max(0, Number(days) || 0), track_clicks: track, bot_passthrough: botPassthrough });
|
|
31
|
+
toast.success(`Shortlink ${row.code} created`); close(row);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof ShortlinkReconciliationError) blockRetries();
|
|
34
|
+
toast.error(error instanceof Error ? error.message : 'Shortlink creation failed');
|
|
35
|
+
} finally { setBusy(false); }
|
|
36
|
+
};
|
|
37
|
+
return <form className="modal-pad" onSubmit={(event) => void submit(event)}><h2 className="modal-title">Create shortlink</h2><p className="dim">The destination is sent once to the API and is deliberately omitted from cached Admin rows.</p>
|
|
38
|
+
<label className="field"><span className="field-label">Destination URL</span><input className="input" type="url" required value={url} onChange={(event) => setUrl(event.target.value)} /></label>
|
|
39
|
+
<label className="field"><span className="field-label">Source label</span><input className="input" maxLength={50} value={source} onChange={(event) => setSource(event.target.value)} /></label>
|
|
40
|
+
<label className="field"><span className="field-label">Expire after days</span><input className="input" type="number" min="0" value={days} onChange={(event) => setDays(event.target.value)} /><span className="field-help">0 means no expiry.</span></label>
|
|
41
|
+
<label className="check-row"><input type="checkbox" checked={track} onChange={(event) => setTrack(event.target.checked)} /> Track click records</label>
|
|
42
|
+
<label className="check-row"><input type="checkbox" checked={botPassthrough} onChange={(event) => setBotPassthrough(event.target.checked)} /> Allow known preview bots through</label>
|
|
43
|
+
<div className="modal-actions"><button type="button" className="btn" onClick={() => close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy}>{busy ? 'Creating…' : 'Create'}</button></div>
|
|
44
|
+
</form>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function showShortlinkDetail(id: number): void { void modal.detail((close) => <ShortlinkDetail id={id} onClose={() => close(null)} />); }
|
|
48
|
+
|
|
49
|
+
export function ShortlinksPage() {
|
|
50
|
+
const queryClient = useQueryClient(); const [retryBlocked, setRetryBlocked] = useState(false);
|
|
51
|
+
const refresh = async () => { try { await refreshShortlinks(queryClient); setRetryBlocked(false); toast.success('Shortlinks refreshed'); } catch { toast.error('Refresh failed. Mutations remain blocked.'); } };
|
|
52
|
+
return <div className="shortlinks-page">{retryBlocked && <div className="form-alert shortlink-page-alert">A change may have persisted without authoritative reconciliation. Creation is blocked until refresh succeeds. <button className="btn btn-compact" onClick={() => void refresh()}>Refresh</button></div>}
|
|
53
|
+
<ModelTable model={ShortlinkModel} eyebrow="Communications · Redirects" title="Shortlinks" columns={columns} filters={filters}
|
|
54
|
+
searchable={false} defaultSort="-created" columnChooser persistState persistKey="admin:shortlinks:links"
|
|
55
|
+
addLabel="Create shortlink" onAdd={retryBlocked ? undefined : () => void modal.open((close) => <CreateShortlink close={close} blockRetries={() => setRetryBlocked(true)} />, { size: 'sm' })}
|
|
56
|
+
onRowClick={(row) => showShortlinkDetail(row.id)} />
|
|
57
|
+
<p className="shortlink-origin-note">Displayed short URLs use the configured API origin: <code>{functionalShortlinkUrl('code')}</code>.</p>
|
|
58
|
+
</div>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AdminSection } from '../index';
|
|
2
|
+
import { ShortlinkHistoryPage } from './ShortlinkHistoryPage';
|
|
3
|
+
import { ShortlinksPage } from './ShortlinksPage';
|
|
4
|
+
import { SHORTLINK_MANAGE_PERMISSIONS } from './models';
|
|
5
|
+
|
|
6
|
+
export * from './models';
|
|
7
|
+
export * from './ShortlinksPage';
|
|
8
|
+
export * from './ShortlinkHistoryPage';
|
|
9
|
+
export * from './ShortlinkDetail';
|
|
10
|
+
|
|
11
|
+
export const SHORTLINKS_ADMIN_SECTION: AdminSection = {
|
|
12
|
+
id: 'shortlinks', basePath: 'shortlinks', title: 'Shortlinks', icon: 'bi-link-45deg', navigationGroup: 'communications',
|
|
13
|
+
permissions: SHORTLINK_MANAGE_PERMISSIONS,
|
|
14
|
+
routes: [
|
|
15
|
+
{ path: 'links', label: 'Links', component: ShortlinksPage, permissions: SHORTLINK_MANAGE_PERMISSIONS },
|
|
16
|
+
{ path: 'history', label: 'Click history', component: ShortlinkHistoryPage, permissions: SHORTLINK_MANAGE_PERMISSIONS },
|
|
17
|
+
],
|
|
18
|
+
};
|