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,132 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { mojoCall, mojoSave, type Group } from '../../../client';
|
|
3
|
+
import { formModal, modal, toast } from '../../../ui';
|
|
4
|
+
import { MemberModel, type MemberRow } from './models';
|
|
5
|
+
|
|
6
|
+
export interface AdmissionGroup extends Pick<Group, 'id' | 'name' | 'kind'> {}
|
|
7
|
+
|
|
8
|
+
/** Email-only, with no user-directory lookup or existence-dependent branch. */
|
|
9
|
+
export async function inviteMemberByEmail(groupId: number, email: string): Promise<MemberRow | null> {
|
|
10
|
+
const response = await mojoCall('/api/group/member/invite', {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
body: { group: groupId, email: email.trim() },
|
|
13
|
+
});
|
|
14
|
+
return (response.data ?? null) as MemberRow | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AdmissionMode = 'invite' | 'add';
|
|
18
|
+
|
|
19
|
+
function chooseAdmissionMode(group: AdmissionGroup, showInvite: boolean, showAdd: boolean): Promise<AdmissionMode | null> {
|
|
20
|
+
return modal.open<AdmissionMode>((close) => (
|
|
21
|
+
<div className="modal-pad">
|
|
22
|
+
<h2 className="modal-title">Add a member to {group.name}</h2>
|
|
23
|
+
<p className="modal-message">
|
|
24
|
+
Invite by email without checking whether an account exists, or select an existing user from the authorized directory.
|
|
25
|
+
</p>
|
|
26
|
+
<div className="detail-kpi-grid">
|
|
27
|
+
{showInvite && (
|
|
28
|
+
<button type="button" className="btn btn-primary" onClick={() => close('invite')}>
|
|
29
|
+
<i className="bi bi-envelope" /> Invite by email
|
|
30
|
+
</button>
|
|
31
|
+
)}
|
|
32
|
+
{showAdd && (
|
|
33
|
+
<button type="button" className="btn" onClick={() => close('add')}>
|
|
34
|
+
<i className="bi bi-person-plus" /> Add existing user
|
|
35
|
+
</button>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
<div className="modal-actions">
|
|
39
|
+
<button type="button" className="btn" onClick={() => close(null as unknown as AdmissionMode)}>Cancel</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
), { size: 'md' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function pickGroup(): Promise<AdmissionGroup | null> {
|
|
46
|
+
const data = await formModal({
|
|
47
|
+
title: 'Choose a group',
|
|
48
|
+
submitText: 'Continue',
|
|
49
|
+
fields: [{
|
|
50
|
+
name: 'group', type: 'collection', label: 'Group', required: true,
|
|
51
|
+
endpoint: '/api/group', labelField: 'name', valueField: 'id',
|
|
52
|
+
maxItems: 12, emptyFetch: true, debounceMs: 300,
|
|
53
|
+
placeholder: 'Search groups…',
|
|
54
|
+
}],
|
|
55
|
+
});
|
|
56
|
+
if (data?.group == null || data.group === '') return null;
|
|
57
|
+
const id = Number(data.group);
|
|
58
|
+
if (!Number.isFinite(id)) return null;
|
|
59
|
+
// The picker returns only an id. The label is presentation-only here and
|
|
60
|
+
// deliberately does not trigger a second directory request.
|
|
61
|
+
return { id, name: `Group #${id}`, kind: 'group' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OpenMemberAdmissionOptions {
|
|
65
|
+
group?: AdmissionGroup;
|
|
66
|
+
canInvite: boolean;
|
|
67
|
+
canCreate: boolean;
|
|
68
|
+
canReadUsers: boolean;
|
|
69
|
+
/** Fixed-group panels use separate buttons; headers/global pages omit it for the chooser. */
|
|
70
|
+
initialMode?: AdmissionMode;
|
|
71
|
+
queryClient?: QueryClient;
|
|
72
|
+
onCreated?: (member: MemberRow) => void | Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shared explicit Invite/Add chooser used by global Members and GroupDetail. */
|
|
76
|
+
export async function openMemberAdmissionDialog(options: OpenMemberAdmissionOptions): Promise<MemberRow | null> {
|
|
77
|
+
const group = options.group ?? await pickGroup();
|
|
78
|
+
if (!group) return null;
|
|
79
|
+
const canAddExisting = options.canCreate && options.canReadUsers;
|
|
80
|
+
if (!options.canInvite && !canAddExisting) return null;
|
|
81
|
+
|
|
82
|
+
const allowedInitial = options.initialMode === 'invite'
|
|
83
|
+
? options.canInvite
|
|
84
|
+
: options.initialMode === 'add'
|
|
85
|
+
? canAddExisting
|
|
86
|
+
: false;
|
|
87
|
+
const mode = allowedInitial
|
|
88
|
+
? options.initialMode!
|
|
89
|
+
: await chooseAdmissionMode(group, options.canInvite, canAddExisting);
|
|
90
|
+
if (!mode) return null;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
let member: MemberRow | null;
|
|
94
|
+
if (mode === 'invite') {
|
|
95
|
+
const data = await formModal({
|
|
96
|
+
title: `Invite a member to ${group.name}`,
|
|
97
|
+
submitText: 'Send invitation',
|
|
98
|
+
fields: [{
|
|
99
|
+
name: 'email', type: 'email', label: 'Email', required: true,
|
|
100
|
+
help: 'The same confirmation is shown whether this is a new, existing, or already-associated account.',
|
|
101
|
+
}],
|
|
102
|
+
});
|
|
103
|
+
if (!data?.email) return null;
|
|
104
|
+
member = await inviteMemberByEmail(group.id, String(data.email));
|
|
105
|
+
toast.success('Invitation request accepted');
|
|
106
|
+
} else {
|
|
107
|
+
const data = await formModal({
|
|
108
|
+
title: `Add an existing user to ${group.name}`,
|
|
109
|
+
submitText: 'Add to group',
|
|
110
|
+
fields: [{
|
|
111
|
+
name: 'user', type: 'collection', label: 'User', required: true,
|
|
112
|
+
endpoint: '/api/user', labelField: 'email', valueField: 'id',
|
|
113
|
+
maxItems: 10, emptyFetch: false, debounceMs: 300,
|
|
114
|
+
placeholder: 'Search users by email…',
|
|
115
|
+
help: 'This directory-backed option is available only to authorized user-directory viewers.',
|
|
116
|
+
}],
|
|
117
|
+
});
|
|
118
|
+
if (data?.user == null || data.user === '') return null;
|
|
119
|
+
member = await mojoSave<MemberRow>(MemberModel.endpoint, null, {
|
|
120
|
+
group: group.id,
|
|
121
|
+
user: Number(data.user),
|
|
122
|
+
});
|
|
123
|
+
toast.success('Existing user added to the group');
|
|
124
|
+
}
|
|
125
|
+
if (member) await options.onCreated?.(member);
|
|
126
|
+
if (options.queryClient) await MemberModel.invalidate(options.queryClient);
|
|
127
|
+
return member;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
toast.error(error instanceof Error ? error.message : 'Member admission failed');
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineModel,
|
|
3
|
+
type Field,
|
|
4
|
+
type Group,
|
|
5
|
+
type Params,
|
|
6
|
+
type User,
|
|
7
|
+
} from '../../../client';
|
|
8
|
+
|
|
9
|
+
/** Group-local read gate for the reusable fixed-group composition. */
|
|
10
|
+
export const MEMBER_GROUP_READ_PERMISSIONS = [
|
|
11
|
+
'view_members',
|
|
12
|
+
'view_groups',
|
|
13
|
+
'manage_groups',
|
|
14
|
+
'manage_group',
|
|
15
|
+
'groups',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Global Member Admin gates. `sys.` prevents active-group grants leaking in. */
|
|
19
|
+
export const MEMBER_READ_PERMISSIONS = [
|
|
20
|
+
'sys.view_members',
|
|
21
|
+
'sys.view_groups',
|
|
22
|
+
'sys.manage_groups',
|
|
23
|
+
'sys.manage_group',
|
|
24
|
+
'sys.groups',
|
|
25
|
+
];
|
|
26
|
+
export const MEMBER_SAVE_PERMISSIONS = [
|
|
27
|
+
'sys.manage_groups',
|
|
28
|
+
'sys.manage_group',
|
|
29
|
+
'sys.groups',
|
|
30
|
+
];
|
|
31
|
+
export const MEMBER_INVITE_PERMISSIONS = [
|
|
32
|
+
'sys.manage_users',
|
|
33
|
+
'sys.manage_members',
|
|
34
|
+
'sys.manage_group',
|
|
35
|
+
'sys.manage_groups',
|
|
36
|
+
];
|
|
37
|
+
export const MEMBER_USER_DIRECTORY_PERMISSIONS = [
|
|
38
|
+
'sys.view_users',
|
|
39
|
+
'sys.manage_users',
|
|
40
|
+
'sys.users',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export interface MemberUser extends User {
|
|
44
|
+
requires_mfa?: boolean;
|
|
45
|
+
has_passkey?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `/api/group/member` default graph. `metadata.role` is a display label only. */
|
|
49
|
+
export interface MemberRow {
|
|
50
|
+
id: number;
|
|
51
|
+
created: number;
|
|
52
|
+
modified: number;
|
|
53
|
+
is_active: boolean;
|
|
54
|
+
permissions: Record<string, unknown>;
|
|
55
|
+
metadata: Record<string, unknown> & {
|
|
56
|
+
role?: unknown;
|
|
57
|
+
invited_by?: unknown;
|
|
58
|
+
invited_by_name?: unknown;
|
|
59
|
+
};
|
|
60
|
+
user: MemberUser | null;
|
|
61
|
+
group: Group | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MEMBER_LIST_KEYS = new Set([
|
|
65
|
+
'start', 'size', 'search', 'sort',
|
|
66
|
+
'group', 'user', 'is_active',
|
|
67
|
+
'user__email__icontains', 'user__display_name__icontains',
|
|
68
|
+
'group__name__icontains', 'metadata__role__icontains',
|
|
69
|
+
'dr_field', 'dr_start', 'dr_end',
|
|
70
|
+
]);
|
|
71
|
+
const MEMBER_SORT_FIELDS = new Set(['id', 'created', 'modified', 'is_active']);
|
|
72
|
+
const MEMBER_DATE_FIELDS = new Set(['created', 'modified']);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* URL/persisted table state is untrusted. Keep only verified GroupMember
|
|
76
|
+
* lookups and ordering fields before it reaches either the query key or wire.
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeMemberListParams(params: Params): Params {
|
|
79
|
+
const safe: Params = {};
|
|
80
|
+
for (const [key, value] of Object.entries(params)) {
|
|
81
|
+
if (MEMBER_LIST_KEYS.has(key)) safe[key] = value;
|
|
82
|
+
}
|
|
83
|
+
if (typeof safe.sort === 'string') {
|
|
84
|
+
const field = safe.sort.replace(/^-/, '');
|
|
85
|
+
if (!MEMBER_SORT_FIELDS.has(field)) delete safe.sort;
|
|
86
|
+
}
|
|
87
|
+
if (typeof safe.dr_field !== 'string' || !MEMBER_DATE_FIELDS.has(safe.dr_field)) {
|
|
88
|
+
delete safe.dr_field;
|
|
89
|
+
delete safe.dr_start;
|
|
90
|
+
delete safe.dr_end;
|
|
91
|
+
}
|
|
92
|
+
return safe;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const MemberModel = defineModel<MemberRow>({
|
|
96
|
+
name: 'member',
|
|
97
|
+
endpoint: '/api/group/member',
|
|
98
|
+
permissions: {
|
|
99
|
+
view: MEMBER_READ_PERMISSIONS,
|
|
100
|
+
manage: MEMBER_SAVE_PERMISSIONS,
|
|
101
|
+
create: MEMBER_SAVE_PERMISSIONS,
|
|
102
|
+
},
|
|
103
|
+
normalizeListParams: normalizeMemberListParams,
|
|
104
|
+
actions: {
|
|
105
|
+
// GroupMember.on_action_resend_invite answers `{status:true}`.
|
|
106
|
+
resend_invite: { permissions: MEMBER_SAVE_PERMISSIONS, response: 'payload' },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export interface MemberPermissionDef {
|
|
111
|
+
name: string;
|
|
112
|
+
label: string;
|
|
113
|
+
tooltip?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const permissionRegistry = new Map<string, MemberPermissionDef>();
|
|
117
|
+
let permissionVersion = 0;
|
|
118
|
+
const permissionListeners = new Set<() => void>();
|
|
119
|
+
|
|
120
|
+
/** Keys that are derived or unsafe to offer as editable member grants. */
|
|
121
|
+
export function isEditableMemberPermission(name: string): boolean {
|
|
122
|
+
return Boolean(name)
|
|
123
|
+
&& !name.startsWith('sys.')
|
|
124
|
+
&& !['admin', 'member', 'full_member'].includes(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extend the editable catalog for product-specific group grants. Invalid
|
|
129
|
+
* system/derived/client-only names remain visible as raw read-only values but
|
|
130
|
+
* can never become switches.
|
|
131
|
+
*/
|
|
132
|
+
export function registerMemberPermissions(defs: MemberPermissionDef[]): void {
|
|
133
|
+
let changed = false;
|
|
134
|
+
for (const def of defs) {
|
|
135
|
+
if (!isEditableMemberPermission(def.name)) {
|
|
136
|
+
console.warn(`registerMemberPermissions: "${def.name}" is not an editable member grant — ignored`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
permissionRegistry.set(def.name, { ...def });
|
|
140
|
+
changed = true;
|
|
141
|
+
}
|
|
142
|
+
if (!changed) return;
|
|
143
|
+
permissionVersion += 1;
|
|
144
|
+
for (const listener of permissionListeners) listener();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getMemberPermissions(): MemberPermissionDef[] {
|
|
148
|
+
return [...permissionRegistry.values()].map((def) => ({ ...def }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function subscribeMemberPermissions(listener: () => void): () => void {
|
|
152
|
+
permissionListeners.add(listener);
|
|
153
|
+
return () => permissionListeners.delete(listener);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function memberPermissionsVersion(): number {
|
|
157
|
+
return permissionVersion;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
registerMemberPermissions([
|
|
161
|
+
{ name: 'manage_group', label: 'Manage Group' },
|
|
162
|
+
{ name: 'view_metrics', label: 'View Metrics' },
|
|
163
|
+
{ name: 'view_logs', label: 'View Logs' },
|
|
164
|
+
{ name: 'view_tickets', label: 'View Tickets' },
|
|
165
|
+
{ name: 'view_members', label: 'View Members' },
|
|
166
|
+
{ name: 'manage_members', label: 'Manage Members' },
|
|
167
|
+
{ name: 'view_billing', label: 'View Billing' },
|
|
168
|
+
{
|
|
169
|
+
name: 'guest',
|
|
170
|
+
label: 'Guest membership',
|
|
171
|
+
tooltip: 'Guests do not receive the derived full_member tier. Other grants remain independent.',
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
export function memberPermissionFields(): Field[] {
|
|
176
|
+
return getMemberPermissions().map((permission) => ({
|
|
177
|
+
name: `permissions.${permission.name}`,
|
|
178
|
+
type: 'switch',
|
|
179
|
+
label: permission.label,
|
|
180
|
+
columns: 6,
|
|
181
|
+
help: permission.tooltip,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function rawMemberGrants(permissions: Record<string, unknown> | null | undefined): string[] {
|
|
186
|
+
return Object.entries(permissions ?? {})
|
|
187
|
+
.filter(([, value]) => value === true || value === 1)
|
|
188
|
+
.map(([name]) => name)
|
|
189
|
+
.sort();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Server-effective membership grants, excluding ignored/client-only keys. */
|
|
193
|
+
export function effectiveMemberGrants(permissions: Record<string, unknown> | null | undefined): string[] {
|
|
194
|
+
const raw = rawMemberGrants(permissions);
|
|
195
|
+
const effective = raw.filter((name) => name !== 'admin' && name !== 'full_member' && !name.startsWith('sys.'));
|
|
196
|
+
effective.push('member');
|
|
197
|
+
if (!raw.includes('guest')) effective.push('full_member');
|
|
198
|
+
return [...new Set(effective)].sort();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function ignoredMemberGrants(permissions: Record<string, unknown> | null | undefined): string[] {
|
|
202
|
+
return rawMemberGrants(permissions).filter((name) =>
|
|
203
|
+
name === 'admin' || name === 'full_member' || name.startsWith('sys.'));
|
|
204
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// UserDetail — the FULL UserView.js port (web-mojo admin/account/users/
|
|
2
|
+
// UserView.js, 2511 lines + its five admin sections, ALL read in full
|
|
3
|
+
// 2026-08-05): DetailView modal with the complete section map
|
|
4
|
+
//
|
|
5
|
+
// Identity: Overview · Profile · Personal · Security · OAuth
|
|
6
|
+
// Access: Groups · Sys Perms · App Perms(when registered) · API Keys
|
|
7
|
+
// Activity: Devices · Logins · Audit
|
|
8
|
+
// Settings: Notifications · Metadata
|
|
9
|
+
//
|
|
10
|
+
// plus header parity (presence, reason-keyed status badge, throttle chip,
|
|
11
|
+
// inactivity warning, verified/2FA/role chips, active toggle → disable-flow
|
|
12
|
+
// modal) and the full kebab. Sections live in ./sections/* — one file
|
|
13
|
+
// per section; ./sections/actions.tsx holds the canonical handler per
|
|
14
|
+
// action (kebab + Profile cards + Security rows share them, the source's
|
|
15
|
+
// Phase 4 dedup).
|
|
16
|
+
//
|
|
17
|
+
// Header deviations forced by DetailView's prop surface (read-only this
|
|
18
|
+
// wave — MERGE-WIRE notes filed): chips are not clickable, so the org chip's
|
|
19
|
+
// click-through lives in the kebab ("Open organization") and as an Overview
|
|
20
|
+
// row link; the avatar slot renders initials (no image/click slot), so the
|
|
21
|
+
// shared ImageField avatar manager hangs off the kebab;
|
|
22
|
+
// presence/status/locked/warning render as chips instead of a two-row aux.
|
|
23
|
+
import { useSyncExternalStore } from 'react';
|
|
24
|
+
import { useCan, useMe } from '../../../client';
|
|
25
|
+
import {
|
|
26
|
+
DetailView, getFormTabs, subscribeFormTabs, fmt, modal,
|
|
27
|
+
type Chip, type DetailMenuEntry,
|
|
28
|
+
} from '../../../ui';
|
|
29
|
+
import {
|
|
30
|
+
USER_DEVICE_PERMISSIONS, USER_EVENT_PERMISSIONS, USER_LOGIN_PERMISSIONS,
|
|
31
|
+
USER_LOG_PERMISSIONS, USER_MANAGE_PERMISSIONS, USER_PUSH_DEVICE_PERMISSIONS,
|
|
32
|
+
UserModel, type UserRow,
|
|
33
|
+
} from './models';
|
|
34
|
+
import { MEMBER_READ_PERMISSIONS } from '../members';
|
|
35
|
+
import { useUserAdminActions } from './sections/actions';
|
|
36
|
+
import { useSharedUserQueries } from './sections/queries';
|
|
37
|
+
import {
|
|
38
|
+
accountType, inactivityWarning, isAnonymized, isOnline, statusBadge, useAdminCaller,
|
|
39
|
+
} from './sections/shared';
|
|
40
|
+
import { USER_APP_PERMS_TABSET } from './sections/permission-catalog';
|
|
41
|
+
import { OverviewSection } from './sections/OverviewSection';
|
|
42
|
+
import { ProfileSection } from './sections/ProfileSection';
|
|
43
|
+
import { PersonalSection } from './sections/PersonalSection';
|
|
44
|
+
import { SecuritySection } from './sections/SecuritySection';
|
|
45
|
+
import { OAuthSection } from './sections/OAuthSection';
|
|
46
|
+
import { GroupsSection } from './sections/GroupsSection';
|
|
47
|
+
import { AppPermsSection, SysPermsSection } from './sections/PermissionsSection';
|
|
48
|
+
import { ApiKeysSection } from './sections/ApiKeysSection';
|
|
49
|
+
import { DevicesSection } from './sections/DevicesSection';
|
|
50
|
+
import { LoginsSection } from './sections/LoginsSection';
|
|
51
|
+
import { AuditSection } from './sections/AuditSection';
|
|
52
|
+
import { NotificationsSection } from './sections/NotificationsSection';
|
|
53
|
+
import { UserMetadataSection } from './sections/MetadataPanel';
|
|
54
|
+
|
|
55
|
+
export interface UserDetailProps {
|
|
56
|
+
id: number;
|
|
57
|
+
onClose: () => void;
|
|
58
|
+
onOpenGroup?: (groupId: number) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function UserDetail({ id, onClose, onOpenGroup }: UserDetailProps) {
|
|
62
|
+
const { data: user, isPending } = UserModel.useOne(id);
|
|
63
|
+
const isAdmin = useAdminCaller();
|
|
64
|
+
// App Perms renders only when an app registered tabs — the portal's
|
|
65
|
+
// `User.APP_PERMISSION_FIELDS.length > 0` gate, live via the registry
|
|
66
|
+
// subscription (late registration adds the section without a remount).
|
|
67
|
+
const appPermsCount = useSyncExternalStore(
|
|
68
|
+
subscribeFormTabs,
|
|
69
|
+
() => getFormTabs(USER_APP_PERMS_TABSET).length,
|
|
70
|
+
() => 0,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (isPending || !user) {
|
|
74
|
+
return <div className="detail-loading"><span className="skel skel-block" /></div>;
|
|
75
|
+
}
|
|
76
|
+
return <UserDetailLoaded user={user} isAdmin={isAdmin} hasAppPerms={appPermsCount > 0} onClose={onClose} onOpenGroup={onOpenGroup} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }: {
|
|
80
|
+
user: UserRow;
|
|
81
|
+
isAdmin: boolean;
|
|
82
|
+
hasAppPerms: boolean;
|
|
83
|
+
onClose: () => void;
|
|
84
|
+
onOpenGroup?: (groupId: number) => void;
|
|
85
|
+
}) {
|
|
86
|
+
const { data: me } = useMe();
|
|
87
|
+
const isSelf = me?.id === user.id;
|
|
88
|
+
const canMembers = useCan(MEMBER_READ_PERMISSIONS).can;
|
|
89
|
+
const canDevices = useCan(USER_DEVICE_PERMISSIONS).can;
|
|
90
|
+
const canPushDevices = useCan(USER_PUSH_DEVICE_PERMISSIONS).can;
|
|
91
|
+
const canLogins = useCan(USER_LOGIN_PERMISSIONS).can;
|
|
92
|
+
const canLogs = useCan(USER_LOG_PERMISSIONS).can;
|
|
93
|
+
const canEvents = useCan(USER_EVENT_PERMISSIONS).can;
|
|
94
|
+
const canCredentials = isSelf || isAdmin;
|
|
95
|
+
const shared = useSharedUserQueries(user.id, {
|
|
96
|
+
devices: canDevices,
|
|
97
|
+
pushDevices: canPushDevices,
|
|
98
|
+
members: canMembers,
|
|
99
|
+
logins: canLogins,
|
|
100
|
+
events: canEvents,
|
|
101
|
+
logs: canLogs,
|
|
102
|
+
apiKeys: canCredentials,
|
|
103
|
+
throttle: isAdmin,
|
|
104
|
+
});
|
|
105
|
+
const throttleRetry = Math.max(0, Math.floor(Number(shared.throttle.data?.retry_after_seconds ?? 0))) || 0;
|
|
106
|
+
const actions = useUserAdminActions(user, {
|
|
107
|
+
throttleRetry,
|
|
108
|
+
refetchThrottle: () => void shared.throttle.refetch(),
|
|
109
|
+
onOpenGroup,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── Header chips ──────────────────────────────────────────────────
|
|
113
|
+
const online = isOnline(user);
|
|
114
|
+
const lastSeen = user.last_activity ?? user.last_login;
|
|
115
|
+
const status = statusBadge(user);
|
|
116
|
+
const warning = inactivityWarning(user);
|
|
117
|
+
const org = typeof user.org === 'object' && user.org ? user.org : null;
|
|
118
|
+
const chips: Chip[] = [
|
|
119
|
+
// Presence — the header-aux dot as a chip (DetailView has no aux slot).
|
|
120
|
+
{
|
|
121
|
+
icon: 'bi-circle-fill',
|
|
122
|
+
text: online ? 'Online' : lastSeen ? `Offline · last active ${fmt.relative(lastSeen)}` : 'No activity',
|
|
123
|
+
tone: online ? 'success' : 'muted',
|
|
124
|
+
},
|
|
125
|
+
...(user.is_superuser ? [{ text: 'Superuser', tone: 'info' as const }]
|
|
126
|
+
: accountType(user) === 'Staff' ? [{ text: 'Staff', tone: 'info' as const }] : []),
|
|
127
|
+
...(user.is_email_verified ? [{ icon: 'bi-shield-check', text: 'Email verified', tone: 'success' as const }] : []),
|
|
128
|
+
...(user.is_phone_verified && user.phone_number ? [{ icon: 'bi-shield-check', text: 'Phone verified', tone: 'success' as const }] : []),
|
|
129
|
+
...(user.requires_mfa ? [{ text: '2FA enabled', tone: 'primary' as const }] : []),
|
|
130
|
+
// Org chip. Chips can't click (MERGE-WIRE) — the open lives in the
|
|
131
|
+
// kebab + the Overview row link.
|
|
132
|
+
...(org ? [{ icon: 'bi-buildings', text: org.name, tone: 'muted' as const }] : []),
|
|
133
|
+
// Reason-keyed status badge (Blocked / Banned / Auto-disabled /
|
|
134
|
+
// Anonymized / Self-deactivated) — only while disabled.
|
|
135
|
+
...(status ? [{ text: status.label, tone: status.tone }] : []),
|
|
136
|
+
// Login-throttle badge — independent of is_active; admin-only fetch,
|
|
137
|
+
// absent while the GET hasn't settled (non-fatal).
|
|
138
|
+
...(throttleRetry > 0 ? [{ icon: 'bi-clock-history', text: `Login locked ${throttleRetry}s`, tone: 'danger' as const }] : []),
|
|
139
|
+
// Inactivity-warning surface is read-only until the backend exposes a
|
|
140
|
+
// supported clear action for active users.
|
|
141
|
+
...(warning ? [{ icon: 'bi-exclamation-triangle', text: `Inactivity warning — ${warning.days ?? '?'}d to auto-disable`, tone: 'warning' as const }] : []),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// ── Kebab (UserView contextItems at full scope) ───────────────────
|
|
145
|
+
const ADMIN = USER_MANAGE_PERMISSIONS;
|
|
146
|
+
const MENU: DetailMenuEntry<UserRow>[] = [
|
|
147
|
+
{ label: 'Edit user', icon: 'bi-pencil', permissions: ADMIN, onSelect: () => void actions.editUser() },
|
|
148
|
+
{
|
|
149
|
+
label: 'Manage avatar…', icon: 'bi-image', permissions: ADMIN,
|
|
150
|
+
onSelect: () => actions.openAvatarModal(),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
label: 'Open organization', icon: 'bi-buildings',
|
|
154
|
+
when: (u) => Boolean(onOpenGroup && (typeof u?.org === 'object' ? u.org?.id : u?.org)),
|
|
155
|
+
onSelect: () => void actions.openOrg(),
|
|
156
|
+
},
|
|
157
|
+
{ divider: true },
|
|
158
|
+
{ label: 'Change password', icon: 'bi-key', permissions: ADMIN, onSelect: () => void actions.changePassword() },
|
|
159
|
+
{ label: 'Send password reset', icon: 'bi-envelope', permissions: ADMIN, onSelect: () => void actions.sendPasswordReset() },
|
|
160
|
+
{ label: 'Send magic login link', icon: 'bi-link-45deg', permissions: ADMIN, onSelect: () => void actions.sendMagicLink() },
|
|
161
|
+
{
|
|
162
|
+
label: 'Resend invite', icon: 'bi-envelope-plus', permissions: ADMIN,
|
|
163
|
+
when: (u) => Boolean(u?.email) && u?.last_login == null,
|
|
164
|
+
onSelect: () => void actions.resendInvite(),
|
|
165
|
+
},
|
|
166
|
+
{ divider: true },
|
|
167
|
+
{
|
|
168
|
+
label: 'Reset MFA', icon: 'bi-shield-x', permissions: ADMIN,
|
|
169
|
+
when: (u) => u?.requires_mfa === true,
|
|
170
|
+
onSelect: () => void actions.resetMfa(),
|
|
171
|
+
},
|
|
172
|
+
{ label: 'Clear rate limit', icon: 'bi-shield-slash', permissions: ADMIN, onSelect: () => void actions.clearRateLimit() },
|
|
173
|
+
{
|
|
174
|
+
label: 'Revoke all sessions', icon: 'bi-box-arrow-right', danger: true, permissions: ADMIN,
|
|
175
|
+
onSelect: () => {
|
|
176
|
+
// Kebab entry confirms; the Security row uses ArmedButton.
|
|
177
|
+
void modal.confirm({
|
|
178
|
+
title: 'Revoke all sessions',
|
|
179
|
+
message: 'Revoke all sessions? The user will be signed out of all devices immediately.',
|
|
180
|
+
confirmText: 'Revoke',
|
|
181
|
+
danger: true,
|
|
182
|
+
}).then((ok) => { if (ok) void actions.revokeAllSessions(); });
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
// ── Rail badges (source setBadge set: ApiKeys/Groups/Devices/Audit) ──
|
|
188
|
+
const deviceTotal = (shared.devices.data?.count ?? 0) + (shared.pushDevices.data?.count ?? 0);
|
|
189
|
+
const auditTotal = (shared.events.data?.count ?? 0) + (shared.activity.data?.count ?? 0) + (shared.objectLogs.data?.count ?? 0);
|
|
190
|
+
const badges: Record<string, number | null> = {
|
|
191
|
+
ApiKeys: (shared.apiKeys.data?.count ?? 0) > 0 ? shared.apiKeys.data!.count : null,
|
|
192
|
+
Groups: (shared.members.data?.count ?? 0) > 0 ? shared.members.data!.count : null,
|
|
193
|
+
Devices: deviceTotal > 0 ? deviceTotal : null,
|
|
194
|
+
Audit: auditTotal > 0 ? auditTotal : null,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Active toggle — hidden for anonymized users (irreversible) AND for
|
|
198
|
+
// non-admin callers. Off → disable flow (reason + note modal; cancel
|
|
199
|
+
// leaves the switch on); on → reactivate, no prompt.
|
|
200
|
+
const activeToggle = !isAnonymized(user) && isAdmin
|
|
201
|
+
? { value: user.is_active, onChange: (next: boolean) => { void (next ? actions.reactivateUser() : actions.disableUser()); } }
|
|
202
|
+
: undefined;
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<DetailView<UserRow>
|
|
206
|
+
avatarName={user.display_name || user.username}
|
|
207
|
+
title={user.display_name || user.username || user.email || `User #${user.id}`}
|
|
208
|
+
subtitle={[user.email, user.phone_number].filter(Boolean).join(' · ')}
|
|
209
|
+
chips={chips}
|
|
210
|
+
active={activeToggle}
|
|
211
|
+
contextMenu={MENU}
|
|
212
|
+
menuContext={user}
|
|
213
|
+
badges={badges}
|
|
214
|
+
onClose={onClose}
|
|
215
|
+
sections={[
|
|
216
|
+
{ key: 'Overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <OverviewSection user={user} shared={shared} onOpenGroup={onOpenGroup} /> },
|
|
217
|
+
{ key: 'Profile', label: 'Profile', icon: 'bi-person', render: () => <ProfileSection user={user} actions={actions} canManage={isAdmin} canCredentials={canCredentials} /> },
|
|
218
|
+
{ key: 'Personal', label: 'Personal', icon: 'bi-card-text', permissions: ADMIN, render: () => <PersonalSection user={user} /> },
|
|
219
|
+
{ key: 'Security', label: 'Security', icon: 'bi-shield-lock', render: () => <SecuritySection user={user} actions={actions} canManage={isAdmin} canPasskeys={canCredentials} /> },
|
|
220
|
+
...(canCredentials ? [{ key: 'OAuth', label: 'OAuth', icon: 'bi-link-45deg', render: () => <OAuthSection user={user} canManage={canCredentials} /> }] : []),
|
|
221
|
+
{ divider: 'Access' },
|
|
222
|
+
...(canMembers ? [{ key: 'Groups', label: 'Groups', icon: 'bi-people', render: () => <GroupsSection user={user} onOpenGroup={onOpenGroup} /> }] : []),
|
|
223
|
+
{
|
|
224
|
+
key: 'SysPerms', label: 'Sys Perms', icon: 'bi-shield-check',
|
|
225
|
+
permissions: ADMIN,
|
|
226
|
+
render: () => <SysPermsSection user={user} />,
|
|
227
|
+
},
|
|
228
|
+
...(hasAppPerms ? [{
|
|
229
|
+
key: 'AppPerms', label: 'App Perms', icon: 'bi-puzzle',
|
|
230
|
+
permissions: ADMIN,
|
|
231
|
+
render: () => <AppPermsSection user={user} />,
|
|
232
|
+
}] : []),
|
|
233
|
+
...(canCredentials ? [{ key: 'ApiKeys', label: 'API Keys', icon: 'bi-key', render: () => <ApiKeysSection user={user} canManage={canCredentials} isSelf={isSelf} /> }] : []),
|
|
234
|
+
{ divider: 'Activity' },
|
|
235
|
+
...((canDevices || canPushDevices) ? [{
|
|
236
|
+
key: 'Devices', label: 'Devices', icon: 'bi-laptop',
|
|
237
|
+
render: () => (
|
|
238
|
+
<DevicesSection
|
|
239
|
+
user={user}
|
|
240
|
+
counts={{ browser: shared.devices.data?.count ?? 0, push: shared.pushDevices.data?.count ?? 0 }}
|
|
241
|
+
canBrowser={canDevices}
|
|
242
|
+
canPush={canPushDevices}
|
|
243
|
+
/>
|
|
244
|
+
),
|
|
245
|
+
}] : []),
|
|
246
|
+
...(canLogins ? [{ key: 'Logins', label: 'Logins', icon: 'bi-geo-alt', render: () => <LoginsSection user={user} /> }] : []),
|
|
247
|
+
...((canLogs || canEvents) ? [{
|
|
248
|
+
key: 'Audit', label: 'Audit', icon: 'bi-clock-history',
|
|
249
|
+
render: () => <AuditSection user={user} canLogs={canLogs} canEvents={canEvents} />,
|
|
250
|
+
}] : []),
|
|
251
|
+
{ divider: 'Settings' },
|
|
252
|
+
{ key: 'Notifications', label: 'Notifications', icon: 'bi-bell', render: () => <NotificationsSection user={user} /> },
|
|
253
|
+
{ key: 'Metadata', label: 'Metadata', icon: 'bi-braces', render: () => <UserMetadataSection user={user} canManage={isAdmin} /> },
|
|
254
|
+
]}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
257
|
+
}
|