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,268 @@
|
|
|
1
|
+
// Mini @mojo/client — the typed django-mojo protocol layer.
|
|
2
|
+
//
|
|
3
|
+
// Contract (from the web-mojo port extraction):
|
|
4
|
+
// - Envelope: {status, ...} — status:false carries error/message. Unwrapped
|
|
5
|
+
// HERE, at exactly one boundary. A failed save REJECTS (MojoError); it is
|
|
6
|
+
// never resolved as success. (web-mojo's Model.save() never-rejects trap
|
|
7
|
+
// is deliberately not carried forward.)
|
|
8
|
+
// - Lists: {status, data: T[], count, size, start} → MojoList<T>.
|
|
9
|
+
// - Singles: {status, data: T} → T.
|
|
10
|
+
// - Paging: start/size. Sort: 'field' | '-field'. Filters: Django lookups.
|
|
11
|
+
// - Every request carries the X-Mojo-UID device header; when auth is
|
|
12
|
+
// initialized (initAuth), a pre-request gate + Authorization header are
|
|
13
|
+
// installed via installAuthHooks — the client core itself has zero auth
|
|
14
|
+
// knowledge (web-mojo's Rest-interceptor shape).
|
|
15
|
+
//
|
|
16
|
+
// With VITE_MOJO_API unset the transport is the in-memory mock; set it to a
|
|
17
|
+
// django-mojo origin and the same code talks to the real backend.
|
|
18
|
+
import { mockFetch } from './mock';
|
|
19
|
+
import { MojoError } from './errors';
|
|
20
|
+
import { DUID_HEADER, getDuid } from './duid';
|
|
21
|
+
import type { MojoList, Params } from './types';
|
|
22
|
+
|
|
23
|
+
export { MojoError, AuthRequiredError } from './errors';
|
|
24
|
+
|
|
25
|
+
const API_BASE: string = import.meta.env.VITE_MOJO_API ?? '';
|
|
26
|
+
|
|
27
|
+
/** True when the in-memory mock transport is active (VITE_MOJO_API unset). */
|
|
28
|
+
export function usingMockTransport(): boolean {
|
|
29
|
+
return !API_BASE;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The configured django-mojo origin ('' under the mock transport). */
|
|
33
|
+
export function apiOrigin(): string {
|
|
34
|
+
return API_BASE;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* TanStack Query defaults tuned for the mojo protocol — spread these into the
|
|
39
|
+
* app's QueryClient:
|
|
40
|
+
* · 4xx MojoErrors never retry (a 404/401/403 is deterministic — retrying
|
|
41
|
+
* it only delays the error surfacing, e.g. the group-context fallback)
|
|
42
|
+
* · networkMode 'always' under the mock (an in-memory backend can't be
|
|
43
|
+
* offline; online-pausing would wedge failures forever), 'online' for
|
|
44
|
+
* real backends.
|
|
45
|
+
*/
|
|
46
|
+
export function mojoQueryDefaults() {
|
|
47
|
+
return {
|
|
48
|
+
queries: {
|
|
49
|
+
networkMode: (usingMockTransport() ? 'always' : 'online') as 'always' | 'online',
|
|
50
|
+
retry: (failureCount: number, error: unknown): boolean => {
|
|
51
|
+
if (error instanceof MojoError && error.status >= 400 && error.status < 500) return false;
|
|
52
|
+
return failureCount < 1;
|
|
53
|
+
},
|
|
54
|
+
// No refetch storm on window focus (web-mojo parity: freshness
|
|
55
|
+
// comes from explicit refresh + the opt-in autoRefresh interval).
|
|
56
|
+
// With it on, every hop back from devtools/another window refired
|
|
57
|
+
// EVERY active query — read as phantom duplicate fetches.
|
|
58
|
+
refetchOnWindowFocus: false,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildQuery(params: Params): string {
|
|
64
|
+
const qs = new URLSearchParams();
|
|
65
|
+
for (const [key, value] of Object.entries(params)) {
|
|
66
|
+
if (value === undefined || value === null || value === '') continue;
|
|
67
|
+
qs.set(key, String(value));
|
|
68
|
+
}
|
|
69
|
+
const s = qs.toString();
|
|
70
|
+
return s ? `?${s}` : '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FetchOpts {
|
|
74
|
+
params?: Params;
|
|
75
|
+
method?: 'GET' | 'POST' | 'DELETE';
|
|
76
|
+
body?: Record<string, unknown>;
|
|
77
|
+
/** Cancels the selected transport request. */
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface Envelope {
|
|
82
|
+
status: boolean;
|
|
83
|
+
code?: number;
|
|
84
|
+
data?: unknown;
|
|
85
|
+
error?: string;
|
|
86
|
+
error_code?: number | string;
|
|
87
|
+
message?: string;
|
|
88
|
+
count?: number;
|
|
89
|
+
size?: number;
|
|
90
|
+
start?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Installed by auth (initAuth). The transport stays auth-agnostic. */
|
|
94
|
+
export interface AuthHooks {
|
|
95
|
+
/** Pre-request gate; may throw AuthRequiredError → synthetic-401 reject, no fetch. */
|
|
96
|
+
preRequest(path: string): Promise<void>;
|
|
97
|
+
/** Authorization header value, or null when no session exists. */
|
|
98
|
+
authHeader(): string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let authHooks: AuthHooks | null = null;
|
|
102
|
+
|
|
103
|
+
export function installAuthHooks(hooks: AuthHooks | null): void {
|
|
104
|
+
authHooks = hooks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
|
|
108
|
+
// Gate BEFORE headers are built: after a mid-gate token refresh the fresh
|
|
109
|
+
// Authorization value is picked up below by construction (web-mojo had to
|
|
110
|
+
// re-stamp a snapshotted header here).
|
|
111
|
+
if (authHooks) await authHooks.preRequest(path);
|
|
112
|
+
|
|
113
|
+
const headers: Record<string, string> = {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
[DUID_HEADER]: getDuid(),
|
|
116
|
+
};
|
|
117
|
+
const bearer = authHooks?.authHeader();
|
|
118
|
+
if (bearer) headers['Authorization'] = bearer;
|
|
119
|
+
|
|
120
|
+
if (!API_BASE) {
|
|
121
|
+
return (await mockFetch(path, { ...opts, headers })) as Envelope;
|
|
122
|
+
}
|
|
123
|
+
const res = await fetch(`${API_BASE}${path}${buildQuery(opts.params ?? {})}`, {
|
|
124
|
+
method: opts.method ?? 'GET',
|
|
125
|
+
headers,
|
|
126
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
127
|
+
signal: opts.signal,
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
// django-mojo sends its envelope on error responses too — surface the
|
|
131
|
+
// real message ("Invalid username or password"), not "HTTP 401".
|
|
132
|
+
let detail = `HTTP ${res.status}`;
|
|
133
|
+
let errorCode: string | number | undefined;
|
|
134
|
+
let data: unknown;
|
|
135
|
+
try {
|
|
136
|
+
const body = (await res.json()) as Envelope;
|
|
137
|
+
detail = body.error ?? body.message ?? detail;
|
|
138
|
+
errorCode = body.error_code;
|
|
139
|
+
data = body.data;
|
|
140
|
+
} catch {
|
|
141
|
+
// Non-JSON error body — keep the status text.
|
|
142
|
+
}
|
|
143
|
+
throw new MojoError(detail, res.status, errorCode, data);
|
|
144
|
+
}
|
|
145
|
+
return (await res.json()) as Envelope;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The single envelope-unwrap boundary. */
|
|
149
|
+
async function unwrap(path: string, opts: FetchOpts): Promise<Envelope> {
|
|
150
|
+
const body = await transport(path, opts);
|
|
151
|
+
if (body.status === false) {
|
|
152
|
+
const legacyStatus = typeof body.error_code === 'number' ? body.error_code : 0;
|
|
153
|
+
throw new MojoError(
|
|
154
|
+
body.error ?? body.message ?? 'Request failed',
|
|
155
|
+
body.code ?? legacyStatus,
|
|
156
|
+
body.error_code,
|
|
157
|
+
body.data,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Typed escape hatch over the unwrap boundary for protocol modules (auth) and
|
|
165
|
+
* one-off endpoints. Still the same single boundary — never parse envelopes
|
|
166
|
+
* anywhere else.
|
|
167
|
+
*/
|
|
168
|
+
export function mojoCall(path: string, opts: FetchOpts = {}): Promise<Envelope> {
|
|
169
|
+
return unwrap(path, opts);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function mojoList<T>(endpoint: string, params: Params = {}): Promise<MojoList<T>> {
|
|
173
|
+
const body = await unwrap(endpoint, { params });
|
|
174
|
+
return {
|
|
175
|
+
rows: (body.data ?? []) as T[],
|
|
176
|
+
count: body.count ?? 0,
|
|
177
|
+
start: body.start ?? 0,
|
|
178
|
+
size: body.size ?? 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function mojoGet<T>(endpoint: string, id: number | string): Promise<T> {
|
|
183
|
+
const body = await unwrap(`${endpoint}/${id}`, {});
|
|
184
|
+
return body.data as T;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface MetricsResponse {
|
|
188
|
+
labels: string[];
|
|
189
|
+
datasets: { label: string; data: number[] }[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* django-mojo metrics. The wire shape (verified live) is
|
|
194
|
+
* `{data: {<slug>: number[]}, labels: string[]}` — a slug-keyed map with NO
|
|
195
|
+
* granularity/range echo. Normalized HERE (the one boundary) into the
|
|
196
|
+
* datasets array the chart components consume; display names for slugs are a
|
|
197
|
+
* presentation concern (MetricsChart), not a wire fact.
|
|
198
|
+
*/
|
|
199
|
+
export async function mojoMetrics(params: Params): Promise<MetricsResponse> {
|
|
200
|
+
const body = await unwrap('/api/metrics/fetch', { params });
|
|
201
|
+
const raw = (body.data ?? {}) as { data?: Record<string, number[]>; labels?: string[] };
|
|
202
|
+
return {
|
|
203
|
+
labels: raw.labels ?? [],
|
|
204
|
+
datasets: Object.entries(raw.data ?? {}).map(([slug, data]) => ({ label: slug, data: data ?? [] })),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Create (no id) or update (with id). Rejects on any failure. */
|
|
209
|
+
export async function mojoSave<T>(endpoint: string, id: number | string | null, changes: Record<string, unknown>): Promise<T> {
|
|
210
|
+
const path = id == null ? endpoint : `${endpoint}/${id}`;
|
|
211
|
+
const body = await unwrap(path, { method: 'POST', body: changes });
|
|
212
|
+
return body.data as T;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Delete a record. django-mojo answers `{status: "deleted"}` (a string — the
|
|
217
|
+
* envelope's only non-boolean status; it passes the unwrap's `=== false`
|
|
218
|
+
* failure check by design). Failures reject like every other call.
|
|
219
|
+
*/
|
|
220
|
+
export async function mojoDelete(endpoint: string, id: number | string): Promise<void> {
|
|
221
|
+
await unwrap(`${endpoint}/${id}`, { method: 'DELETE' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Trigger a browser download of an in-memory file. Shared by safe client exports. */
|
|
225
|
+
export function downloadBlob(blob: Blob, filename: string): void {
|
|
226
|
+
const url = URL.createObjectURL(blob);
|
|
227
|
+
const a = document.createElement('a');
|
|
228
|
+
a.href = url;
|
|
229
|
+
a.download = filename;
|
|
230
|
+
document.body.appendChild(a);
|
|
231
|
+
a.click();
|
|
232
|
+
a.remove();
|
|
233
|
+
URL.revokeObjectURL(url);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Server-side export (mojo/models/rest.py on_rest_list_response): the list
|
|
238
|
+
* endpoint with `download_format=csv|json` + `filename` streams a file built
|
|
239
|
+
* from the model's graph fields under the CURRENT filters (paging params are
|
|
240
|
+
* stripped here — an export is the whole result set). Under the mock the
|
|
241
|
+
* same filter pipeline synthesizes the file content in-memory.
|
|
242
|
+
*/
|
|
243
|
+
export async function mojoDownload(endpoint: string, params: Params, format: 'csv' | 'json'): Promise<void> {
|
|
244
|
+
const cleaned: Params = { ...params };
|
|
245
|
+
delete cleaned.start;
|
|
246
|
+
delete cleaned.size;
|
|
247
|
+
const filename = `export-${endpoint.split('/').filter(Boolean).pop() ?? 'data'}.${format}`;
|
|
248
|
+
const withFormat: Params = { ...cleaned, download_format: format, filename };
|
|
249
|
+
|
|
250
|
+
if (authHooks) await authHooks.preRequest(endpoint);
|
|
251
|
+
const headers: Record<string, string> = { [DUID_HEADER]: getDuid() };
|
|
252
|
+
const bearer = authHooks?.authHeader();
|
|
253
|
+
if (bearer) headers['Authorization'] = bearer;
|
|
254
|
+
|
|
255
|
+
if (!API_BASE) {
|
|
256
|
+
const body = (await mockFetch(endpoint, { params: withFormat, headers })) as Envelope;
|
|
257
|
+
if (body.status === false) {
|
|
258
|
+
const legacyStatus = typeof body.error_code === 'number' ? body.error_code : 0;
|
|
259
|
+
throw new MojoError(body.error ?? 'Export failed', body.code ?? legacyStatus, body.error_code, body.data);
|
|
260
|
+
}
|
|
261
|
+
const file = body.data as { filename: string; content: string; mime: string };
|
|
262
|
+
downloadBlob(new Blob([file.content], { type: file.mime }), file.filename);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const res = await fetch(`${API_BASE}${endpoint}${buildQuery(withFormat)}`, { headers });
|
|
266
|
+
if (!res.ok) throw new MojoError(`Export failed (HTTP ${res.status})`, res.status);
|
|
267
|
+
downloadBlob(await res.blob(), filename);
|
|
268
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Device Unique ID — a per-browser UUID django-mojo uses for device tracking
|
|
2
|
+
// and rate limiting. Sent on every request as the X-Mojo-UID header (web-mojo
|
|
3
|
+
// Rest parity, storage key included).
|
|
4
|
+
|
|
5
|
+
export const DUID_HEADER = 'X-Mojo-UID';
|
|
6
|
+
const DUID_KEY = 'mojo_device_uid';
|
|
7
|
+
|
|
8
|
+
let duid: string | null = null;
|
|
9
|
+
|
|
10
|
+
function generate(): string {
|
|
11
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
|
12
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
13
|
+
const r = (Math.random() * 16) | 0;
|
|
14
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Stable per-browser DUID; falls back to per-session when storage is blocked. */
|
|
19
|
+
export function getDuid(): string {
|
|
20
|
+
if (duid) return duid;
|
|
21
|
+
try {
|
|
22
|
+
duid = localStorage.getItem(DUID_KEY);
|
|
23
|
+
if (!duid) {
|
|
24
|
+
duid = generate();
|
|
25
|
+
localStorage.setItem(DUID_KEY, duid);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
duid = generate();
|
|
29
|
+
}
|
|
30
|
+
return duid;
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Error types for the mojo client. Own module so client.ts and auth.ts can
|
|
2
|
+
// both import them without a dependency cycle.
|
|
3
|
+
|
|
4
|
+
export class MojoError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
/** Semantic django-mojo error code (`s3_operation_incomplete`, etc.). */
|
|
7
|
+
errorCode: string | number | undefined;
|
|
8
|
+
/** Structured, server-sanitized failure evidence. Never log indiscriminately. */
|
|
9
|
+
data: unknown;
|
|
10
|
+
constructor(message: string, status = 0, errorCode?: string | number, data?: unknown) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'MojoError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.errorCode = errorCode;
|
|
15
|
+
this.data = data;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown by the pre-request auth gate when the access token is expired and
|
|
21
|
+
* cannot be refreshed. The transport recognizes it and short-circuits the
|
|
22
|
+
* request WITHOUT calling fetch — web-mojo's "synthetic 401". web-mojo
|
|
23
|
+
* resolved a 401-shaped response object (its Rest never rejects); here the
|
|
24
|
+
* same short-circuit REJECTS, per the failure-is-unmissable rule.
|
|
25
|
+
*/
|
|
26
|
+
export class AuthRequiredError extends MojoError {
|
|
27
|
+
reason: 'unauthorized';
|
|
28
|
+
constructor(message = 'Authentication required') {
|
|
29
|
+
super(message, 401);
|
|
30
|
+
this.name = 'AuthRequiredError';
|
|
31
|
+
this.reason = 'unauthorized';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Group context plumbing — types + the React context object, in their own
|
|
2
|
+
// module so me.ts (useCan reads the active member) and group.tsx (the
|
|
3
|
+
// provider needs useAuthSnapshot from me.ts) never import each other.
|
|
4
|
+
import { createContext } from 'react';
|
|
5
|
+
import type { MemberLike } from './me';
|
|
6
|
+
|
|
7
|
+
export interface Group {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
kind: string;
|
|
11
|
+
uuid?: string | null;
|
|
12
|
+
parent?: { id: number; name: string; kind?: string } | null;
|
|
13
|
+
[field: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GroupContextValue {
|
|
17
|
+
/** The active group, or null when operating in global context. */
|
|
18
|
+
group: Group | null;
|
|
19
|
+
/** The signed-in user's membership in the active group (permissions dict). */
|
|
20
|
+
member: MemberLike | null;
|
|
21
|
+
/** True while boot resolution or a switch is in flight. */
|
|
22
|
+
loading: boolean;
|
|
23
|
+
setActiveGroup(group: Group): void;
|
|
24
|
+
clearActiveGroup(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Null outside a <GroupProvider> — consumers must handle both. */
|
|
28
|
+
export const GroupContext = createContext<GroupContextValue | null>(null);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// GroupProvider — the active-group spine, ported from web-mojo
|
|
2
|
+
// PortalApp.checkActiveGroup / setActiveGroup (PortalApp.js:230-348):
|
|
3
|
+
// · resolution order: `?group=` URL param BEATS the stored id
|
|
4
|
+
// · the param rides the REAL search string (before the hash) —
|
|
5
|
+
// `/?group=5#/users` — so it survives every hash navigation and never
|
|
6
|
+
// fights the per-route params store
|
|
7
|
+
// · storage key `active_group_id` (web-mojo parity)
|
|
8
|
+
// · on activation the user's member record for that group is fetched
|
|
9
|
+
// (`/api/group/<id>/member`) — group-context permissions flow from it
|
|
10
|
+
// · URL-group failure falls back to the stored group, then to none
|
|
11
|
+
import { useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
12
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
13
|
+
import { mojoCall, mojoGet } from './client';
|
|
14
|
+
import { useAuthSnapshot } from './me';
|
|
15
|
+
import type { MemberLike } from './me';
|
|
16
|
+
import { GroupContext, type Group, type GroupContextValue } from './group-context';
|
|
17
|
+
|
|
18
|
+
const STORAGE_KEY = 'active_group_id';
|
|
19
|
+
|
|
20
|
+
function readUrlGroupParam(): string | null {
|
|
21
|
+
if (typeof window === 'undefined') return null;
|
|
22
|
+
return new URLSearchParams(window.location.search).get('group');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeUrlGroupParam(id: number | null): void {
|
|
26
|
+
const params = new URLSearchParams(window.location.search);
|
|
27
|
+
if (id == null) params.delete('group');
|
|
28
|
+
else params.set('group', String(id));
|
|
29
|
+
const qs = params.toString();
|
|
30
|
+
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + (window.location.hash || ''));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadStoredGroupId(): number | null {
|
|
34
|
+
try {
|
|
35
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
36
|
+
const id = raw == null ? NaN : Number(raw);
|
|
37
|
+
return Number.isFinite(id) && id > 0 ? id : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function saveStoredGroupId(id: number): void {
|
|
44
|
+
try {
|
|
45
|
+
localStorage.setItem(STORAGE_KEY, String(id));
|
|
46
|
+
} catch { /* storage unavailable — context stays session-only */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clearStoredGroupId(): void {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
52
|
+
} catch { /* ignore */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function GroupProvider({ children }: { children: ReactNode }) {
|
|
56
|
+
const auth = useAuthSnapshot();
|
|
57
|
+
const queryClient = useQueryClient();
|
|
58
|
+
const [groupId, setGroupId] = useState<number | null>(() => {
|
|
59
|
+
const urlRaw = readUrlGroupParam();
|
|
60
|
+
const urlId = urlRaw == null ? NaN : Number(urlRaw);
|
|
61
|
+
if (Number.isFinite(urlId) && urlId > 0) return urlId;
|
|
62
|
+
return loadStoredGroupId();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const groupQuery = useQuery({
|
|
66
|
+
queryKey: ['group', groupId, auth.uid],
|
|
67
|
+
queryFn: () => mojoGet<Group>('/api/group', groupId!),
|
|
68
|
+
enabled: auth.authenticated && groupId != null,
|
|
69
|
+
staleTime: 5 * 60_000,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// web-mojo's fallback chain: a failing URL/stored group falls back to the
|
|
73
|
+
// stored one (when different), else the context clears entirely.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!groupQuery.isError || groupId == null) return;
|
|
76
|
+
const stored = loadStoredGroupId();
|
|
77
|
+
if (stored != null && stored !== groupId) {
|
|
78
|
+
console.warn(`[portal-mojo] active group ${groupId} failed to load; falling back to stored group ${stored}`);
|
|
79
|
+
writeUrlGroupParam(stored);
|
|
80
|
+
setGroupId(stored);
|
|
81
|
+
} else {
|
|
82
|
+
console.warn(`[portal-mojo] active group ${groupId} failed to load; clearing group context`);
|
|
83
|
+
clearStoredGroupId();
|
|
84
|
+
writeUrlGroupParam(null);
|
|
85
|
+
setGroupId(null);
|
|
86
|
+
}
|
|
87
|
+
}, [groupQuery.isError, groupId]);
|
|
88
|
+
|
|
89
|
+
const group = groupId != null ? (groupQuery.data ?? null) : null;
|
|
90
|
+
|
|
91
|
+
const memberQuery = useQuery({
|
|
92
|
+
queryKey: ['group-member', group?.id, auth.uid],
|
|
93
|
+
queryFn: async () => (await mojoCall(`/api/group/${group!.id}/member`)).data as MemberLike,
|
|
94
|
+
enabled: auth.authenticated && group != null,
|
|
95
|
+
staleTime: 5 * 60_000,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const value = useMemo<GroupContextValue>(() => ({
|
|
99
|
+
group,
|
|
100
|
+
member: (group != null ? memberQuery.data : null) ?? null,
|
|
101
|
+
loading: auth.authenticated && groupId != null
|
|
102
|
+
&& (groupQuery.isLoading || (group != null && memberQuery.isLoading)),
|
|
103
|
+
setActiveGroup(next: Group) {
|
|
104
|
+
// Seed the cache with the row the switcher already holds — no
|
|
105
|
+
// refetch flash on switch.
|
|
106
|
+
queryClient.setQueryData(['group', next.id, auth.uid], next);
|
|
107
|
+
saveStoredGroupId(next.id);
|
|
108
|
+
writeUrlGroupParam(next.id);
|
|
109
|
+
setGroupId(next.id);
|
|
110
|
+
},
|
|
111
|
+
clearActiveGroup() {
|
|
112
|
+
clearStoredGroupId();
|
|
113
|
+
writeUrlGroupParam(null);
|
|
114
|
+
setGroupId(null);
|
|
115
|
+
},
|
|
116
|
+
}), [group, memberQuery.data, memberQuery.isLoading, groupQuery.isLoading, groupId, auth.authenticated, auth.uid, queryClient]);
|
|
117
|
+
|
|
118
|
+
return <GroupContext.Provider value={value}>{children}</GroupContext.Provider>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Active-group context. Throws outside <GroupProvider> — mount it app-wide. */
|
|
122
|
+
export function useActiveGroup(): GroupContextValue {
|
|
123
|
+
const ctx = useContext(GroupContext);
|
|
124
|
+
if (!ctx) throw new Error('useActiveGroup requires a <GroupProvider> above it');
|
|
125
|
+
return ctx;
|
|
126
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// TanStack Query wraps the client: caching, dedup, background refetch,
|
|
2
|
+
// loading/error states — everything Collection.fetch + fetch:start/end
|
|
3
|
+
// events + stale guards did by hand in web-mojo.
|
|
4
|
+
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
5
|
+
import { mojoGet, mojoList, mojoSave } from './client';
|
|
6
|
+
import type { MojoList, Params } from './types';
|
|
7
|
+
|
|
8
|
+
export function useModelList<T>(
|
|
9
|
+
endpoint: string,
|
|
10
|
+
params: Params = {},
|
|
11
|
+
opts: { enabled?: boolean; sanitizeRow?: (row: T) => T } = {},
|
|
12
|
+
) {
|
|
13
|
+
return useQuery({
|
|
14
|
+
queryKey: [endpoint, params],
|
|
15
|
+
queryFn: async (): Promise<MojoList<T>> => {
|
|
16
|
+
const list = await mojoList<T>(endpoint, params);
|
|
17
|
+
return opts.sanitizeRow
|
|
18
|
+
? { ...list, rows: list.rows.map(opts.sanitizeRow) }
|
|
19
|
+
: list;
|
|
20
|
+
},
|
|
21
|
+
// Old page stays visible while the next loads — no skeleton flash on
|
|
22
|
+
// page/sort changes (web-mojo needed careful fetch-event juggling for
|
|
23
|
+
// this; here it's one option).
|
|
24
|
+
placeholderData: keepPreviousData,
|
|
25
|
+
// Lets ModelTable hold the first request until persisted view state
|
|
26
|
+
// has been rehydrated into the URL (persistState).
|
|
27
|
+
enabled: opts.enabled !== false,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useModel<T>(endpoint: string, id: number | string | null, sanitizeRow?: (row: T) => T) {
|
|
32
|
+
return useQuery({
|
|
33
|
+
queryKey: [endpoint, 'one', id],
|
|
34
|
+
queryFn: async () => {
|
|
35
|
+
const row = await mojoGet<T>(endpoint, id!);
|
|
36
|
+
return sanitizeRow ? sanitizeRow(row) : row;
|
|
37
|
+
},
|
|
38
|
+
enabled: id != null,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Save mutation; invalidates every query for the endpoint on success. */
|
|
43
|
+
export function useSaveModel<T>(endpoint: string) {
|
|
44
|
+
const qc = useQueryClient();
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: ({ id, changes }: { id: number | string | null; changes: Record<string, unknown> }) =>
|
|
47
|
+
mojoSave<T>(endpoint, id, changes),
|
|
48
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: [endpoint] }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// portal-mojo/client — the typed django-mojo protocol layer: envelope unwrap
|
|
2
|
+
// (one boundary; a failed save REJECTS), start/size paging, '-field' sort,
|
|
3
|
+
// Django lookups, the URL-synced table-params store, TanStack Query hooks,
|
|
4
|
+
// and the auth client (password/magic/passkey flows, single-flight refresh,
|
|
5
|
+
// pre-request gate, DUID). The in-memory mock transport (mock.ts) is internal
|
|
6
|
+
// to client.ts — it is the wire contract's executable spec and evolves in
|
|
7
|
+
// lockstep with the client.
|
|
8
|
+
export * from './client';
|
|
9
|
+
export * from './errors';
|
|
10
|
+
export * from './model';
|
|
11
|
+
export * from './auth';
|
|
12
|
+
export * from './jwt';
|
|
13
|
+
export * from './duid';
|
|
14
|
+
export * from './me';
|
|
15
|
+
export * from './group-context';
|
|
16
|
+
export * from './group';
|
|
17
|
+
export * from './hooks';
|
|
18
|
+
export * from './params';
|
|
19
|
+
export * from './lookups';
|
|
20
|
+
export * from './location';
|
|
21
|
+
export * from './markdown';
|
|
22
|
+
export * from './record-feed';
|
|
23
|
+
export * from './safe-export';
|
|
24
|
+
export * from './upload';
|
|
25
|
+
export * from './realtime';
|
|
26
|
+
export * from './realtime-mock';
|
|
27
|
+
export * from './types';
|
|
28
|
+
// Dev affordance: per-endpoint call counts from the mock (single-flight proof).
|
|
29
|
+
export {
|
|
30
|
+
armMockReauth, clearMockRequestHistory, getMockCallCounts,
|
|
31
|
+
getMockRequestHistory, setMockDnsConfigMalformed, setMockDnsAcmeMode, armMockDnsWriteFault,
|
|
32
|
+
getMockPhoneConfigCredentialState, getMockEffectivePhoneConfigId,
|
|
33
|
+
deleteMockPhoneGroupForTest,
|
|
34
|
+
setMockDnsRegistrarMode, armMockRegistrarPurchaseFault,
|
|
35
|
+
getMockPushConfigCredentialState,
|
|
36
|
+
armMockUploadFault, armMockRecordNoteFault, clearMockUploadObservations, getMockUploadObservations, setMockUploadMode,
|
|
37
|
+
} from './mock';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Client-side JWT inspection — decode only, NO signature verification (the
|
|
2
|
+
// server is the authority; the client only needs expiry/identity hints).
|
|
3
|
+
// Ported from web-mojo TokenManager's Token class.
|
|
4
|
+
|
|
5
|
+
export interface JwtPayload {
|
|
6
|
+
uid?: string | number;
|
|
7
|
+
sub?: string | number;
|
|
8
|
+
user_id?: string | number;
|
|
9
|
+
email?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
username?: string;
|
|
12
|
+
exp?: number;
|
|
13
|
+
iat?: number;
|
|
14
|
+
[claim: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Decode a JWT payload (URL-safe base64, padded). Null on any malformation. */
|
|
18
|
+
export function decodeJwt(token: string | null | undefined): JwtPayload | null {
|
|
19
|
+
if (!token || typeof token !== 'string') return null;
|
|
20
|
+
const parts = token.split('.');
|
|
21
|
+
if (parts.length !== 3) return null;
|
|
22
|
+
try {
|
|
23
|
+
let base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/');
|
|
24
|
+
const padding = 4 - (base64.length % 4);
|
|
25
|
+
if (padding !== 4) base64 += '='.repeat(padding);
|
|
26
|
+
return JSON.parse(atob(base64)) as JwtPayload;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface JwtInfo {
|
|
33
|
+
raw: string;
|
|
34
|
+
payload: JwtPayload;
|
|
35
|
+
uid: string | null;
|
|
36
|
+
email: string | null;
|
|
37
|
+
name: string | null;
|
|
38
|
+
exp: Date | null;
|
|
39
|
+
iat: Date | null;
|
|
40
|
+
/** Decodable and (when an exp claim exists) not past it. No exp ⇒ valid. */
|
|
41
|
+
isValid: boolean;
|
|
42
|
+
isExpired: boolean;
|
|
43
|
+
expiringSoon(thresholdMinutes?: number): boolean;
|
|
44
|
+
/** Minutes since iat, or null when the claim is absent. */
|
|
45
|
+
ageMinutes(): number | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Inspect a token. Null when the token is missing or undecodable. */
|
|
49
|
+
export function tokenInfo(token: string | null | undefined): JwtInfo | null {
|
|
50
|
+
const payload = decodeJwt(token);
|
|
51
|
+
if (!payload || !token) return null;
|
|
52
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
53
|
+
const isExpired = payload.exp != null ? nowSec() >= payload.exp : false;
|
|
54
|
+
return {
|
|
55
|
+
raw: token,
|
|
56
|
+
payload,
|
|
57
|
+
uid: payload.uid != null ? String(payload.uid)
|
|
58
|
+
: payload.sub != null ? String(payload.sub)
|
|
59
|
+
: payload.user_id != null ? String(payload.user_id) : null,
|
|
60
|
+
email: payload.email ?? null,
|
|
61
|
+
name: payload.name ?? payload.username ?? null,
|
|
62
|
+
exp: payload.exp ? new Date(payload.exp * 1000) : null,
|
|
63
|
+
iat: payload.iat ? new Date(payload.iat * 1000) : null,
|
|
64
|
+
isValid: !isExpired,
|
|
65
|
+
isExpired,
|
|
66
|
+
expiringSoon(thresholdMinutes = 5) {
|
|
67
|
+
if (payload.exp == null) return false;
|
|
68
|
+
return payload.exp - nowSec() <= thresholdMinutes * 60;
|
|
69
|
+
},
|
|
70
|
+
ageMinutes() {
|
|
71
|
+
if (payload.iat == null) return null;
|
|
72
|
+
return Math.floor((nowSec() - payload.iat) / 60);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|