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,190 @@
|
|
|
1
|
+
// Audit — UserAuditSection port (read in full 2026-08-05): the
|
|
2
|
+
// disable-history accordion (metadata.protected.disable.history — ISO `at`
|
|
3
|
+
// stamps per services/disable.py) over three day-grouped, searchable,
|
|
4
|
+
// pageSize-5 feeds:
|
|
5
|
+
// Activity — /api/logs?uid=<id> (everything the user did)
|
|
6
|
+
// Events — /api/incident/event?model_name=account.User&model_id=<id>
|
|
7
|
+
// Audit Log — /api/logs?model_name=account.User&model_id=<id>
|
|
8
|
+
// (changes made TO the record)
|
|
9
|
+
// Log and incident tabs are independently system-gated. A denied feed never
|
|
10
|
+
// mounts and therefore cannot issue a background request.
|
|
11
|
+
import { useEffect, useState } from 'react';
|
|
12
|
+
import { Badge, Eyebrow, fmt } from '../../../../ui';
|
|
13
|
+
import { LogModel, type LogRow } from '../../../monitoring';
|
|
14
|
+
import { EventModel } from '../../../incidents';
|
|
15
|
+
import type { UserRow } from '../models';
|
|
16
|
+
import {
|
|
17
|
+
DISABLE_REASON_BADGES, disableBlock, groupRowsByDay,
|
|
18
|
+
LOG_LEVEL_ICON, LOG_LEVEL_TONE,
|
|
19
|
+
Pager, SectionSearch, SectionTabs, useSectionList,
|
|
20
|
+
} from './shared';
|
|
21
|
+
|
|
22
|
+
// ── Disable-history accordion ─────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function DisableHistory({ user }: { user: UserRow }) {
|
|
25
|
+
const history = disableBlock(user)?.history;
|
|
26
|
+
if (!Array.isArray(history) || history.length === 0) return null;
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<Eyebrow>Disable history</Eyebrow>
|
|
30
|
+
<div className="us-history">
|
|
31
|
+
{history.map((entry, idx) => {
|
|
32
|
+
const badge = (entry.reason && DISABLE_REASON_BADGES[entry.reason]) || { label: 'Inactive', tone: 'muted' as const };
|
|
33
|
+
return (
|
|
34
|
+
<details key={idx} className="us-history-item">
|
|
35
|
+
<summary>
|
|
36
|
+
<Badge tone={badge.tone}>{badge.label}</Badge>
|
|
37
|
+
<span className="dim">{entry.at ? fmt.relative(entry.at) : ''}</span>
|
|
38
|
+
{entry.by_username && <span>by <code>{entry.by_username}</code></span>}
|
|
39
|
+
{entry.reactivated_at && <span className="chip chip-muted us-history-flag">Reactivated</span>}
|
|
40
|
+
<i className="bi bi-chevron-down us-history-chev" />
|
|
41
|
+
</summary>
|
|
42
|
+
<div className="us-history-body">
|
|
43
|
+
<div><strong>Disabled:</strong> {entry.at ? fmt.datetime(entry.at) : '—'}</div>
|
|
44
|
+
{entry.note && <div><strong>Note:</strong> {entry.note}</div>}
|
|
45
|
+
{entry.reactivated_at && (
|
|
46
|
+
<div className="us-history-react">
|
|
47
|
+
<div>
|
|
48
|
+
<strong>Reactivated:</strong> {fmt.datetime(entry.reactivated_at)}
|
|
49
|
+
{entry.reactivated_by_username && <> by <code>{entry.reactivated_by_username}</code></>}
|
|
50
|
+
</div>
|
|
51
|
+
{entry.reactivated_note && <div><strong>Note:</strong> {entry.reactivated_note}</div>}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</details>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</div>
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Feed rows ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function LogFeedRow({ log, showPath }: { log: LogRow; showPath: boolean }) {
|
|
66
|
+
const tone = LOG_LEVEL_TONE[(log.level ?? '').toLowerCase()] ?? 'muted';
|
|
67
|
+
const icon = LOG_LEVEL_ICON[(log.level ?? '').toLowerCase()] ?? 'bi-circle';
|
|
68
|
+
return (
|
|
69
|
+
<div className={`us-audit-row tone-${tone}`}>
|
|
70
|
+
<div className="us-row-icon"><i className={`bi ${icon}`} /></div>
|
|
71
|
+
<div className="us-row-info">
|
|
72
|
+
<div className="us-row-title">{log.kind || log.level || 'event'}</div>
|
|
73
|
+
<div className="us-row-meta">{log.log || '(no message)'}</div>
|
|
74
|
+
{showPath && log.path && <div className="us-row-path"><code>{log.path}</code></div>}
|
|
75
|
+
</div>
|
|
76
|
+
<span className="us-row-when" title={fmt.datetime(log.created)}>{fmt.relative(log.created)}</span>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function LogsTab({ params, placeholder, empty, showPath }: {
|
|
82
|
+
params: Record<string, string | number>;
|
|
83
|
+
placeholder: string;
|
|
84
|
+
empty: string;
|
|
85
|
+
showPath: boolean;
|
|
86
|
+
}) {
|
|
87
|
+
const list = useSectionList(5, { ...params, sort: '-created' });
|
|
88
|
+
const { data, isPending, isError, error } = LogModel.useList(list.params);
|
|
89
|
+
const rows = data?.rows ?? [];
|
|
90
|
+
const groups = groupRowsByDay(rows, (l) => l.created);
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<div className="us-list-head">
|
|
94
|
+
<SectionSearch state={list} placeholder={placeholder} />
|
|
95
|
+
</div>
|
|
96
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
97
|
+
{isError && <p className="dim">{error instanceof Error ? error.message : 'Failed to load.'}</p>}
|
|
98
|
+
{!isPending && !isError && rows.length === 0 && (
|
|
99
|
+
<div className="us-empty"><i className="bi bi-clock-history" /><div>{empty}</div></div>
|
|
100
|
+
)}
|
|
101
|
+
{groups.map((g) => (
|
|
102
|
+
<div key={g.label}>
|
|
103
|
+
<div className="us-day-head">{g.label}</div>
|
|
104
|
+
{g.rows.map((l) => <LogFeedRow key={l.id} log={l} showPath={showPath} />)}
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
<Pager state={list} count={data?.count ?? 0} />
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function EventsTab({ user }: { user: UserRow }) {
|
|
113
|
+
const list = useSectionList(5, { model_name: 'account.User', model_id: user.id, sort: '-created' });
|
|
114
|
+
const { data, isPending, isError, error } = EventModel.useList(list.params);
|
|
115
|
+
const rows = data?.rows ?? [];
|
|
116
|
+
const groups = groupRowsByDay(rows, (e) => e.created);
|
|
117
|
+
return (
|
|
118
|
+
<>
|
|
119
|
+
<div className="us-list-head">
|
|
120
|
+
<SectionSearch state={list} placeholder="Search events…" />
|
|
121
|
+
</div>
|
|
122
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
123
|
+
{isError && <p className="dim">{error instanceof Error ? error.message : 'Failed to load.'}</p>}
|
|
124
|
+
{!isPending && !isError && rows.length === 0 && (
|
|
125
|
+
<div className="us-empty"><i className="bi bi-shield-exclamation" /><div>No events for this user.</div></div>
|
|
126
|
+
)}
|
|
127
|
+
{groups.map((g) => (
|
|
128
|
+
<div key={g.label}>
|
|
129
|
+
<div className="us-day-head">{g.label}</div>
|
|
130
|
+
{g.rows.map((e) => (
|
|
131
|
+
<div key={e.id} className="us-audit-row tone-info">
|
|
132
|
+
<div className="us-row-icon"><i className="bi bi-shield-exclamation" /></div>
|
|
133
|
+
<div className="us-row-info">
|
|
134
|
+
<div className="us-row-title">{e.title || e.category || 'event'}</div>
|
|
135
|
+
{e.details && <div className="us-row-meta">{e.details}</div>}
|
|
136
|
+
{e.category && <div className="us-row-path"><span className="chip chip-muted">{e.category}</span></div>}
|
|
137
|
+
</div>
|
|
138
|
+
<span className="us-row-when" title={fmt.datetime(e.created)}>{fmt.relative(e.created)}</span>
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
143
|
+
<Pager state={list} count={data?.count ?? 0} />
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── The section ───────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export function AuditSection({ user, canLogs, canEvents }: { user: UserRow; canLogs: boolean; canEvents: boolean }) {
|
|
151
|
+
const [tab, setTab] = useState(canLogs ? 'activity' : 'events');
|
|
152
|
+
const tabs = [
|
|
153
|
+
...(canLogs ? [{ key: 'activity', label: 'Activity' }, { key: 'audit', label: 'Audit Log' }] : []),
|
|
154
|
+
...(canEvents ? [{ key: 'events', label: 'Events' }] : []),
|
|
155
|
+
];
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const visible = (tab === 'events' && canEvents) || (tab !== 'events' && canLogs);
|
|
158
|
+
if (!visible) setTab(canLogs ? 'activity' : 'events');
|
|
159
|
+
}, [canEvents, canLogs, tab]);
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
<DisableHistory user={user} />
|
|
163
|
+
<Eyebrow>Audit</Eyebrow>
|
|
164
|
+
<SectionTabs
|
|
165
|
+
tabs={tabs}
|
|
166
|
+
active={tab}
|
|
167
|
+
onSelect={setTab}
|
|
168
|
+
/>
|
|
169
|
+
{/* Tabs stay mounted-but-hidden so page/search state survives a
|
|
170
|
+
tab switch (web-mojo TabView keep-alive semantic). */}
|
|
171
|
+
{canLogs && <div hidden={tab !== 'activity'}>
|
|
172
|
+
<LogsTab
|
|
173
|
+
params={{ uid: user.id }}
|
|
174
|
+
placeholder="Search activity…"
|
|
175
|
+
empty="No activity recorded yet."
|
|
176
|
+
showPath
|
|
177
|
+
/>
|
|
178
|
+
</div>}
|
|
179
|
+
{canEvents && <div hidden={tab !== 'events'}><EventsTab user={user} /></div>}
|
|
180
|
+
{canLogs && <div hidden={tab !== 'audit'}>
|
|
181
|
+
<LogsTab
|
|
182
|
+
params={{ model_name: 'account.User', model_id: user.id }}
|
|
183
|
+
placeholder="Search audit log…"
|
|
184
|
+
empty="No record changes logged."
|
|
185
|
+
showPath={false}
|
|
186
|
+
/>
|
|
187
|
+
</div>}
|
|
188
|
+
</>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Devices — UserDevicesSection port (read in full 2026-08-05): two tabs,
|
|
2
|
+
// each a searchable pageSize-5 server list.
|
|
3
|
+
//
|
|
4
|
+
// Browser — /api/user/device?user=<id>: rows carry the ua-parser
|
|
5
|
+
// device_info block (user_agent.family+major · os.family+major), a
|
|
6
|
+
// day-relative last_seen and the DUID truncated in the middle — the
|
|
7
|
+
// source row template, verbatim.
|
|
8
|
+
// Push — /api/account/devices/push?user=<id>: the REAL RegisteredDevice
|
|
9
|
+
// shape (platform / device_name / os_version / app_version /
|
|
10
|
+
// push_enabled) — web-mojo's template bound a device_info block that
|
|
11
|
+
// this backend never serializes; the row binds the live fields instead.
|
|
12
|
+
import { useEffect, useState } from 'react';
|
|
13
|
+
import { Badge, Eyebrow, fmt } from '../../../../ui';
|
|
14
|
+
import { showUserDeviceDetail } from '../../../security/devices/UserDeviceDetail';
|
|
15
|
+
import { DeviceModel, PushDeviceModel, type UserRow } from '../models';
|
|
16
|
+
import { lastSeenLabel, Pager, SectionSearch, SectionTabs, useSectionList } from './shared';
|
|
17
|
+
|
|
18
|
+
const PLATFORM_ICON: Record<string, string> = {
|
|
19
|
+
ios: 'bi-apple',
|
|
20
|
+
android: 'bi-android2',
|
|
21
|
+
web: 'bi-globe2',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function BrowserTab({ user, enabled }: { user: UserRow; enabled: boolean }) {
|
|
25
|
+
const list = useSectionList(5, { user: user.id, sort: '-last_seen' });
|
|
26
|
+
const { data, isPending } = DeviceModel.useList(list.params, { enabled });
|
|
27
|
+
const rows = data?.rows ?? [];
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<div className="us-list-head">
|
|
31
|
+
<SectionSearch state={list} placeholder="Search browser devices…" />
|
|
32
|
+
</div>
|
|
33
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
34
|
+
{!isPending && rows.length === 0 && (
|
|
35
|
+
<div className="us-empty"><i className="bi bi-laptop" /><div>No browser devices on file.</div></div>
|
|
36
|
+
)}
|
|
37
|
+
{rows.map((d) => {
|
|
38
|
+
const ua = d.device_info;
|
|
39
|
+
const browser = ua?.user_agent.family || 'Unknown browser';
|
|
40
|
+
const browserMajor = ua?.user_agent.major ?? '';
|
|
41
|
+
const os = ua?.os.family || 'Unknown OS';
|
|
42
|
+
const osMajor = ua?.os.major ?? '';
|
|
43
|
+
return (
|
|
44
|
+
// Clickable since #1291: the full device dossier
|
|
45
|
+
// (locations, logins, sessions, cross-account sharing)
|
|
46
|
+
// lives in admin/security/devices and opens as a KISS
|
|
47
|
+
// modal over this one.
|
|
48
|
+
<button
|
|
49
|
+
key={d.id}
|
|
50
|
+
type="button"
|
|
51
|
+
className="us-device-row"
|
|
52
|
+
onClick={() => showUserDeviceDetail(d.id)}
|
|
53
|
+
title="Open device details"
|
|
54
|
+
>
|
|
55
|
+
<span className="us-row-icon"><i className="bi bi-laptop" /></span>
|
|
56
|
+
<span className="us-row-info">
|
|
57
|
+
<span className="us-row-title">
|
|
58
|
+
{browser} {browserMajor} · {os} {osMajor}
|
|
59
|
+
</span>
|
|
60
|
+
<span className="us-row-meta">
|
|
61
|
+
{lastSeenLabel(d.last_seen)}
|
|
62
|
+
{d.duid && <> · <code title={d.duid}>{fmt.truncateMiddle(d.duid, 8, '…')}</code></>}
|
|
63
|
+
{d.last_ip && <> · {d.last_ip}</>}
|
|
64
|
+
</span>
|
|
65
|
+
</span>
|
|
66
|
+
<Badge tone="info">Browser</Badge>
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
<Pager state={list} count={data?.count ?? 0} />
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function PushTab({ user, enabled }: { user: UserRow; enabled: boolean }) {
|
|
76
|
+
const list = useSectionList(5, { user: user.id, sort: '-last_seen' });
|
|
77
|
+
const { data, isPending } = PushDeviceModel.useList(list.params, { enabled });
|
|
78
|
+
const rows = data?.rows ?? [];
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<div className="us-list-head">
|
|
82
|
+
<SectionSearch state={list} placeholder="Search push devices…" />
|
|
83
|
+
</div>
|
|
84
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
85
|
+
{!isPending && rows.length === 0 && (
|
|
86
|
+
<div className="us-empty"><i className="bi bi-bell" /><div>No push devices on file.</div></div>
|
|
87
|
+
)}
|
|
88
|
+
{rows.map((d) => (
|
|
89
|
+
<div key={d.id} className="us-device-row">
|
|
90
|
+
<div className="us-row-icon"><i className={`bi ${PLATFORM_ICON[d.platform] ?? 'bi-bell'}`} /></div>
|
|
91
|
+
<div className="us-row-info">
|
|
92
|
+
<div className="us-row-title">
|
|
93
|
+
{d.device_name || d.device_id}
|
|
94
|
+
{d.os_version && <span className="dim"> · {d.platform} {d.os_version}</span>}
|
|
95
|
+
</div>
|
|
96
|
+
<div className="us-row-meta">
|
|
97
|
+
{lastSeenLabel(d.last_seen)}
|
|
98
|
+
{d.app_version && <> · app {d.app_version}</>}
|
|
99
|
+
{' · '}<code title={d.device_id}>{fmt.truncateMiddle(d.device_id, 8, '…')}</code>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<Badge tone={d.push_enabled ? 'primary' : 'muted'}>
|
|
103
|
+
<i className="bi bi-bell" /> {d.push_enabled ? 'Push' : 'Push off'}
|
|
104
|
+
</Badge>
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
<Pager state={list} count={data?.count ?? 0} />
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function DevicesSection({ user, counts, canBrowser, canPush }: {
|
|
113
|
+
user: UserRow;
|
|
114
|
+
counts: { browser: number; push: number };
|
|
115
|
+
canBrowser: boolean;
|
|
116
|
+
canPush: boolean;
|
|
117
|
+
}) {
|
|
118
|
+
const [tab, setTab] = useState(canBrowser ? 'browser' : 'push');
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (tab === 'browser' && !canBrowser) setTab('push');
|
|
121
|
+
if (tab === 'push' && !canPush) setTab('browser');
|
|
122
|
+
}, [canBrowser, canPush, tab]);
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<Eyebrow>Devices & sessions</Eyebrow>
|
|
126
|
+
<SectionTabs
|
|
127
|
+
tabs={[
|
|
128
|
+
...(canBrowser ? [{ key: 'browser', label: 'Browser', badge: counts.browser || null }] : []),
|
|
129
|
+
...(canPush ? [{ key: 'push', label: 'Push', badge: counts.push || null }] : []),
|
|
130
|
+
]}
|
|
131
|
+
active={tab}
|
|
132
|
+
onSelect={setTab}
|
|
133
|
+
/>
|
|
134
|
+
{/* Tabs stay mounted-but-hidden so page/search state survives a
|
|
135
|
+
tab switch (web-mojo TabView keep-alive semantic). */}
|
|
136
|
+
{canBrowser && <div hidden={tab !== 'browser'}><BrowserTab user={user} enabled={canBrowser} /></div>}
|
|
137
|
+
{canPush && <div hidden={tab !== 'push'}><PushTab user={user} enabled={canPush} /></div>}
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Groups — the source's ListView-of-MemberList scoped to this user (read in
|
|
2
|
+
// full 2026-08-05): group-membership cards (name + kind badge + joined date)
|
|
3
|
+
// with the per-group permission keys as chips below, server search
|
|
4
|
+
// (SEARCH_FIELDS sweep user__username/email/display_name — search here
|
|
5
|
+
// narrows by the group side too on the mock) and pageSize-5 paging. Row
|
|
6
|
+
// click opens the group's detail, stacked.
|
|
7
|
+
import { Badge, fmt } from '../../../../ui';
|
|
8
|
+
import { MemberModel } from '../../members';
|
|
9
|
+
import type { UserRow } from '../models';
|
|
10
|
+
import { Pager, SectionSearch, useSectionList } from './shared';
|
|
11
|
+
|
|
12
|
+
export function GroupsSection({ user, onOpenGroup }: { user: UserRow; onOpenGroup?: (groupId: number) => void }) {
|
|
13
|
+
const list = useSectionList(5, { user: user.id, sort: '-created' });
|
|
14
|
+
const { data, isPending } = MemberModel.useList(list.params);
|
|
15
|
+
const rows = data?.rows ?? [];
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<div className="us-list-head">
|
|
20
|
+
<SectionSearch state={list} placeholder="Search groups…" />
|
|
21
|
+
</div>
|
|
22
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
23
|
+
{!isPending && rows.length === 0 && (
|
|
24
|
+
<div className="us-empty">
|
|
25
|
+
<i className="bi bi-people" />
|
|
26
|
+
<div>{list.search ? 'No memberships match this search.' : 'This user has no group memberships.'}</div>
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
{rows.map((m) => {
|
|
30
|
+
const grants = Object.entries(m.permissions ?? {})
|
|
31
|
+
.filter(([, v]) => v === true || v === 1)
|
|
32
|
+
.map(([k]) => k);
|
|
33
|
+
const content = (
|
|
34
|
+
<>
|
|
35
|
+
<div className="us-feed-top">
|
|
36
|
+
<strong>{m.group?.name ?? '—'}</strong>
|
|
37
|
+
{m.group?.kind && <Badge tone="muted">{m.group.kind}</Badge>}
|
|
38
|
+
{!m.is_active && <Badge tone="warning">Membership disabled</Badge>}
|
|
39
|
+
<span className="us-feed-when dim">Joined {fmt.date(m.created, '—')}</span>
|
|
40
|
+
</div>
|
|
41
|
+
{grants.length > 0 && (
|
|
42
|
+
<div className="us-feed-body chip-row">
|
|
43
|
+
{grants.map((g) => <span key={g} className="chip chip-muted">{g}</span>)}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
return onOpenGroup && m.group?.id ? (
|
|
49
|
+
<button
|
|
50
|
+
key={m.id}
|
|
51
|
+
type="button"
|
|
52
|
+
className="us-feed-row"
|
|
53
|
+
onClick={() => onOpenGroup(m.group!.id)}
|
|
54
|
+
>
|
|
55
|
+
{content}
|
|
56
|
+
</button>
|
|
57
|
+
) : (
|
|
58
|
+
<div key={m.id} className="us-feed-row">{content}</div>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
<Pager state={list} count={data?.count ?? 0} />
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Logins — the source's Locations section (read in full 2026-08-05).
|
|
2
|
+
//
|
|
3
|
+
// ── THE MAP TAB IS NOW SHIPPED (#1291) ───────────────────────────────
|
|
4
|
+
// This file previously carried an explicit deviation note: web-mojo's
|
|
5
|
+
// Locations section opened on a MapLibre map with the login list as the
|
|
6
|
+
// second tab, and portal-mojo shipped the list alone because "no map
|
|
7
|
+
// library". That seam is CLOSED — #1426's WorldMap is a dependency-free SVG
|
|
8
|
+
// rebuild, so the Map tab mounts here exactly as the source had it, with the
|
|
9
|
+
// same wire (`/api/account/logins/user?user_id=`) and zero new dependencies.
|
|
10
|
+
//
|
|
11
|
+
// The Map tab is present only when the caller can read the aggregation
|
|
12
|
+
// endpoint; absent, it issues NO request at all rather than a denied one.
|
|
13
|
+
import { useState } from 'react';
|
|
14
|
+
import { Eyebrow, fmt } from '../../../../ui';
|
|
15
|
+
import { useCan } from '../../../../client';
|
|
16
|
+
import { LoginLocationMap } from '../../../security/devices/LoginLocationMap';
|
|
17
|
+
import { showLoginEventDetail } from '../../../security/devices/LoginEventDetail';
|
|
18
|
+
import { showUserDeviceDetailByDuid } from '../../../security/devices/UserDeviceDetail';
|
|
19
|
+
import { LOGIN_SUMMARY_PERMS, loginRiskTone } from '../../../security/devices/models';
|
|
20
|
+
import { LoginEventModel, type UserRow } from '../models';
|
|
21
|
+
import { groupRowsByDay, Pager, SectionSearch, SectionTabs, useSectionList } from './shared';
|
|
22
|
+
|
|
23
|
+
/** WorldMap tone → the `tone-*` class the login dot is styled with. */
|
|
24
|
+
const DOT_CLASS: Record<string, string> = { bad: 'danger', warn: 'warning', ok: 'success' };
|
|
25
|
+
|
|
26
|
+
function LoginList({ user }: { user: UserRow }) {
|
|
27
|
+
const list = useSectionList(5, { user: user.id, sort: '-created' });
|
|
28
|
+
const { data, isPending } = LoginEventModel.useList(list.params);
|
|
29
|
+
const rows = data?.rows ?? [];
|
|
30
|
+
const groups = groupRowsByDay(rows, (l) => l.created);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<div className="us-list-head">
|
|
35
|
+
<SectionSearch state={list} placeholder="Search logins…" />
|
|
36
|
+
</div>
|
|
37
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
38
|
+
{!isPending && rows.length === 0 && (
|
|
39
|
+
<div className="us-empty"><i className="bi bi-geo-alt" /><div>No login events on file.</div></div>
|
|
40
|
+
)}
|
|
41
|
+
{groups.map((g) => (
|
|
42
|
+
<div key={g.label}>
|
|
43
|
+
<div className="us-day-head">{g.label}</div>
|
|
44
|
+
{g.rows.map((l) => {
|
|
45
|
+
const where = [
|
|
46
|
+
l.city ?? '—',
|
|
47
|
+
l.region && l.region !== l.city ? l.region : null,
|
|
48
|
+
].filter(Boolean).join(', ');
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
key={l.id}
|
|
52
|
+
type="button"
|
|
53
|
+
className="us-login-row"
|
|
54
|
+
onClick={() => showLoginEventDetail(l.id, { onOpenDeviceByDuid: showUserDeviceDetailByDuid })}
|
|
55
|
+
>
|
|
56
|
+
<span className={`us-login-dot tone-${DOT_CLASS[loginRiskTone(l)] ?? 'muted'}`} />
|
|
57
|
+
<span className="us-row-info">
|
|
58
|
+
<span className="us-row-title">
|
|
59
|
+
{where}
|
|
60
|
+
{l.country_code && <span className="dim"> · {l.country_code}</span>}
|
|
61
|
+
{l.is_new_country && <span className="chip chip-warning us-chip-sm">New country</span>}
|
|
62
|
+
</span>
|
|
63
|
+
<span className="us-row-meta">
|
|
64
|
+
{l.ip_address && <code>{l.ip_address}</code>}
|
|
65
|
+
{l.source && <> · {l.source}</>}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
<span className="us-row-when" title={fmt.datetime(l.created)}>{fmt.relative(l.created)}</span>
|
|
69
|
+
</button>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
<Pager state={list} count={data?.count ?? 0} />
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function LoginsSection({ user }: { user: UserRow }) {
|
|
80
|
+
const canMap = useCan(LOGIN_SUMMARY_PERMS).can;
|
|
81
|
+
const [tab, setTab] = useState(canMap ? 'map' : 'logins');
|
|
82
|
+
const active = canMap ? tab : 'logins';
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<Eyebrow>Login history</Eyebrow>
|
|
87
|
+
{canMap && (
|
|
88
|
+
<SectionTabs
|
|
89
|
+
tabs={[{ key: 'map', label: 'Map' }, { key: 'logins', label: 'Logins' }]}
|
|
90
|
+
active={active}
|
|
91
|
+
onSelect={setTab}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
{/* The map is scoped to this user and mounts only when it can be
|
|
95
|
+
read — no denied background request when the grant is absent. */}
|
|
96
|
+
{canMap && (
|
|
97
|
+
<div hidden={active !== 'map'}>
|
|
98
|
+
<LoginLocationMap userId={user.id} height={280} enabled={canMap} />
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
{/* Kept mounted-but-hidden so page/search state survives a tab
|
|
102
|
+
switch (web-mojo TabView keep-alive semantic). */}
|
|
103
|
+
<div hidden={active !== 'logins'}><LoginList user={user} /></div>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Metadata — two halves, both from the existing toolkit:
|
|
2
|
+
// · the editable metadata blob via ui's MetadataSection (which IS the
|
|
3
|
+
// AdminMetadataSection port — add/edit/remove, JSON-parsed values,
|
|
4
|
+
// rejected saves keep the editor open), saving through the model cache
|
|
5
|
+
// so every other section sees the new blob;
|
|
6
|
+
// · the whole-record JSON view via DataView (the source section's "JSON
|
|
7
|
+
// dump" reading) — inference-typed grid + collapsible JSON blocks.
|
|
8
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
9
|
+
import { DataView, Eyebrow, MetadataSection } from '../../../../ui';
|
|
10
|
+
import { UserModel, type UserRow } from '../models';
|
|
11
|
+
|
|
12
|
+
export function UserMetadataSection({ user, canManage }: { user: UserRow; canManage: boolean }) {
|
|
13
|
+
const qc = useQueryClient();
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
{canManage && <MetadataSection
|
|
17
|
+
endpoint={UserModel.endpoint}
|
|
18
|
+
id={user.id}
|
|
19
|
+
metadata={(user.metadata ?? {}) as Record<string, unknown>}
|
|
20
|
+
onSaved={(next) => {
|
|
21
|
+
// Owner write-back: paint the new blob into the one-record
|
|
22
|
+
// cache, then let invalidation refresh lists.
|
|
23
|
+
qc.setQueryData(UserModel.keys.one(user.id), { ...user, metadata: next });
|
|
24
|
+
void UserModel.invalidate(qc);
|
|
25
|
+
}}
|
|
26
|
+
/>}
|
|
27
|
+
|
|
28
|
+
<Eyebrow>Raw record</Eyebrow>
|
|
29
|
+
<DataView
|
|
30
|
+
data={user as unknown as Record<string, unknown>}
|
|
31
|
+
exclude={['metadata']}
|
|
32
|
+
columns={2}
|
|
33
|
+
/>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Notifications — AdminNotificationsSection port (read in full 2026-08-05):
|
|
2
|
+
// the per-kind × per-channel (In-App / Email / Push) toggle grid over
|
|
3
|
+
// /api/account/notification/preferences. A toggle POSTs the PARTIAL update
|
|
4
|
+
// {preferences: {kind: {channel: bool}}} optimistically and reverts on
|
|
5
|
+
// failure (source semantics; absent channels read as ON — `!== false`).
|
|
6
|
+
//
|
|
7
|
+
// LIVE-SCOPE NOTE (django-mojo notification_prefs.py, read 2026-08-05): the
|
|
8
|
+
// real handler reads request.user and IGNORES the user param — the
|
|
9
|
+
// admin-views-another-user path is not a backend surface. Other-user detail
|
|
10
|
+
// therefore stays explicitly unavailable and never issues this query.
|
|
11
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
12
|
+
import { mojoCall, useMe } from '../../../../client';
|
|
13
|
+
import { Eyebrow, toast } from '../../../../ui';
|
|
14
|
+
import type { UserRow } from '../models';
|
|
15
|
+
|
|
16
|
+
const CHANNELS = ['in_app', 'email', 'push'] as const;
|
|
17
|
+
const CHANNEL_LABELS: Record<string, string> = {
|
|
18
|
+
in_app: 'In-App',
|
|
19
|
+
email: 'Email',
|
|
20
|
+
push: 'Push',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Preferences = Record<string, Record<string, boolean>>;
|
|
24
|
+
|
|
25
|
+
const prefsKey = (userId: number) => ['/api/account/notification/preferences', userId] as const;
|
|
26
|
+
|
|
27
|
+
export function NotificationsSection({ user }: { user: UserRow }) {
|
|
28
|
+
const qc = useQueryClient();
|
|
29
|
+
const { data: me } = useMe();
|
|
30
|
+
const isSelf = me?.id === user.id;
|
|
31
|
+
const { data: prefs, isPending, isError, error } = useQuery<Preferences>({
|
|
32
|
+
queryKey: prefsKey(user.id),
|
|
33
|
+
queryFn: async () => {
|
|
34
|
+
const body = await mojoCall('/api/account/notification/preferences');
|
|
35
|
+
const data = body.data as { preferences?: Preferences } | undefined;
|
|
36
|
+
return data?.preferences ?? {};
|
|
37
|
+
},
|
|
38
|
+
enabled: isSelf,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const toggle = async (kind: string, channel: string, next: boolean) => {
|
|
42
|
+
// Optimistic write into the query cache; revert + toast on rejection.
|
|
43
|
+
const prev = qc.getQueryData<Preferences>(prefsKey(user.id)) ?? {};
|
|
44
|
+
qc.setQueryData<Preferences>(prefsKey(user.id), {
|
|
45
|
+
...prev,
|
|
46
|
+
[kind]: { ...(prev[kind] ?? {}), [channel]: next },
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
await mojoCall('/api/account/notification/preferences', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
body: { preferences: { [kind]: { [channel]: next } } },
|
|
52
|
+
});
|
|
53
|
+
} catch (err) {
|
|
54
|
+
qc.setQueryData(prefsKey(user.id), prev);
|
|
55
|
+
toast.error(err instanceof Error ? err.message : 'Failed to update preference');
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const kinds = Object.keys(prefs ?? {}).sort();
|
|
60
|
+
const kindLabel = (kind: string) =>
|
|
61
|
+
kind.replace(/[_-]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<Eyebrow>Notification preferences</Eyebrow>
|
|
66
|
+
{!isSelf && (
|
|
67
|
+
<div className="us-empty">
|
|
68
|
+
<i className="bi bi-lock" />
|
|
69
|
+
<div>Notification preferences are caller-only and cannot be administered for another user.</div>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
{isSelf && <>
|
|
73
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
74
|
+
{isError && <p className="dim">{error instanceof Error ? error.message : 'Failed to load preferences.'}</p>}
|
|
75
|
+
{!isPending && !isError && kinds.length === 0 && (
|
|
76
|
+
<div className="us-empty">
|
|
77
|
+
<i className="bi bi-bell" />
|
|
78
|
+
<div>No notification preferences configured</div>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
{kinds.length > 0 && (
|
|
82
|
+
<table className="us-prefs-table">
|
|
83
|
+
<thead>
|
|
84
|
+
<tr>
|
|
85
|
+
<th>Type</th>
|
|
86
|
+
{CHANNELS.map((ch) => <th key={ch}>{CHANNEL_LABELS[ch]}</th>)}
|
|
87
|
+
</tr>
|
|
88
|
+
</thead>
|
|
89
|
+
<tbody>
|
|
90
|
+
{kinds.map((kind) => (
|
|
91
|
+
<tr key={kind}>
|
|
92
|
+
<td>{kindLabel(kind)}</td>
|
|
93
|
+
{CHANNELS.map((ch) => {
|
|
94
|
+
// Absent channel == enabled (source `!== false`).
|
|
95
|
+
const checked = prefs?.[kind]?.[ch] !== false;
|
|
96
|
+
return (
|
|
97
|
+
<td key={ch}>
|
|
98
|
+
<input
|
|
99
|
+
type="checkbox"
|
|
100
|
+
role="switch"
|
|
101
|
+
className="switch"
|
|
102
|
+
checked={checked}
|
|
103
|
+
aria-label={`${kindLabel(kind)} via ${CHANNEL_LABELS[ch]}`}
|
|
104
|
+
onChange={(e) => void toggle(kind, ch, e.target.checked)}
|
|
105
|
+
/>
|
|
106
|
+
</td>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</tr>
|
|
110
|
+
))}
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
)}
|
|
114
|
+
</>}
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
}
|