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,546 @@
|
|
|
1
|
+
// UserDeviceDetail — the DeviceView port (961 lines, read in full), as a
|
|
2
|
+
// KISS `modal.detail`.
|
|
3
|
+
//
|
|
4
|
+
// What the source had that is NOT carried, because the backend field does not
|
|
5
|
+
// exist: the `is_trusted` active toggle, the "Mark trusted/untrusted" kebab
|
|
6
|
+
// item, the Trusted and Blocked chips, the TRUST timeline row, and "Forget
|
|
7
|
+
// device" (no CAN_DELETE anywhere on UserDevice). Also dropped: the
|
|
8
|
+
// `session_count` / `location_count` KPIs and chips, which existed on no
|
|
9
|
+
// model and rendered "—" forever.
|
|
10
|
+
//
|
|
11
|
+
// What is NEW because the backend actually has it: the Sessions section is
|
|
12
|
+
// REAL — `UserDevice`'s `sessions` graph exposes `bouncer_device`,
|
|
13
|
+
// `active_sessions` (24h of BouncerSignal grouped by msid, with a per-tab
|
|
14
|
+
// breakdown) and `recent_locations`. The source's "Session history is not yet
|
|
15
|
+
// recorded server-side" placeholder is obsolete. Logins are new too: the
|
|
16
|
+
// `UserLoginEvent.device` FK exists and web-mojo never used it.
|
|
17
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
18
|
+
import {
|
|
19
|
+
Badge, DetailView, Eyebrow, FlatRow, KnownFieldsCard, MetricCard, Timeline,
|
|
20
|
+
fmt, modal, toast, type Chip, type Tone,
|
|
21
|
+
} from '../../../ui';
|
|
22
|
+
import { useCan } from '../../../client';
|
|
23
|
+
import { BOUNCER_VIEW_PERMS, BouncerDeviceModel } from '../../bouncer/models';
|
|
24
|
+
import { showBouncerDeviceDetail } from '../../bouncer/devices';
|
|
25
|
+
import { GEOIP_VIEW_PERMS, countryFlag, threatTone } from '../geoip/models';
|
|
26
|
+
import { showGeoIpDossierForAddress } from '../geoip/GeoIpDossier';
|
|
27
|
+
import { showLoginEventDetail } from './LoginEventDetail';
|
|
28
|
+
import { showUserDeviceLocationDetail } from './UserDeviceLocationDetail';
|
|
29
|
+
import {
|
|
30
|
+
DEVICE_LOCATION_VIEW_PERMS, LOGIN_EVENT_VIEW_PERMS,
|
|
31
|
+
LoginEventModel, UserDeviceLocationModel, UserDeviceModel,
|
|
32
|
+
browserLabel, browserVersion, daysActive, deviceIcon, deviceLabel,
|
|
33
|
+
osLabel, osVersion, presenceOf, useDeviceSiblings, useUserDeviceSessions,
|
|
34
|
+
type UserDeviceRow,
|
|
35
|
+
} from './models';
|
|
36
|
+
|
|
37
|
+
const DASH = <span className="dim-italic">—</span>;
|
|
38
|
+
|
|
39
|
+
/** parse_user_agent emits no screen/locale/timezone block. The rows stay so
|
|
40
|
+
* the shape is documented, but they render an honest em dash rather than
|
|
41
|
+
* pretending a field that never arrives is merely empty. */
|
|
42
|
+
const NOT_COLLECTED = <span className="dim-italic" title="parse_user_agent does not collect this">—</span>;
|
|
43
|
+
|
|
44
|
+
function presenceChip(row: UserDeviceRow): Chip {
|
|
45
|
+
const presence = presenceOf(row.last_seen);
|
|
46
|
+
if (presence === 'online') return { icon: 'bi-circle-fill', text: 'Online', tone: 'success' };
|
|
47
|
+
if (presence === 'never') return { icon: 'bi-circle', text: 'Never seen', tone: 'muted' };
|
|
48
|
+
return { icon: 'bi-circle', text: 'Offline', tone: 'muted' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Sections ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function LocationsSection({ deviceId, canView }: { deviceId: number; canView: boolean }) {
|
|
54
|
+
const query = UserDeviceLocationModel.useList(
|
|
55
|
+
{ user_device: deviceId, size: 25, sort: '-last_seen' },
|
|
56
|
+
{ enabled: canView },
|
|
57
|
+
);
|
|
58
|
+
const rows = query.data?.rows ?? [];
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<Eyebrow>Locations</Eyebrow>
|
|
62
|
+
{!canView && <p className="dim-italic">You do not have permission to read device locations.</p>}
|
|
63
|
+
{canView && query.isPending && <p className="dim">Loading locations…</p>}
|
|
64
|
+
{canView && query.error && <p className="text-bad">{query.error.message}</p>}
|
|
65
|
+
{canView && !query.isPending && !rows.length && <p className="dim-italic">No locations recorded for this device.</p>}
|
|
66
|
+
{rows.map((row) => {
|
|
67
|
+
const geo = row.geolocation;
|
|
68
|
+
const place = [geo?.city, geo?.region].filter(Boolean).join(', ') || geo?.country_name || 'Unknown location';
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
key={row.id}
|
|
72
|
+
type="button"
|
|
73
|
+
className="ud-loc-row"
|
|
74
|
+
onClick={() => showUserDeviceLocationDetail(row.id)}
|
|
75
|
+
>
|
|
76
|
+
<span className="ud-loc-main">
|
|
77
|
+
<span className="ud-loc-title">
|
|
78
|
+
<i className="bi bi-geo-alt" /> {place}
|
|
79
|
+
{geo?.country_name && <span className="dim"> · {countryFlag(geo.country_code)} {geo.country_name}</span>}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="ud-loc-meta">
|
|
82
|
+
<code>{row.ip_address}</code>
|
|
83
|
+
{(geo?.isp || geo?.asn_org) && <> · {geo?.isp || geo?.asn_org}</>}
|
|
84
|
+
{geo?.is_vpn && <Badge tone="warning">VPN</Badge>}
|
|
85
|
+
{geo?.is_tor && <Badge tone="danger">Tor</Badge>}
|
|
86
|
+
{geo?.is_proxy && <Badge tone="warning">Proxy</Badge>}
|
|
87
|
+
{geo?.is_cloud && <Badge tone="info">Cloud</Badge>}
|
|
88
|
+
</span>
|
|
89
|
+
</span>
|
|
90
|
+
<span className="ud-loc-when" title={fmt.datetime(row.last_seen)}>{fmt.relative(row.last_seen)}</span>
|
|
91
|
+
</button>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function LoginsSectionBody({ deviceId, canView }: { deviceId: number; canView: boolean }) {
|
|
99
|
+
const query = LoginEventModel.useList(
|
|
100
|
+
{ device: deviceId, size: 25, sort: '-created', graph: 'list' },
|
|
101
|
+
{ enabled: canView },
|
|
102
|
+
);
|
|
103
|
+
const rows = query.data?.rows ?? [];
|
|
104
|
+
// Display-only day grouping — the order stays the server's.
|
|
105
|
+
const groups: { label: string; rows: typeof rows }[] = [];
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
const label = fmt.date(row.created);
|
|
108
|
+
const last = groups[groups.length - 1];
|
|
109
|
+
if (last && last.label === label) last.rows.push(row);
|
|
110
|
+
else groups.push({ label, rows: [row] });
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<Eyebrow>Logins from this device</Eyebrow>
|
|
115
|
+
{!canView && <p className="dim-italic">You do not have permission to read login events.</p>}
|
|
116
|
+
{canView && query.isPending && <p className="dim">Loading logins…</p>}
|
|
117
|
+
{canView && query.error && <p className="text-bad">{query.error.message}</p>}
|
|
118
|
+
{canView && !query.isPending && !rows.length && (
|
|
119
|
+
<p className="dim-italic">No logins are linked to this device record.</p>
|
|
120
|
+
)}
|
|
121
|
+
{groups.map((group) => (
|
|
122
|
+
<div key={group.label}>
|
|
123
|
+
<div className="us-day-head">{group.label}</div>
|
|
124
|
+
{group.rows.map((row) => (
|
|
125
|
+
<button key={row.id} type="button" className="ud-loc-row" onClick={() => showLoginEventDetail(row.id)}>
|
|
126
|
+
<span className="ud-loc-main">
|
|
127
|
+
<span className="ud-loc-title">
|
|
128
|
+
{[row.city, row.region, row.country_code].filter(Boolean).join(', ') || 'Unknown location'}
|
|
129
|
+
{row.is_new_country && <Badge tone="danger">New country</Badge>}
|
|
130
|
+
{!row.is_new_country && row.is_new_region && <Badge tone="warning">New region</Badge>}
|
|
131
|
+
</span>
|
|
132
|
+
<span className="ud-loc-meta">
|
|
133
|
+
{row.ip_address && <code>{row.ip_address}</code>}
|
|
134
|
+
{row.source && <> · {row.source}</>}
|
|
135
|
+
</span>
|
|
136
|
+
</span>
|
|
137
|
+
<span className="ud-loc-when" title={fmt.datetime(row.created)}>{fmt.relative(row.created)}</span>
|
|
138
|
+
</button>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
))}
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function SessionsSection({ device, canBouncer }: { device: UserDeviceRow; canBouncer: boolean }) {
|
|
147
|
+
const { data, isPending, error } = useUserDeviceSessions(device.id, Boolean(device.muid));
|
|
148
|
+
// Resolve the BouncerDevice row so "Open Bouncer device" has an id.
|
|
149
|
+
const bouncer = BouncerDeviceModel.useList(
|
|
150
|
+
{ muid: device.muid ?? '', size: 1 },
|
|
151
|
+
{ enabled: canBouncer && Boolean(device.muid) },
|
|
152
|
+
);
|
|
153
|
+
const bouncerRow = bouncer.data?.rows?.[0];
|
|
154
|
+
|
|
155
|
+
if (!device.muid) {
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
<Eyebrow>Sessions</Eyebrow>
|
|
159
|
+
<p className="dim-italic">
|
|
160
|
+
This device predates the Bouncer MUID cookie, so no pre-auth reputation or
|
|
161
|
+
session grouping is available for it. Sharing falls back to the DUID.
|
|
162
|
+
</p>
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
<Eyebrow>Pre-auth reputation</Eyebrow>
|
|
170
|
+
{isPending && <p className="dim">Loading sessions…</p>}
|
|
171
|
+
{error && <p className="text-bad">{error.message}</p>}
|
|
172
|
+
{data?.bouncer_device
|
|
173
|
+
? (
|
|
174
|
+
<>
|
|
175
|
+
<FlatRow label="Risk tier"><Badge tone={data.bouncer_device.risk_tier === 'low' ? 'success' : data.bouncer_device.risk_tier === 'medium' ? 'warning' : data.bouncer_device.risk_tier === 'unknown' ? 'muted' : 'danger'}>{data.bouncer_device.risk_tier.toUpperCase()}</Badge></FlatRow>
|
|
176
|
+
<FlatRow label="Signals seen">{data.bouncer_device.event_count}</FlatRow>
|
|
177
|
+
<FlatRow label="Blocks">{data.bouncer_device.block_count}</FlatRow>
|
|
178
|
+
<FlatRow label="Fingerprint">{data.bouncer_device.fingerprint_id ? <code>{data.bouncer_device.fingerprint_id}</code> : DASH}</FlatRow>
|
|
179
|
+
<FlatRow label="First seen">{fmt.datetime(data.bouncer_device.first_seen)}</FlatRow>
|
|
180
|
+
{canBouncer && bouncerRow && (
|
|
181
|
+
<div className="geoip-actions">
|
|
182
|
+
<button className="btn btn-compact" onClick={() => showBouncerDeviceDetail(bouncerRow.id)}>
|
|
183
|
+
<i className="bi bi-fingerprint" /> Open Bouncer device
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</>
|
|
188
|
+
)
|
|
189
|
+
: !isPending && <p className="dim-italic">No pre-auth Bouncer record for this MUID.</p>}
|
|
190
|
+
|
|
191
|
+
<Eyebrow>Active sessions (last 24h)</Eyebrow>
|
|
192
|
+
{!isPending && !(data?.active_sessions ?? []).length && (
|
|
193
|
+
<p className="dim-italic">No browser sessions recorded in the last 24 hours.</p>
|
|
194
|
+
)}
|
|
195
|
+
{(data?.active_sessions ?? []).map((session) => (
|
|
196
|
+
<div key={session.msid} className="ud-session">
|
|
197
|
+
<div className="ud-session-head">
|
|
198
|
+
<code title={session.msid}>{fmt.truncateMiddle(session.msid, 16)}</code>
|
|
199
|
+
<span className="dim">{session.ip || '—'}</span>
|
|
200
|
+
<span className="dim">{session.signal_count} signal{session.signal_count === 1 ? '' : 's'}</span>
|
|
201
|
+
<span className="ud-loc-when">{fmt.relative(session.last_activity)}</span>
|
|
202
|
+
</div>
|
|
203
|
+
<div className="ud-session-meta dim">
|
|
204
|
+
Started {fmt.datetime(session.started)}
|
|
205
|
+
{session.tabs.length > 0 && <> · {session.tabs.length} tab{session.tabs.length === 1 ? '' : 's'}</>}
|
|
206
|
+
</div>
|
|
207
|
+
{session.tabs.length > 0 && (
|
|
208
|
+
<ul className="ud-session-tabs">
|
|
209
|
+
{session.tabs.map((tab) => (
|
|
210
|
+
<li key={tab.mtab}>
|
|
211
|
+
<code>{fmt.truncateMiddle(tab.mtab, 12)}</code>
|
|
212
|
+
<span className="dim">{tab.signal_count} signal{tab.signal_count === 1 ? '' : 's'} · last {fmt.relative(tab.last_activity)}</span>
|
|
213
|
+
</li>
|
|
214
|
+
))}
|
|
215
|
+
</ul>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
|
|
220
|
+
<Eyebrow>Recent locations</Eyebrow>
|
|
221
|
+
{!isPending && !(data?.recent_locations ?? []).length && (
|
|
222
|
+
<p className="dim-italic">No recent locations on the sessions graph.</p>
|
|
223
|
+
)}
|
|
224
|
+
{(data?.recent_locations ?? []).map((loc) => (
|
|
225
|
+
<FlatRow key={`${loc.ip_address}-${loc.first_seen}`} label={loc.ip_address}>
|
|
226
|
+
{[loc.city, loc.country].filter(Boolean).join(', ') || <span className="dim">Unenriched</span>}
|
|
227
|
+
<span className="dim"> · last seen {fmt.relative(loc.last_seen)}</span>
|
|
228
|
+
</FlatRow>
|
|
229
|
+
))}
|
|
230
|
+
</>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** "Is this device shared across accounts?" — the item's headline question. */
|
|
235
|
+
function SharedSection({ device, onOpenUser }: { device: UserDeviceRow; onOpenUser?: (userId: number) => void }) {
|
|
236
|
+
const { siblings, isPending, error } = useDeviceSiblings(device);
|
|
237
|
+
return (
|
|
238
|
+
<>
|
|
239
|
+
<Eyebrow>Shared across accounts</Eyebrow>
|
|
240
|
+
<FlatRow label="Matched on">
|
|
241
|
+
{device.muid ? <>MUID <code>{device.muid}</code></> : <>DUID <code>{device.duid}</code> <span className="dim">(no MUID on this row)</span></>}
|
|
242
|
+
</FlatRow>
|
|
243
|
+
{isPending && <p className="dim">Looking for sibling device records…</p>}
|
|
244
|
+
{error && <p className="text-bad">{error.message}</p>}
|
|
245
|
+
{!isPending && !siblings.length && (
|
|
246
|
+
<p className="dim-italic">This device is only linked to one account.</p>
|
|
247
|
+
)}
|
|
248
|
+
{siblings.map((sibling) => (
|
|
249
|
+
<button key={sibling.id} type="button" className="ud-loc-row" onClick={() => showUserDeviceDetail(sibling.id)}>
|
|
250
|
+
<span className="ud-loc-main">
|
|
251
|
+
<span className="ud-loc-title">
|
|
252
|
+
<i className={`bi ${deviceIcon(sibling.device_info)}`} />
|
|
253
|
+
{sibling.user?.display_name || sibling.user?.username || `User #${sibling.id}`}
|
|
254
|
+
</span>
|
|
255
|
+
<span className="ud-loc-meta">
|
|
256
|
+
{browserLabel(sibling.device_info)} on {osLabel(sibling.device_info)}
|
|
257
|
+
{sibling.last_ip && <> · <code>{sibling.last_ip}</code></>}
|
|
258
|
+
</span>
|
|
259
|
+
</span>
|
|
260
|
+
<span className="ud-loc-when">{fmt.relative(sibling.last_seen)}</span>
|
|
261
|
+
</button>
|
|
262
|
+
))}
|
|
263
|
+
{siblings.length > 0 && onOpenUser && (
|
|
264
|
+
<p className="dim ud-shared-hint">
|
|
265
|
+
Sharing a device identity across accounts is normal for shared machines and
|
|
266
|
+
suspicious for unrelated users — open each owner to judge.
|
|
267
|
+
</p>
|
|
268
|
+
)}
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── The dossier ───────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
export function UserDeviceDetail({ id, onClose, onOpenUser }: {
|
|
276
|
+
id: number;
|
|
277
|
+
onClose: () => void;
|
|
278
|
+
onOpenUser?: (userId: number) => void;
|
|
279
|
+
}) {
|
|
280
|
+
const qc = useQueryClient();
|
|
281
|
+
const { data: device, isPending, error } = UserDeviceModel.useOne(id);
|
|
282
|
+
const canLocations = useCan(DEVICE_LOCATION_VIEW_PERMS).can;
|
|
283
|
+
const canLogins = useCan(LOGIN_EVENT_VIEW_PERMS).can;
|
|
284
|
+
const canBouncer = useCan(BOUNCER_VIEW_PERMS).can;
|
|
285
|
+
const canGeoIp = useCan(GEOIP_VIEW_PERMS).can;
|
|
286
|
+
|
|
287
|
+
// Real counts replacing the phantom `session_count` / `location_count`.
|
|
288
|
+
const locationCount = UserDeviceLocationModel.useList(
|
|
289
|
+
{ user_device: id, size: 0 },
|
|
290
|
+
{ enabled: canLocations },
|
|
291
|
+
).data?.count;
|
|
292
|
+
const loginCount = LoginEventModel.useList(
|
|
293
|
+
{ device: id, size: 0 },
|
|
294
|
+
{ enabled: canLogins },
|
|
295
|
+
).data?.count;
|
|
296
|
+
const { siblings } = useDeviceSiblings(device);
|
|
297
|
+
// The most recent location supplies the threat signals the source read
|
|
298
|
+
// off a `device_info.last_geo` block the wire never sends.
|
|
299
|
+
const latestLocation = UserDeviceLocationModel.useList(
|
|
300
|
+
{ user_device: id, size: 1, sort: '-last_seen' },
|
|
301
|
+
{ enabled: canLocations },
|
|
302
|
+
).data?.rows?.[0];
|
|
303
|
+
|
|
304
|
+
if (isPending) return <div className="modal-pad dim">Loading device…</div>;
|
|
305
|
+
if (!device || error) return <div className="modal-pad text-bad">{error?.message ?? 'Device not found'}</div>;
|
|
306
|
+
|
|
307
|
+
const info = device.device_info;
|
|
308
|
+
const geo = latestLocation?.geolocation ?? null;
|
|
309
|
+
const days = daysActive(device);
|
|
310
|
+
|
|
311
|
+
const subtitle = [
|
|
312
|
+
device.last_seen != null ? `Last seen ${fmt.relative(device.last_seen)}` : 'Never seen',
|
|
313
|
+
device.last_ip ? `from ${device.last_ip}` : null,
|
|
314
|
+
geo ? `· ${[geo.city, geo.country_name].filter(Boolean).join(', ')}` : null,
|
|
315
|
+
device.user?.display_name ? `· owner ${device.user.display_name}` : null,
|
|
316
|
+
].filter(Boolean).join(' ');
|
|
317
|
+
|
|
318
|
+
const chips: Chip[] = [
|
|
319
|
+
presenceChip(device),
|
|
320
|
+
{ icon: 'bi-window', text: browserLabel(info), tone: 'info' },
|
|
321
|
+
{ text: osLabel(info) },
|
|
322
|
+
...(locationCount != null ? [{ text: `${locationCount} location${locationCount === 1 ? '' : 's'}` }] : []),
|
|
323
|
+
...(loginCount != null ? [{ text: `${loginCount} login${loginCount === 1 ? '' : 's'}` }] : []),
|
|
324
|
+
...(siblings.length ? [{ icon: 'bi-people', text: `Shared across ${siblings.length + 1} accounts`, tone: 'warning' as const }] : []),
|
|
325
|
+
...(geo?.is_vpn ? [{ icon: 'bi-shield-shaded', text: 'VPN', tone: 'warning' as const }] : []),
|
|
326
|
+
...(geo?.is_tor ? [{ icon: 'bi-shield-lock', text: 'Tor', tone: 'danger' as const }] : []),
|
|
327
|
+
...(geo?.is_proxy ? [{ icon: 'bi-diagram-3', text: 'Proxy', tone: 'warning' as const }] : []),
|
|
328
|
+
...(geo?.is_cloud ? [{ icon: 'bi-cloud', text: 'Cloud', tone: 'info' as const }] : []),
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
const copy = (label: string, value: string) => {
|
|
332
|
+
void navigator.clipboard?.writeText(value)
|
|
333
|
+
.then(() => toast.success(`${label} copied`))
|
|
334
|
+
.catch(() => toast.error(`Could not copy ${label.toLowerCase()}`));
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<DetailView
|
|
339
|
+
icon={deviceIcon(info)}
|
|
340
|
+
title={`${browserLabel(info)} on ${osLabel(info)}`}
|
|
341
|
+
subtitle={subtitle}
|
|
342
|
+
chips={chips}
|
|
343
|
+
sections={[
|
|
344
|
+
{
|
|
345
|
+
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
346
|
+
<>
|
|
347
|
+
<Eyebrow>Overview</Eyebrow>
|
|
348
|
+
<div className="geoip-kpis">
|
|
349
|
+
<MetricCard label="Locations" value={canLocations ? (locationCount ?? '—') : '—'} icon="bi-geo-alt" />
|
|
350
|
+
<MetricCard label="Logins" value={canLogins ? (loginCount ?? '—') : '—'} icon="bi-box-arrow-in-right" />
|
|
351
|
+
<MetricCard label="Days active" value={days ?? '—'} icon="bi-calendar-range" />
|
|
352
|
+
<MetricCard label="Last seen" value={device.last_seen != null ? fmt.relative(device.last_seen) : 'Never'} icon="bi-clock-history" />
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<Eyebrow>Threat signals</Eyebrow>
|
|
356
|
+
{/* NO trust row: `is_trusted` does not exist on
|
|
357
|
+
UserDevice, so the source's first timeline
|
|
358
|
+
entry reported a field it invented. */}
|
|
359
|
+
<Timeline
|
|
360
|
+
items={[
|
|
361
|
+
{
|
|
362
|
+
tone: geo?.is_vpn ? 'warning' : 'success',
|
|
363
|
+
title: geo?.is_vpn ? 'VPN detected' : 'No VPN signal',
|
|
364
|
+
meta: latestLocation ? fmt.relative(latestLocation.last_seen) : '—',
|
|
365
|
+
body: geo?.is_vpn ? 'The most recent location resolved to a VPN exit.' : undefined,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
tone: geo?.is_tor ? 'danger' : 'success',
|
|
369
|
+
title: geo?.is_tor ? 'Seen from a Tor exit' : 'No Tor signal',
|
|
370
|
+
meta: latestLocation ? fmt.relative(latestLocation.last_seen) : '—',
|
|
371
|
+
body: geo?.is_tor ? 'Recent activity routed through the Tor network.' : undefined,
|
|
372
|
+
},
|
|
373
|
+
...(geo?.is_proxy ? [{
|
|
374
|
+
tone: 'warning' as const,
|
|
375
|
+
title: 'Open proxy detected',
|
|
376
|
+
meta: fmt.relative(latestLocation!.last_seen),
|
|
377
|
+
body: 'The most recent location went through an open proxy.',
|
|
378
|
+
}] : []),
|
|
379
|
+
...(geo?.threat_level ? [{
|
|
380
|
+
tone: threatTone(geo.threat_level) as Tone,
|
|
381
|
+
title: `Last IP threat level: ${geo.threat_level}`,
|
|
382
|
+
meta: fmt.relative(latestLocation!.last_seen),
|
|
383
|
+
body: <>Risk score {geo.risk_score ?? '—'} / 100 on <code>{latestLocation!.ip_address}</code></>,
|
|
384
|
+
}] : []),
|
|
385
|
+
{
|
|
386
|
+
tone: 'info',
|
|
387
|
+
title: 'Geo footprint',
|
|
388
|
+
meta: 'live',
|
|
389
|
+
body: locationCount != null
|
|
390
|
+
? <>{locationCount} distinct location{locationCount === 1 ? '' : 's'}{geo?.country_name ? <> · last from {geo.country_name}</> : null}</>
|
|
391
|
+
: 'Location history is not visible to you.',
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
tone: 'muted',
|
|
395
|
+
title: 'Device fingerprint',
|
|
396
|
+
meta: device.first_seen != null ? fmt.relative(device.first_seen) : '',
|
|
397
|
+
body: <>DUID <code>{device.duid}</code>{device.muid ? <> · MUID <code>{device.muid}</code></> : null}</>,
|
|
398
|
+
},
|
|
399
|
+
]}
|
|
400
|
+
/>
|
|
401
|
+
</>
|
|
402
|
+
),
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
key: 'hardware', label: 'Hardware', icon: 'bi-cpu', render: () => (
|
|
406
|
+
<>
|
|
407
|
+
<Eyebrow>Browser</Eyebrow>
|
|
408
|
+
<FlatRow label="Family">{info?.user_agent.family || DASH}</FlatRow>
|
|
409
|
+
<FlatRow label="Version">{browserVersion(info) || DASH}</FlatRow>
|
|
410
|
+
|
|
411
|
+
<Eyebrow>Operating system</Eyebrow>
|
|
412
|
+
<FlatRow label="Family">{info?.os.family || DASH}</FlatRow>
|
|
413
|
+
<FlatRow label="Version">{osVersion(info) || DASH}</FlatRow>
|
|
414
|
+
|
|
415
|
+
<Eyebrow>Hardware</Eyebrow>
|
|
416
|
+
<FlatRow label="Brand">{info?.device.brand || DASH}</FlatRow>
|
|
417
|
+
<FlatRow label="Family">{info?.device.family || DASH}</FlatRow>
|
|
418
|
+
<FlatRow label="Model">{info?.device.model || DASH}</FlatRow>
|
|
419
|
+
<FlatRow label="Summary">{deviceLabel(info)}</FlatRow>
|
|
420
|
+
|
|
421
|
+
<Eyebrow>Display & environment</Eyebrow>
|
|
422
|
+
{/* `parse_user_agent` emits os/device/user_agent/
|
|
423
|
+
string and nothing else — screen, locale and
|
|
424
|
+
timezone are never collected server-side. */}
|
|
425
|
+
<FlatRow label="Resolution">{NOT_COLLECTED}</FlatRow>
|
|
426
|
+
<FlatRow label="Locale">{NOT_COLLECTED}</FlatRow>
|
|
427
|
+
<FlatRow label="Timezone">{NOT_COLLECTED}</FlatRow>
|
|
428
|
+
|
|
429
|
+
<Eyebrow>Identification</Eyebrow>
|
|
430
|
+
<FlatRow label="Device ID (DUID)"><code>{device.duid}</code></FlatRow>
|
|
431
|
+
<FlatRow label="Managed ID (MUID)">{device.muid ? <code>{device.muid}</code> : <span className="dim-italic">— (pre-Bouncer device)</span>}</FlatRow>
|
|
432
|
+
<FlatRow label="User-agent hash">{device.user_agent_hash ? <code title={device.user_agent_hash}>{fmt.truncateMiddle(device.user_agent_hash, 20)}</code> : DASH}</FlatRow>
|
|
433
|
+
<FlatRow label="Last IP">
|
|
434
|
+
{device.last_ip
|
|
435
|
+
? (canGeoIp
|
|
436
|
+
? <button className="btn-link" onClick={() => showGeoIpDossierForAddress(device.last_ip!)}><code>{device.last_ip}</code></button>
|
|
437
|
+
: <code>{device.last_ip}</code>)
|
|
438
|
+
: DASH}
|
|
439
|
+
</FlatRow>
|
|
440
|
+
<FlatRow label="First seen"><code>{fmt.datetime(device.first_seen)}</code></FlatRow>
|
|
441
|
+
<FlatRow label="Last seen"><code>{fmt.datetime(device.last_seen)}</code></FlatRow>
|
|
442
|
+
|
|
443
|
+
{info?.string && (
|
|
444
|
+
<>
|
|
445
|
+
<Eyebrow>User agent</Eyebrow>
|
|
446
|
+
<pre className="ud-ua-block">{info.string}</pre>
|
|
447
|
+
</>
|
|
448
|
+
)}
|
|
449
|
+
</>
|
|
450
|
+
),
|
|
451
|
+
},
|
|
452
|
+
{ divider: 'Activity' },
|
|
453
|
+
{
|
|
454
|
+
key: 'locations', label: 'Locations', icon: 'bi-geo-alt',
|
|
455
|
+
render: () => <LocationsSection deviceId={id} canView={canLocations} />,
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
key: 'logins', label: 'Logins', icon: 'bi-box-arrow-in-right',
|
|
459
|
+
render: () => <LoginsSectionBody deviceId={id} canView={canLogins} />,
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
key: 'sessions', label: 'Sessions', icon: 'bi-clock-history',
|
|
463
|
+
render: () => <SessionsSection device={device} canBouncer={canBouncer} />,
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
key: 'shared', label: 'Shared', icon: 'bi-people',
|
|
467
|
+
render: () => <SharedSection device={device} onOpenUser={onOpenUser} />,
|
|
468
|
+
},
|
|
469
|
+
{ divider: 'Detail' },
|
|
470
|
+
{
|
|
471
|
+
key: 'metadata', label: 'Metadata', icon: 'bi-braces', render: () => (
|
|
472
|
+
<>
|
|
473
|
+
<Eyebrow>Metadata</Eyebrow>
|
|
474
|
+
{/* UserDevice has no `metadata` field — the source
|
|
475
|
+
read one that does not exist. The record is the
|
|
476
|
+
blob. */}
|
|
477
|
+
<KnownFieldsCard
|
|
478
|
+
data={device as unknown as Record<string, unknown>}
|
|
479
|
+
known={[
|
|
480
|
+
{ key: 'id', label: 'Record ID' },
|
|
481
|
+
{ key: 'duid', label: 'DUID' },
|
|
482
|
+
{ key: 'muid', label: 'MUID', hideEmpty: true },
|
|
483
|
+
{ key: 'user.display_name', label: 'Owner', hideEmpty: true },
|
|
484
|
+
{ key: 'first_seen', label: 'First seen', format: 'datetime' },
|
|
485
|
+
{ key: 'last_seen', label: 'Last seen', format: 'datetime' },
|
|
486
|
+
]}
|
|
487
|
+
rawLabel="Raw device record"
|
|
488
|
+
/>
|
|
489
|
+
</>
|
|
490
|
+
),
|
|
491
|
+
},
|
|
492
|
+
]}
|
|
493
|
+
initialSection="overview"
|
|
494
|
+
contextMenu={[
|
|
495
|
+
...(device.user && onOpenUser
|
|
496
|
+
? [{ label: 'View user', icon: 'bi-person', onSelect: () => onOpenUser(device.user!.id) }]
|
|
497
|
+
: []),
|
|
498
|
+
...(device.last_ip && canGeoIp
|
|
499
|
+
? [{ label: 'Look up last IP', icon: 'bi-globe2', onSelect: () => showGeoIpDossierForAddress(device.last_ip!) }]
|
|
500
|
+
: []),
|
|
501
|
+
{ divider: true },
|
|
502
|
+
{ label: 'Copy DUID', icon: 'bi-clipboard', onSelect: () => copy('DUID', device.duid) },
|
|
503
|
+
...(device.muid
|
|
504
|
+
? [{ label: 'Copy MUID', icon: 'bi-clipboard', onSelect: () => copy('MUID', device.muid!) }]
|
|
505
|
+
: []),
|
|
506
|
+
{
|
|
507
|
+
label: 'Refresh record', icon: 'bi-arrow-repeat',
|
|
508
|
+
onSelect: () => { void qc.invalidateQueries({ queryKey: UserDeviceModel.keys.one(device.id) }); },
|
|
509
|
+
},
|
|
510
|
+
// NO "Forget device" and NO trust toggle — UserDevice declares
|
|
511
|
+
// neither CAN_DELETE nor an `is_trusted` field.
|
|
512
|
+
]}
|
|
513
|
+
onClose={onClose}
|
|
514
|
+
/>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function showUserDeviceDetail(id: number, opts: { onOpenUser?: (userId: number) => void } = {}): void {
|
|
519
|
+
void modal.detail((close) => <UserDeviceDetail id={id} onClose={() => close(null)} {...opts} />);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Open a device by DUID. The `basic` UserDevice sub-graph embedded on
|
|
524
|
+
* location and login rows carries no `id`, so cross-links from those surfaces
|
|
525
|
+
* have to resolve through the list endpoint first.
|
|
526
|
+
*/
|
|
527
|
+
export function DeviceByDuid({ duid, onClose }: { duid: string; onClose: () => void }) {
|
|
528
|
+
const { data, isPending, error } = UserDeviceModel.useList({ duid, size: 1 });
|
|
529
|
+
const row = data?.rows?.[0];
|
|
530
|
+
if (isPending) return <div className="modal-pad dim">Resolving device…</div>;
|
|
531
|
+
if (error) return <div className="modal-pad text-bad">{error.message}</div>;
|
|
532
|
+
if (!row) {
|
|
533
|
+
return (
|
|
534
|
+
<div className="modal-pad">
|
|
535
|
+
<h2 className="modal-title">Device not found</h2>
|
|
536
|
+
<p className="dim">No device record matches DUID <code>{duid}</code>.</p>
|
|
537
|
+
<div className="modal-actions"><button className="btn" onClick={onClose}>Close</button></div>
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return <UserDeviceDetail id={row.id} onClose={onClose} />;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function showUserDeviceDetailByDuid(duid: string): void {
|
|
545
|
+
void modal.detail((close) => <DeviceByDuid duid={duid} onClose={() => close(null)} />);
|
|
546
|
+
}
|