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,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback, useEffect, useMemo, useRef, useState,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
} from 'react';
|
|
5
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import {
|
|
7
|
+
CollectionSelect, FilePicker, UploadQueue, fmt, modal, toast,
|
|
8
|
+
useFileDrop, useUploadQueue, validateFileSelection,
|
|
9
|
+
type FileSelectionResult,
|
|
10
|
+
} from '../../ui';
|
|
11
|
+
import type { StartFileUploadOptions, UploadedFileRef } from '../../client/upload';
|
|
12
|
+
import {
|
|
13
|
+
FileManagerUploadPolicyModel, FileModel,
|
|
14
|
+
type FileManagerUploadPolicyRow, type RelationRow,
|
|
15
|
+
} from './models';
|
|
16
|
+
|
|
17
|
+
const MANAGER_LIMIT = 50;
|
|
18
|
+
const QUEUE_CAPACITY = 6;
|
|
19
|
+
|
|
20
|
+
function policyAccept(policy: FileManagerUploadPolicyRow | null): string[] {
|
|
21
|
+
if (!policy) return [];
|
|
22
|
+
return [
|
|
23
|
+
...policy.allowed_mime_types,
|
|
24
|
+
...policy.allowed_extensions.map((extension) => `.${extension.replace(/^\./, '').toLowerCase()}`),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function policySummary(policy: FileManagerUploadPolicyRow): ReactNode {
|
|
29
|
+
const types = [...policy.allowed_extensions.map((value) => `.${value.replace(/^\./, '')}`), ...policy.allowed_mime_types];
|
|
30
|
+
return (
|
|
31
|
+
<dl className="storage-upload-policy">
|
|
32
|
+
<div><dt>Maximum size</dt><dd>{policy.max_file_size > 0 ? fmt.filesize(policy.max_file_size) : 'No configured limit'}</dd></div>
|
|
33
|
+
<div><dt>Accepted types</dt><dd>{types.length ? types.join(', ') : 'Any type'}</dd></div>
|
|
34
|
+
<div><dt>Transfer route</dt><dd>{policy.supports_direct_upload ? 'Direct provider target' : 'Local API upload target'}</dd></div>
|
|
35
|
+
</dl>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface DestinationDialogProps {
|
|
40
|
+
initialFiles: File[];
|
|
41
|
+
canChooseGroup: boolean;
|
|
42
|
+
remaining: number;
|
|
43
|
+
close: () => void;
|
|
44
|
+
enqueue: (files: File[], destination: StartFileUploadOptions) => boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function DestinationDialog({ initialFiles, canChooseGroup, remaining, close, enqueue }: DestinationDialogProps) {
|
|
48
|
+
const [scope, setScope] = useState<'system' | 'group'>('system');
|
|
49
|
+
const [groupId, setGroupId] = useState<number | null>(null);
|
|
50
|
+
const [managerId, setManagerId] = useState<number | null>(null);
|
|
51
|
+
const [files, setFiles] = useState<File[]>(initialFiles);
|
|
52
|
+
const [rejections, setRejections] = useState<string[]>([]);
|
|
53
|
+
const groupScope = scope === 'group';
|
|
54
|
+
const params = groupScope
|
|
55
|
+
? { group: groupId ?? undefined, user__isnull: true, size: MANAGER_LIMIT }
|
|
56
|
+
: { group__isnull: true, user__isnull: true, size: MANAGER_LIMIT };
|
|
57
|
+
const managersQuery = FileManagerUploadPolicyModel.useList(params, {
|
|
58
|
+
enabled: !groupScope || groupId != null,
|
|
59
|
+
});
|
|
60
|
+
const managers = managersQuery.data?.rows ?? [];
|
|
61
|
+
const truncated = Number(managersQuery.data?.count ?? 0) > managers.length;
|
|
62
|
+
const policy = managers.find((row) => row.id === managerId) ?? null;
|
|
63
|
+
const accept = policyAccept(policy);
|
|
64
|
+
|
|
65
|
+
useEffect(() => { setManagerId(null); }, [scope, groupId]);
|
|
66
|
+
|
|
67
|
+
const validate = useCallback((selected: Iterable<File>): FileSelectionResult => validateFileSelection(selected, {
|
|
68
|
+
accept,
|
|
69
|
+
maxFileSize: policy && policy.max_file_size > 0 ? policy.max_file_size : undefined,
|
|
70
|
+
maxFiles: Math.max(1, remaining),
|
|
71
|
+
}), [accept, policy, remaining]);
|
|
72
|
+
|
|
73
|
+
const selectFiles = (selection: FileSelectionResult) => {
|
|
74
|
+
setFiles(selection.accepted);
|
|
75
|
+
setRejections(selection.rejected.map((item) => item.message));
|
|
76
|
+
};
|
|
77
|
+
const submit = () => {
|
|
78
|
+
if (!policy) return;
|
|
79
|
+
const selection = validate(files);
|
|
80
|
+
setRejections(selection.rejected.map((item) => item.message));
|
|
81
|
+
if (!selection.accepted.length) return;
|
|
82
|
+
const accepted = enqueue(selection.accepted, {
|
|
83
|
+
fileManagerId: policy.id,
|
|
84
|
+
...(groupScope && groupId != null ? { groupId } : {}),
|
|
85
|
+
...(policy.use ? { use: policy.use } : {}),
|
|
86
|
+
});
|
|
87
|
+
if (accepted) close();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="modal-pad storage-upload-dialog">
|
|
92
|
+
<h2 className="modal-title">Add files</h2>
|
|
93
|
+
<p className="modal-message">Choose an explicit authorized destination. Policy checks here are guidance; the server authorizes every upload.</p>
|
|
94
|
+
{canChooseGroup && (
|
|
95
|
+
<div className="seg storage-upload-scope" aria-label="Destination scope">
|
|
96
|
+
<button type="button" className={`seg-btn${scope === 'system' ? ' seg-active' : ''}`} onClick={() => setScope('system')}>System</button>
|
|
97
|
+
<button type="button" className={`seg-btn${scope === 'group' ? ' seg-active' : ''}`} onClick={() => setScope('group')}>Group</button>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
{groupScope && canChooseGroup && (
|
|
101
|
+
<CollectionSelect<RelationRow>
|
|
102
|
+
endpoint="/api/group" value={groupId}
|
|
103
|
+
onChange={(id) => setGroupId(id == null ? null : Number(id))}
|
|
104
|
+
defaultParams={{ is_active: true, size: 25 }} maxItems={25}
|
|
105
|
+
label="Destination group" placeholder="Search authorized active groups" required
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
<label className="field">
|
|
109
|
+
<span>Storage backend</span>
|
|
110
|
+
<select value={managerId ?? ''} disabled={managersQuery.isLoading || truncated || (groupScope && groupId == null)} onChange={(event) => setManagerId(event.target.value ? Number(event.target.value) : null)}>
|
|
111
|
+
<option value="">{managersQuery.isLoading ? 'Loading authorized backends…' : 'Choose a backend'}</option>
|
|
112
|
+
{managers.map((manager) => <option key={manager.id} value={manager.id}>{manager.name}{manager.use ? ` · ${manager.use}` : ''}</option>)}
|
|
113
|
+
</select>
|
|
114
|
+
</label>
|
|
115
|
+
{managersQuery.isError && <p className="storage-error" role="alert">Authorized upload backends could not be loaded.</p>}
|
|
116
|
+
{truncated && <p className="storage-error" role="alert">More than {MANAGER_LIMIT} backends match. Narrow the destination before uploading.</p>}
|
|
117
|
+
{!managersQuery.isLoading && !managersQuery.isError && !truncated && managers.length === 0 && (!groupScope || groupId != null) && <p className="storage-footnote">No active upload backend is available for this scope.</p>}
|
|
118
|
+
{policy && policySummary(policy)}
|
|
119
|
+
{policy && files.length === 0 && (
|
|
120
|
+
<FilePicker multiple accept={accept} maxFileSize={policy.max_file_size > 0 ? policy.max_file_size : undefined} maxFiles={Math.max(1, remaining)} onSelection={selectFiles}>
|
|
121
|
+
Choose files
|
|
122
|
+
</FilePicker>
|
|
123
|
+
)}
|
|
124
|
+
{files.length > 0 && <p className="storage-upload-selection"><strong>{files.length}</strong> file{files.length === 1 ? '' : 's'} ready · {fmt.filesize(files.reduce((sum, file) => sum + file.size, 0))}</p>}
|
|
125
|
+
{rejections.length > 0 && <ul className="storage-upload-rejections" role="alert">{rejections.map((message, index) => <li key={`${index}:${message}`}>{message}</li>)}</ul>}
|
|
126
|
+
<div className="modal-actions">
|
|
127
|
+
<button type="button" className="btn" onClick={close}>Cancel</button>
|
|
128
|
+
<button type="button" className="btn btn-primary" disabled={!policy || files.length === 0 || truncated} onClick={submit}>Upload {files.length || ''}</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface FileUploadSurfaceProps {
|
|
135
|
+
canManage: boolean;
|
|
136
|
+
canChooseGroup: boolean;
|
|
137
|
+
children: (openPicker: () => void) => ReactNode;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Admin Files upload owner. Files/tasks stay queue-private and never enter Query cache. */
|
|
141
|
+
export function FileUploadSurface({ canManage, canChooseGroup, children }: FileUploadSurfaceProps) {
|
|
142
|
+
const queryClient = useQueryClient();
|
|
143
|
+
const queue = useUploadQueue({ concurrency: 3, capacity: QUEUE_CAPACITY, consumerKey: 'admin-storage-files' });
|
|
144
|
+
const modalOpen = useRef(false);
|
|
145
|
+
const closeModal = useRef<(() => void) | null>(null);
|
|
146
|
+
const previousPermission = useRef(canManage);
|
|
147
|
+
const terminalSignature = useRef('');
|
|
148
|
+
const dirty = useRef(false);
|
|
149
|
+
const refreshPromise = useRef<Promise<void> | null>(null);
|
|
150
|
+
const [refreshMessage, setRefreshMessage] = useState<string | null>(null);
|
|
151
|
+
|
|
152
|
+
const cancelDestinationQueries = useCallback(() => {
|
|
153
|
+
void queryClient.cancelQueries({
|
|
154
|
+
predicate: (query) => query.queryKey[0] === FileManagerUploadPolicyModel.endpoint
|
|
155
|
+
&& typeof query.queryKey[1] === 'object'
|
|
156
|
+
&& (query.queryKey[1] as Record<string, unknown>)?.graph === 'upload_policy',
|
|
157
|
+
});
|
|
158
|
+
void queryClient.cancelQueries({ queryKey: ['/api/group'] });
|
|
159
|
+
}, [queryClient]);
|
|
160
|
+
|
|
161
|
+
const reconcile = useCallback(() => {
|
|
162
|
+
if (refreshPromise.current) return refreshPromise.current;
|
|
163
|
+
const run = async () => {
|
|
164
|
+
do {
|
|
165
|
+
dirty.current = false;
|
|
166
|
+
try {
|
|
167
|
+
await queryClient.invalidateQueries({ queryKey: FileModel.keys.root, refetchType: 'none' });
|
|
168
|
+
await queryClient.refetchQueries({ type: 'active', queryKey: FileModel.keys.root }, { throwOnError: true });
|
|
169
|
+
setRefreshMessage('Authoritative refresh complete. Current filters may hide newly uploaded files.');
|
|
170
|
+
} catch {
|
|
171
|
+
setRefreshMessage('Uploads may have changed, but the authoritative Files refresh failed. Refresh the table before another action.');
|
|
172
|
+
}
|
|
173
|
+
} while (dirty.current);
|
|
174
|
+
};
|
|
175
|
+
refreshPromise.current = run().finally(() => { refreshPromise.current = null; });
|
|
176
|
+
return refreshPromise.current;
|
|
177
|
+
}, [queryClient]);
|
|
178
|
+
|
|
179
|
+
const onComplete = useCallback(async (file: UploadedFileRef) => {
|
|
180
|
+
if (file.id < 1) throw new Error('Upload completion did not include an authoritative File id');
|
|
181
|
+
dirty.current = true;
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
const enqueue = useCallback((files: File[], destination: StartFileUploadOptions): boolean => {
|
|
185
|
+
const result = queue.enqueue(files, { consumerKey: 'admin-storage-files', destination, onComplete });
|
|
186
|
+
result.rejected.forEach((item) => toast.warning(item.message));
|
|
187
|
+
if (result.acceptedIds.length) setRefreshMessage(null);
|
|
188
|
+
return result.acceptedIds.length > 0;
|
|
189
|
+
}, [queue, onComplete]);
|
|
190
|
+
|
|
191
|
+
const open = useCallback((files: File[] = []) => {
|
|
192
|
+
if (!canManage || modalOpen.current) return;
|
|
193
|
+
modalOpen.current = true;
|
|
194
|
+
const remaining = Math.max(0, QUEUE_CAPACITY - queue.snapshot.items.length);
|
|
195
|
+
if (remaining === 0) {
|
|
196
|
+
toast.warning(`The upload queue can hold ${QUEUE_CAPACITY} files.`);
|
|
197
|
+
modalOpen.current = false;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
files.slice(remaining).forEach((file) => toast.warning(`${file.name} was not added because the upload queue can hold ${QUEUE_CAPACITY} files.`));
|
|
201
|
+
void modal.open((close) => {
|
|
202
|
+
closeModal.current = () => close(null);
|
|
203
|
+
return <DestinationDialog initialFiles={files.slice(0, remaining)} canChooseGroup={canChooseGroup} remaining={remaining} enqueue={enqueue} close={() => close(null)} />;
|
|
204
|
+
}, { size: 'lg' }).finally(() => {
|
|
205
|
+
closeModal.current = null;
|
|
206
|
+
modalOpen.current = false;
|
|
207
|
+
});
|
|
208
|
+
}, [canManage, canChooseGroup, enqueue, queue.snapshot.items.length]);
|
|
209
|
+
|
|
210
|
+
const dropSelection = useCallback((selection: FileSelectionResult) => {
|
|
211
|
+
selection.rejected.forEach((item) => toast.warning(item.message));
|
|
212
|
+
if (selection.accepted.length) open(selection.accepted);
|
|
213
|
+
}, [open]);
|
|
214
|
+
const { isDragActive, dropProps } = useFileDrop({ disabled: !canManage, maxFiles: QUEUE_CAPACITY, onSelection: dropSelection });
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (previousPermission.current && !canManage) {
|
|
218
|
+
closeModal.current?.();
|
|
219
|
+
cancelDestinationQueries();
|
|
220
|
+
queue.cancelAll();
|
|
221
|
+
}
|
|
222
|
+
previousPermission.current = canManage;
|
|
223
|
+
}, [canManage, cancelDestinationQueries, queue]);
|
|
224
|
+
|
|
225
|
+
useEffect(() => () => {
|
|
226
|
+
closeModal.current?.();
|
|
227
|
+
cancelDestinationQueries();
|
|
228
|
+
queue.cancelAll();
|
|
229
|
+
}, [cancelDestinationQueries, queue]);
|
|
230
|
+
|
|
231
|
+
const terminal = useMemo(() => queue.snapshot.items
|
|
232
|
+
.filter((item) => ['completed', 'completed-warning', 'failed', 'cancelled', 'uncertain'].includes(item.status))
|
|
233
|
+
.map((item) => `${item.id}:${item.status}:${item.file?.id ?? ''}`).join('|'), [queue.snapshot.items]);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (terminal !== terminalSignature.current) {
|
|
236
|
+
terminalSignature.current = terminal;
|
|
237
|
+
if (terminal) dirty.current = true;
|
|
238
|
+
}
|
|
239
|
+
if (dirty.current && queue.snapshot.activeCount === 0 && queue.snapshot.queuedCount === 0) void reconcile();
|
|
240
|
+
}, [terminal, queue.snapshot.activeCount, queue.snapshot.queuedCount, reconcile]);
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div className={`storage-file-upload-surface${isDragActive ? ' is-drag-active' : ''}`} {...dropProps}>
|
|
244
|
+
{children(() => open())}
|
|
245
|
+
{isDragActive && <div className="storage-page-drop-overlay" aria-hidden="true"><i className="bi bi-cloud-arrow-up" /><strong>Drop files to choose their destination</strong></div>}
|
|
246
|
+
<UploadQueue queue={queue} className="storage-upload-queue" />
|
|
247
|
+
{refreshMessage && <p className={`storage-upload-refresh${refreshMessage.includes('failed') ? ' is-error' : ''}`} role="status">{refreshMessage}</p>}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { ArmedButton, Badge, DataView, DetailView, FlatRow, SchemaForm, fmt, modal, toast } from '../../ui';
|
|
4
|
+
import { useCan } from '../../client';
|
|
5
|
+
import { FilePreview, filePreviewCategory } from './FilePreview';
|
|
6
|
+
import { normalizeRenditions, normalizeRenditionRoles, pollRenditionConvergence, renditionTargetSignature, type RenditionPollStop } from './file-renditions';
|
|
7
|
+
import { FileModel, ShortLinkShareModel, STORAGE_MANAGE_PERMS, createFileShare, deleteFileShare, isSafeCapabilityUrl, openCapabilityUrl, setFileShareActive, saveFileAndReconcileGroup, type FileRenditionRow } from './models';
|
|
8
|
+
|
|
9
|
+
function ShareResult({ url, close }: { url: string; close: () => void }) {
|
|
10
|
+
return <div className="modal-pad"><h2 className="modal-title">Share link created</h2><p className="modal-message">This newly minted capability is shown once from component-local state.</p><code className="storage-capability">{url}</code><div className="modal-actions"><button className="btn" onClick={() => void navigator.clipboard.writeText(url)}>Copy</button><button className="btn btn-primary" onClick={close}>Done</button></div></div>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function RenditionRows({ rows, canManage, pollingDisabled, onRegenerate }: { rows: FileRenditionRow[]; canManage: boolean; pollingDisabled: boolean; onRegenerate: (roles: string[]) => void }) {
|
|
14
|
+
if (!rows.length) return <p className="dim">No renditions have appeared yet.</p>;
|
|
15
|
+
return <div className="storage-renditions">{rows.map((row) => <div className="storage-rendition" key={`${row.role}:${row.id}`}><div><b>{row.role}</b><small>{row.content_type} · {row.width && row.height ? `${row.width}×${row.height} · ` : ''}{row.file_size == null ? 'unknown size' : fmt.filesize(row.file_size)} · {row.upload_status}</small></div><div className="storage-action-row">{isSafeCapabilityUrl(row.url) && <><button className="btn-icon" title="Open rendition" onClick={() => openCapabilityUrl(row.url!)}><i className="bi bi-box-arrow-up-right" /></button><button className="btn-icon" title="Download rendition" onClick={() => openCapabilityUrl(row.url!, true)}><i className="bi bi-download" /></button></>}{canManage && <button className="btn-icon" disabled={pollingDisabled} title={`Regenerate ${row.role}`} onClick={() => onRegenerate([row.role])}><i className="bi bi-arrow-clockwise" /></button>}</div></div>)}</div>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function FileView({ id, onClose }: { id: number; onClose: () => void }) {
|
|
19
|
+
const queryClient = useQueryClient();
|
|
20
|
+
const query = FileModel.useOne(id);
|
|
21
|
+
const canManage = useCan(STORAGE_MANAGE_PERMS).can;
|
|
22
|
+
const regenerate = FileModel.useAction('regenerate_renditions');
|
|
23
|
+
const remove = FileModel.useDelete();
|
|
24
|
+
const shares = ShortLinkShareModel.useList({ file: id, start: 0, size: 25 }, { enabled: canManage });
|
|
25
|
+
const alive = useRef(true);
|
|
26
|
+
const initialPollStarted = useRef(false);
|
|
27
|
+
const pollGeneration = useRef(0);
|
|
28
|
+
const pollInFlightRef = useRef(false);
|
|
29
|
+
const regenerationBusyRef = useRef(false);
|
|
30
|
+
const [regenerationBusy, setRegenerationBusy] = useState(false);
|
|
31
|
+
const [pollState, setPollState] = useState<RenditionPollStop | 'polling' | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
alive.current = true;
|
|
34
|
+
return () => {
|
|
35
|
+
alive.current = false;
|
|
36
|
+
initialPollStarted.current = false;
|
|
37
|
+
pollGeneration.current += 1;
|
|
38
|
+
};
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const startPoll = async (before: string, targetRoles: readonly string[] | null) => {
|
|
42
|
+
const generation = ++pollGeneration.current;
|
|
43
|
+
pollInFlightRef.current = true;
|
|
44
|
+
setPollState('polling');
|
|
45
|
+
try {
|
|
46
|
+
const reason = await pollRenditionConvergence({ fileId: id, beforeSignature: before, targetRoles, isCurrent: () => alive.current && pollGeneration.current === generation, fetch: async () => { const result = await query.refetch(); if (!result.data) throw new Error('File refresh returned no record'); return result.data; } });
|
|
47
|
+
if (alive.current && pollGeneration.current === generation) setPollState(reason);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (alive.current && pollGeneration.current === generation) { setPollState('timeout'); toast.error(error instanceof Error ? error.message : 'Rendition refresh failed'); }
|
|
50
|
+
} finally { if (pollGeneration.current === generation) pollInFlightRef.current = false; }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const file = query.data;
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!file || initialPollStarted.current || file.upload_status !== 'completed' || normalizeRenditions(file.renditions).length > 0) return;
|
|
56
|
+
if (!['image', 'video', 'document', 'spreadsheet', 'presentation', 'pdf'].includes(filePreviewCategory(file))) return;
|
|
57
|
+
initialPollStarted.current = true;
|
|
58
|
+
void startPoll(renditionTargetSignature(file, null), null);
|
|
59
|
+
}, [file?.id]);
|
|
60
|
+
|
|
61
|
+
if (query.isLoading) return <div className="empty"><p>Loading file…</p></div>;
|
|
62
|
+
if (!file) return <div className="empty"><h2>File unavailable</h2><p>{query.error instanceof Error ? query.error.message : 'The record was not returned.'}</p><button className="btn" onClick={onClose}>Close</button></div>;
|
|
63
|
+
const renditionRows = normalizeRenditions(file.renditions);
|
|
64
|
+
|
|
65
|
+
const updatePublic = async () => {
|
|
66
|
+
const next = !file.is_public;
|
|
67
|
+
const ok = await modal.confirm({ title: next ? 'Make file public?' : 'Make file private?', message: <>Change access for <b>{file.filename}</b>?</>, confirmText: next ? 'Make public' : 'Make private', danger: next });
|
|
68
|
+
if (!ok) return;
|
|
69
|
+
try { await saveFileAndReconcileGroup(queryClient, id, { is_public: next }); await query.refetch(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Access change failed'); }
|
|
70
|
+
};
|
|
71
|
+
const createShare = async () => {
|
|
72
|
+
const values = await modal.open<Record<string, unknown>>((close) => <div className="modal-pad"><h2 className="modal-title">Create share link</h2><SchemaForm fields={[{ name: 'expire_days', type: 'text', label: 'Expire after days', help: '0 means no expiry.' }, { name: 'track_clicks', type: 'switch', label: 'Track clicks' }, { name: 'note', type: 'textarea', label: 'Audit note' }]} initial={{ expire_days: '30', track_clicks: false, note: '' }} submitText="Create share" onCancel={() => close(null as never)} onSubmit={(data) => close(data as unknown as Record<string, unknown>)} /></div>);
|
|
73
|
+
if (!values) return;
|
|
74
|
+
try {
|
|
75
|
+
const result = await createFileShare(id, { expire_days: Number(values.expire_days ?? 30), track_clicks: Boolean(values.track_clicks), note: String(values.note ?? '') });
|
|
76
|
+
await modal.open((close) => <ShareResult url={result.url} close={() => close(null)} />, { size: 'sm' });
|
|
77
|
+
await shares.refetch().catch(() => undefined);
|
|
78
|
+
} catch (error) { toast.error(error instanceof Error ? error.message : 'Share creation failed'); }
|
|
79
|
+
};
|
|
80
|
+
const runRegenerate = async (roles: string[] = []) => {
|
|
81
|
+
if (regenerationBusyRef.current || pollInFlightRef.current || pollState === 'polling') return;
|
|
82
|
+
regenerationBusyRef.current = true;
|
|
83
|
+
setRegenerationBusy(true);
|
|
84
|
+
const normalized = normalizeRenditionRoles(roles);
|
|
85
|
+
try {
|
|
86
|
+
const ok = await modal.confirm({ title: normalized.length ? `Regenerate ${normalized.join(', ')}?` : 'Regenerate all renditions?', message: 'The renderer is asynchronous. This viewer polls finite observable state for up to 12 attempts.', confirmText: 'Queue regeneration' });
|
|
87
|
+
if (!ok) return;
|
|
88
|
+
const currentRoles = normalizeRenditions(file.renditions).map((row) => row.role);
|
|
89
|
+
const targetRoles = normalized.length > 0 ? normalized : currentRoles.length > 0 ? currentRoles : ['thumbnail', 'preview'];
|
|
90
|
+
const before = renditionTargetSignature(file, targetRoles);
|
|
91
|
+
await regenerate.mutateAsync({ id, payload: normalized.length ? normalized : true });
|
|
92
|
+
await startPoll(before, targetRoles);
|
|
93
|
+
} catch (error) { toast.error(error instanceof Error ? error.message : 'Could not queue renditions'); }
|
|
94
|
+
finally { regenerationBusyRef.current = false; setRegenerationBusy(false); }
|
|
95
|
+
};
|
|
96
|
+
const deleteOne = async () => {
|
|
97
|
+
await modal.open((close) => <div className="modal-pad"><h2 className="modal-title">Delete {file.filename}</h2><p className="modal-message">Enter the exact filename. This deletes the record, original backend object, and renditions.</p><SchemaForm fields={[{ name: 'filename', type: 'text', label: 'Exact filename', required: true }]} submitText="Delete file" onCancel={() => close(null)} onSubmit={async (data) => { if (String(data.filename ?? '') !== file.filename) throw new Error('Filename does not match. No request was sent.'); await remove.mutateAsync({ id }); close(true); onClose(); }} /></div>, { size: 'sm' });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return <DetailView title={file.filename} subtitle={`${file.content_type} · ${file.file_size == null ? 'unknown size' : fmt.filesize(file.file_size)}`} icon="bi-file-earmark" chips={[{ text: file.upload_status, tone: file.upload_status === 'completed' ? 'success' : file.upload_status === 'failed' ? 'danger' : 'warning' }, { text: file.is_public ? 'Public' : 'Private', tone: file.is_public ? 'warning' : 'muted' }]} sections={[
|
|
101
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><div className="storage-action-row">{isSafeCapabilityUrl(file.url) && <><button className="btn" onClick={() => openCapabilityUrl(file.url!)}><i className="bi bi-box-arrow-up-right" /> Open</button><button className="btn" onClick={() => openCapabilityUrl(file.url!, true)}><i className="bi bi-download" /> Download</button><button className="btn" onClick={() => void navigator.clipboard.writeText(file.url!)}><i className="bi bi-copy" /> Copy URL</button></>}{canManage && <button className="btn" onClick={() => void updatePublic()}>{file.is_public ? 'Make private…' : 'Make public…'}</button>}</div>{!isSafeCapabilityUrl(file.url) && <p className="storage-unsafe">The backend URL is absent or unsafe; Open, Download, and Copy are refused.</p>}<FlatRow label="Filename">{file.filename}</FlatRow><FlatRow label="Category">{file.category || 'Unknown'}</FlatRow><FlatRow label="Upload status"><Badge tone={file.upload_status === 'completed' ? 'success' : 'warning'}>{file.upload_status}</Badge></FlatRow><FlatRow label="Public">{file.is_public ? 'Yes' : 'No'}</FlatRow><FlatRow label="Active">{file.is_active ? 'Yes' : 'No'}</FlatRow></div> },
|
|
102
|
+
{ key: 'preview', label: 'Preview', icon: 'bi-eye', render: () => <div className="detail-section"><FilePreview file={file} /></div> },
|
|
103
|
+
{ key: 'details', label: 'Details', icon: 'bi-card-list', render: () => <div className="detail-section"><FlatRow label="ID">{file.id}</FlatRow><FlatRow label="Content type">{file.content_type}</FlatRow><FlatRow label="Size">{file.file_size == null ? 'Unknown' : fmt.filesize(file.file_size)}</FlatRow><FlatRow label="Group">{typeof file.group === 'object' && file.group ? file.group.name || `#${file.group.id}` : file.group == null ? 'System' : `#${file.group}`}</FlatRow><FlatRow label="Backend">{typeof file.file_manager === 'object' && file.file_manager ? file.file_manager.name || `#${file.file_manager.id}` : `#${file.file_manager ?? '—'}`}</FlatRow><FlatRow label="Created">{fmt.datetime(file.created)}</FlatRow><FlatRow label="Modified">{fmt.datetime(file.modified)}</FlatRow></div> },
|
|
104
|
+
{ key: 'renditions', label: 'Renditions', icon: 'bi-images', render: () => { const pollingDisabled = regenerationBusy || pollState === 'polling'; return <div className="detail-section"><div className="storage-section-head"><div><h3>Renditions</h3><p className="dim">Derived from the role-keyed File graph; no duplicate rendition store.</p></div>{canManage && <button className="btn" disabled={pollingDisabled} onClick={() => void runRegenerate()}>Regenerate all…</button>}</div><RenditionRows rows={renditionRows} canManage={canManage} pollingDisabled={pollingDisabled} onRegenerate={(roles) => void runRegenerate(roles)} />{pollState === 'polling' && <p className="dim"><i className="bi bi-arrow-repeat spin" /> Waiting for observable rendition change…</p>}{pollState === 'timeout' && <div className="form-alert">No convergence after 12 attempts. The job may still be running. <button className="btn btn-compact" onClick={() => void query.refetch()}>Refresh now</button></div>}{pollState && ['failed', 'expired'].includes(pollState) && <div className="form-alert">Polling stopped because upload state is {pollState}.</div>}</div>; } },
|
|
105
|
+
{ key: 'shares', label: 'Shares', icon: 'bi-share', render: () => <div className="detail-section"><div className="storage-section-head"><div><h3>Visible shares</h3><p className="dim">Owner-visible rows only unless the caller separately holds global shortlink authority.</p></div>{canManage && <button className="btn btn-primary" onClick={() => void createShare()}>Create share…</button>}</div>{!canManage ? <p className="dim">Share controls are unavailable for this viewer.</p> : shares.isError ? <div className="form-alert">Visible shares unavailable. File inspection remains available.</div> : (shares.data?.rows ?? []).length === 0 ? <p className="dim">No visible shares.</p> : <div className="storage-shares">{shares.data!.rows.map((share) => <div key={share.id}><span><code>{share.code}</code><small>{share.is_active ? 'Active' : 'Inactive'} · {share.expires_at ? fmt.datetime(share.expires_at) : 'No expiry'} · {share.hit_count} hits · {share.track_clicks ? 'tracking' : 'not tracking'}{share.note ? ` · ${share.note}` : ''}</small></span><div className="storage-action-row"><button className="btn btn-compact" onClick={async () => { await setFileShareActive(share.id, !share.is_active); await shares.refetch(); }}>{share.is_active ? 'Deactivate' : 'Activate'}</button><button className="btn btn-compact" onClick={async () => { if (!await modal.confirm({ title: `Delete ${share.code}?`, message: 'This permanently deletes the share and its click history.', confirmText: 'Delete', danger: true })) return; await deleteFileShare(share.id); await shares.refetch(); }}>Delete</button></div></div>)}</div>}</div> },
|
|
106
|
+
{ key: 'metadata', label: 'Metadata', icon: 'bi-braces', render: () => <div className="detail-section"><DataView data={file.metadata ?? {}} columns={2} /></div> },
|
|
107
|
+
...(canManage ? [{ key: 'danger', label: 'Danger zone', icon: 'bi-exclamation-octagon', permissions: STORAGE_MANAGE_PERMS, render: () => <div className="detail-section storage-danger"><h3>Delete file</h3><p>This removes the backend object and derived renditions. It cannot be undone.</p><ArmedButton className="btn-danger" icon="bi-trash" label="Delete file" armedLabel="Click again — open exact-filename confirmation" onConfirm={deleteOne} /></div> }] : []),
|
|
108
|
+
]} initialSection="overview" onClose={onClose} />;
|
|
109
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Badge, CollectionSelect, ModelTable, fmt, modal, type BatchAction, type Column, type FilterDef } from '../../ui';
|
|
4
|
+
import { useCan } from '../../client';
|
|
5
|
+
import { FileView } from './FileView';
|
|
6
|
+
import { FileUploadSurface } from './FileUploadSurface';
|
|
7
|
+
import { FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, exportFiles, openCapabilityUrl, saveFileAndReconcileGroup, type FileRow, type RelationRow } from './models';
|
|
8
|
+
|
|
9
|
+
const COLUMNS: Column<FileRow>[] = [
|
|
10
|
+
{ key: 'filename', label: 'Filename', sortable: true, hideable: false },
|
|
11
|
+
{ key: 'category', label: 'Category', sortable: true, render: (row) => <Badge tone="info">{row.category || 'unknown'}</Badge> },
|
|
12
|
+
{ key: 'content_type', label: 'Content type', sortable: true },
|
|
13
|
+
{ key: 'file_size', label: 'Size', sortable: true, align: 'end', render: (row) => row.file_size == null ? '—' : fmt.filesize(row.file_size) },
|
|
14
|
+
{ key: 'group', label: 'Group', render: (row) => typeof row.group === 'object' && row.group ? row.group.name || `#${row.group.id}` : row.group == null ? 'System' : `#${row.group}` },
|
|
15
|
+
{ key: 'file_manager', label: 'Backend', render: (row) => typeof row.file_manager === 'object' && row.file_manager ? row.file_manager.name || `#${row.file_manager.id}` : `#${row.file_manager ?? '—'}` },
|
|
16
|
+
{ key: 'upload_status', label: 'Upload', sortable: true, render: (row) => <Badge tone={row.upload_status === 'completed' ? 'success' : row.upload_status === 'failed' ? 'danger' : 'warning'}>{row.upload_status}</Badge> },
|
|
17
|
+
{ key: 'is_public', label: 'Access', sortable: true, render: (row) => <Badge tone={row.is_public ? 'warning' : 'muted'}>{row.is_public ? 'Public' : 'Private'}</Badge> },
|
|
18
|
+
{ key: 'user', label: 'Uploader', render: (row) => typeof row.user === 'object' && row.user ? row.user.display_name || row.user.name || `#${row.user.id}` : `#${row.user ?? '—'}` },
|
|
19
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.datetime(row.created) },
|
|
20
|
+
];
|
|
21
|
+
const FILTERS: FilterDef[] = [
|
|
22
|
+
{ key: 'upload_status', label: 'Upload status', type: 'select', options: ['pending', 'uploading', 'completed', 'failed', 'expired'].map((value) => ({ value, label: value })) },
|
|
23
|
+
{ key: 'is_public', label: 'Public', type: 'boolean' },
|
|
24
|
+
{ key: 'category', label: 'Category', type: 'select', options: ['image', 'video', 'audio', 'document', 'spreadsheet', 'presentation', 'archive', 'text'].map((value) => ({ value, label: value })) },
|
|
25
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function MoveGroupDialog({ rows, close }: { rows: FileRow[]; close: (value: number | null) => void }) {
|
|
29
|
+
const [group, setGroup] = useState<number | null>(null);
|
|
30
|
+
return <div className="modal-pad"><h2 className="modal-title">Move {rows.length} selected file{rows.length === 1 ? '' : 's'}</h2><p className="modal-message">Choose the explicit target group. Cancelling sends no request.</p><CollectionSelect<RelationRow> endpoint="/api/group" value={group} onChange={(id) => setGroup(id == null ? null : Number(id))} label="Target group" required placeholder="Search authorized groups" /><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Cancel</button><button className="btn btn-primary" disabled={group == null} onClick={() => close(group)}>Move files</button></div></div>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function showFileView(id: number): void { void modal.detail((close) => <FileView id={id} onClose={() => close(null)} />); }
|
|
34
|
+
|
|
35
|
+
export function FilesPage() {
|
|
36
|
+
const queryClient = useQueryClient();
|
|
37
|
+
const canManage = useCan(STORAGE_MANAGE_PERMS).can;
|
|
38
|
+
const canChooseGroup = useCan(GROUP_DIRECTORY_PERMS).can;
|
|
39
|
+
const remove = FileModel.useDelete();
|
|
40
|
+
const saveAccess = (row: FileRow, value: boolean) => saveFileAndReconcileGroup(queryClient, row.id, { is_public: value });
|
|
41
|
+
const batches: BatchAction<FileRow>[] = [
|
|
42
|
+
{ key: 'public', label: 'Make public', eligible: (row) => !row.is_public, confirm: 'Make the selected files public?', run: (row) => saveAccess(row, true) },
|
|
43
|
+
{ key: 'private', label: 'Make private', eligible: (row) => row.is_public, run: (row) => saveAccess(row, false) },
|
|
44
|
+
...(canChooseGroup ? [{ key: 'move', label: 'Move to group', confirm: false as const, prepare: (rows: FileRow[]) => modal.open<number>((close) => <MoveGroupDialog rows={rows} close={(value) => close(value as number)} />), run: (row: FileRow, prepared: unknown) => saveFileAndReconcileGroup(queryClient, row.id, { group: Number(prepared) }, Number(prepared)) }] : []),
|
|
45
|
+
{ key: 'download', label: 'Download selected', confirm: false, run: async (row) => { if (!openCapabilityUrl(row.url ?? '', true)) throw new Error(`${row.filename} has no safe download URL`); } },
|
|
46
|
+
{ key: 'delete', label: 'Delete', danger: true, confirm: 'Delete the selected files and their backend objects? This cannot be undone.', run: (row) => remove.mutateAsync({ id: row.id }) },
|
|
47
|
+
];
|
|
48
|
+
const table = (openUpload?: () => void) => <ModelTable model={FileModel} eyebrow="Infrastructure · Storage" title="Files" columns={COLUMNS} filters={FILTERS} presets={[{ key: 'all', label: 'All', params: {} }, { key: 'ready', label: 'Ready', params: { upload_status: 'completed' } }, { key: 'failed', label: 'Failed', params: { upload_status: 'failed' } }, { key: 'public', label: 'Public', params: { is_public: 'true' } }]} defaultSort="-created" searchable searchPlaceholder="Search filename or content type" selectable={canManage} batchActions={canManage ? batches : []} columnChooser persistState persistKey="admin:storage:files" exportFormats={['csv', 'json']} exporter={exportFiles} onAdd={openUpload} addLabel="Add File" onRowClick={(row) => showFileView(row.id)} />;
|
|
49
|
+
// Do not mount picker/drop/manager queries for view-only operators.
|
|
50
|
+
return canManage
|
|
51
|
+
? <FileUploadSurface canManage canChooseGroup={canChooseGroup}>{(openUpload) => table(openUpload)}</FileUploadSurface>
|
|
52
|
+
: table();
|
|
53
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { FileRenditionRow, FileRow } from './models';
|
|
2
|
+
|
|
3
|
+
export const RENDITION_POLL_DELAY_MS = 5_000;
|
|
4
|
+
export const RENDITION_POLL_MAX_ATTEMPTS = 12;
|
|
5
|
+
export const MAX_RENDITION_ROLES = 20;
|
|
6
|
+
|
|
7
|
+
export function normalizeRenditions(
|
|
8
|
+
value: Record<string, FileRenditionRow> | null | undefined,
|
|
9
|
+
): FileRenditionRow[] {
|
|
10
|
+
return Object.entries(value ?? {}).map(([role, row]) => ({ ...row, role: row.role || role }))
|
|
11
|
+
.sort((a, b) => a.role.localeCompare(b.role));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renditionSignature(row: FileRenditionRow | null | undefined): string | null {
|
|
15
|
+
if (!row) return null;
|
|
16
|
+
return JSON.stringify([
|
|
17
|
+
row.role, row.id, row.upload_status, row.modified,
|
|
18
|
+
row.width ?? null, row.height ?? null, row.file_size ?? null,
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renditionMapSignature(file: Pick<FileRow, 'renditions'>): string {
|
|
23
|
+
return normalizeRenditions(file.renditions)
|
|
24
|
+
.map((row) => renditionSignature(row))
|
|
25
|
+
.join('|');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Signature only the roles queued by one regeneration generation can satisfy. */
|
|
29
|
+
export function renditionTargetSignature(
|
|
30
|
+
file: Pick<FileRow, 'renditions'>,
|
|
31
|
+
targetRoles: readonly string[] | null,
|
|
32
|
+
): string {
|
|
33
|
+
if (targetRoles === null) return renditionMapSignature(file);
|
|
34
|
+
const byRole = new Map(normalizeRenditions(file.renditions).map((row) => [row.role, row]));
|
|
35
|
+
return normalizeRenditionRoles(targetRoles)
|
|
36
|
+
.map((role) => `${role}:${renditionSignature(byRole.get(role)) ?? 'missing'}`)
|
|
37
|
+
.join('|');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeRenditionRoles(roles: readonly string[]): string[] {
|
|
41
|
+
return [...new Set(roles.map((role) => role.trim()).filter(Boolean))].slice(0, MAX_RENDITION_ROLES);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type RenditionPollStop = 'changed' | 'failed' | 'expired' | 'file-changed' | 'closed' | 'timeout';
|
|
45
|
+
|
|
46
|
+
export interface RenditionPollDecision {
|
|
47
|
+
done: boolean;
|
|
48
|
+
reason: RenditionPollStop | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function decideRenditionPoll(args: {
|
|
52
|
+
expectedFileId: number;
|
|
53
|
+
currentFileId: number | null;
|
|
54
|
+
open: boolean;
|
|
55
|
+
attempt: number;
|
|
56
|
+
beforeSignature: string;
|
|
57
|
+
targetRoles: readonly string[] | null;
|
|
58
|
+
file: Pick<FileRow, 'upload_status' | 'renditions'> | null;
|
|
59
|
+
}): RenditionPollDecision {
|
|
60
|
+
if (!args.open) return { done: true, reason: 'closed' };
|
|
61
|
+
if (args.currentFileId !== args.expectedFileId) return { done: true, reason: 'file-changed' };
|
|
62
|
+
if (args.file?.upload_status === 'failed') return { done: true, reason: 'failed' };
|
|
63
|
+
if (args.file?.upload_status === 'expired') return { done: true, reason: 'expired' };
|
|
64
|
+
if (args.file && renditionTargetSignature(args.file, args.targetRoles) !== args.beforeSignature) {
|
|
65
|
+
return { done: true, reason: 'changed' };
|
|
66
|
+
}
|
|
67
|
+
if (args.attempt >= RENDITION_POLL_MAX_ATTEMPTS) return { done: true, reason: 'timeout' };
|
|
68
|
+
return { done: false, reason: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Recursive, non-overlapping convergence polling. */
|
|
72
|
+
export async function pollRenditionConvergence(args: {
|
|
73
|
+
fileId: number;
|
|
74
|
+
beforeSignature: string;
|
|
75
|
+
targetRoles: readonly string[] | null;
|
|
76
|
+
isCurrent: () => boolean;
|
|
77
|
+
fetch: () => Promise<FileRow>;
|
|
78
|
+
wait?: (ms: number) => Promise<void>;
|
|
79
|
+
onAttempt?: (attempt: number, file: FileRow) => void;
|
|
80
|
+
}): Promise<RenditionPollStop> {
|
|
81
|
+
const wait = args.wait ?? ((ms) => new Promise<void>((resolve) => window.setTimeout(resolve, ms)));
|
|
82
|
+
for (let attempt = 1; attempt <= RENDITION_POLL_MAX_ATTEMPTS; attempt += 1) {
|
|
83
|
+
await wait(RENDITION_POLL_DELAY_MS);
|
|
84
|
+
if (!args.isCurrent()) return 'closed';
|
|
85
|
+
const file = await args.fetch();
|
|
86
|
+
args.onAttempt?.(attempt, file);
|
|
87
|
+
const decision = decideRenditionPoll({
|
|
88
|
+
expectedFileId: args.fileId,
|
|
89
|
+
currentFileId: file.id,
|
|
90
|
+
open: args.isCurrent(),
|
|
91
|
+
attempt,
|
|
92
|
+
beforeSignature: args.beforeSignature,
|
|
93
|
+
targetRoles: args.targetRoles,
|
|
94
|
+
file,
|
|
95
|
+
});
|
|
96
|
+
if (decision.done) return decision.reason!;
|
|
97
|
+
}
|
|
98
|
+
return 'timeout';
|
|
99
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AdminSection } from '../index';
|
|
2
|
+
import { BackendsPage } from './BackendsPage';
|
|
3
|
+
import { BucketsPage } from './BucketsPage';
|
|
4
|
+
import { FilesPage } from './FilesPage';
|
|
5
|
+
import { BUCKET_MANAGE_PERMS, STORAGE_VIEW_PERMS } from './models';
|
|
6
|
+
|
|
7
|
+
export * from './models';
|
|
8
|
+
export * from './file-renditions';
|
|
9
|
+
export * from './BucketsPage';
|
|
10
|
+
export * from './BucketDetail';
|
|
11
|
+
export * from './BackendsPage';
|
|
12
|
+
export * from './FileManagerDetail';
|
|
13
|
+
export * from './FilesPage';
|
|
14
|
+
export * from './FileUploadSurface';
|
|
15
|
+
export * from './FileView';
|
|
16
|
+
export * from './FilePreview';
|
|
17
|
+
export * from './storage-dialogs';
|
|
18
|
+
|
|
19
|
+
export const STORAGE_ADMIN_SECTION: AdminSection = {
|
|
20
|
+
id: 'storage',
|
|
21
|
+
basePath: 'storage',
|
|
22
|
+
title: 'Storage',
|
|
23
|
+
icon: 'bi-hdd-stack',
|
|
24
|
+
navigationGroup: 'infrastructure',
|
|
25
|
+
permissions: ['sys.view_fileman', 'sys.manage_files', 'sys.manage_aws', 'sys.files'],
|
|
26
|
+
routes: [
|
|
27
|
+
{ path: 'buckets', label: 'Buckets', component: BucketsPage, permissions: BUCKET_MANAGE_PERMS },
|
|
28
|
+
{ path: 'backends', label: 'Backends', component: BackendsPage, permissions: STORAGE_VIEW_PERMS },
|
|
29
|
+
{ path: 'files', label: 'Files', component: FilesPage, permissions: STORAGE_VIEW_PERMS },
|
|
30
|
+
],
|
|
31
|
+
};
|