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,302 @@
|
|
|
1
|
+
// Password strength + generation — ported from web-mojo MOJOUtils
|
|
2
|
+
// (src/core/utils/MOJOUtils.js: checkPasswordStrength, generatePassword).
|
|
3
|
+
// The REST of MOJOUtils stays unported (sweep verdict: bulk fails the value
|
|
4
|
+
// bar); these two earn their port as the C3 reset-form pieces.
|
|
5
|
+
//
|
|
6
|
+
// Porting notes:
|
|
7
|
+
// · checkPasswordStrength keeps the EXACT scoring semantics — length
|
|
8
|
+
// bands, +1/+1/+1/+2 variety, the -1 common-pattern penalty (applied
|
|
9
|
+
// once), the -3 common-password floor at 0, the same strength
|
|
10
|
+
// thresholds and the same feedback strings. The returned score is
|
|
11
|
+
// floored at 0 (source parity) but the strength class is derived from
|
|
12
|
+
// the raw (possibly negative) running score, exactly like the source.
|
|
13
|
+
// · generatePassword keeps the option surface and guarantees (defaults,
|
|
14
|
+
// charsets, excludeAmbiguous strips, customChars override that SKIPS
|
|
15
|
+
// the per-class required chars, min-length-4 throw, empty-pool throw,
|
|
16
|
+
// one guaranteed char per included class, final shuffle) — but draws
|
|
17
|
+
// randomness from crypto.getRandomValues. web-mojo used Math.random and
|
|
18
|
+
// a `sort(() => Math.random() - 0.5)` shuffle — a non-crypto RNG plus a
|
|
19
|
+
// biased shuffle is a trap deliberately NOT carried into a password
|
|
20
|
+
// generator (Fisher–Yates over rejection-sampled uniform draws here).
|
|
21
|
+
|
|
22
|
+
// ── checkPasswordStrength ─────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type PasswordStrengthClass = 'invalid' | 'very-weak' | 'weak' | 'fair' | 'good' | 'strong';
|
|
25
|
+
|
|
26
|
+
export interface PasswordStrengthDetails {
|
|
27
|
+
length: number;
|
|
28
|
+
hasLowercase: boolean;
|
|
29
|
+
hasUppercase: boolean;
|
|
30
|
+
hasNumbers: boolean;
|
|
31
|
+
hasSpecialChars: boolean;
|
|
32
|
+
hasCommonPatterns: boolean;
|
|
33
|
+
isCommonPassword: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PasswordStrength {
|
|
37
|
+
/** Final score, floored at 0. Practical range 0–9. */
|
|
38
|
+
score: number;
|
|
39
|
+
strength: PasswordStrengthClass;
|
|
40
|
+
feedback: string[];
|
|
41
|
+
details: PasswordStrengthDetails;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COMMON_PATTERNS: RegExp[] = [
|
|
45
|
+
/123/, // sequential numbers
|
|
46
|
+
/abc/i, // sequential letters
|
|
47
|
+
/qwerty/i, // keyboard patterns
|
|
48
|
+
/asdf/i, // keyboard patterns
|
|
49
|
+
/(.)\1{2,}/, // repeated characters (aaa, 111)
|
|
50
|
+
/password/i,
|
|
51
|
+
/admin/i,
|
|
52
|
+
/user/i,
|
|
53
|
+
/login/i,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const COMMON_PASSWORDS = [
|
|
57
|
+
'123456', 'password', '123456789', '12345678', '12345',
|
|
58
|
+
'1234567', '1234567890', 'qwerty', 'abc123', '111111',
|
|
59
|
+
'123123', 'admin', 'letmein', 'welcome', 'monkey',
|
|
60
|
+
'password123', '123qwe', 'qwerty123', '000000', 'dragon',
|
|
61
|
+
'sunshine', 'princess', 'azerty', '1234', 'iloveyou',
|
|
62
|
+
'trustno1', 'superman', 'shadow', 'master', 'jennifer',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Score a password. Exact web-mojo semantics — see the header note. */
|
|
66
|
+
export function checkPasswordStrength(password: string): PasswordStrength {
|
|
67
|
+
if (!password || typeof password !== 'string') {
|
|
68
|
+
return {
|
|
69
|
+
score: 0,
|
|
70
|
+
strength: 'invalid',
|
|
71
|
+
feedback: ['Password must be a non-empty string'],
|
|
72
|
+
details: {
|
|
73
|
+
length: 0,
|
|
74
|
+
hasLowercase: false,
|
|
75
|
+
hasUppercase: false,
|
|
76
|
+
hasNumbers: false,
|
|
77
|
+
hasSpecialChars: false,
|
|
78
|
+
hasCommonPatterns: false,
|
|
79
|
+
isCommonPassword: false,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const feedback: string[] = [];
|
|
85
|
+
const details: PasswordStrengthDetails = {
|
|
86
|
+
length: password.length,
|
|
87
|
+
hasLowercase: /[a-z]/.test(password),
|
|
88
|
+
hasUppercase: /[A-Z]/.test(password),
|
|
89
|
+
hasNumbers: /[0-9]/.test(password),
|
|
90
|
+
hasSpecialChars: /[^a-zA-Z0-9]/.test(password),
|
|
91
|
+
hasCommonPatterns: false,
|
|
92
|
+
isCommonPassword: false,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
let score = 0;
|
|
96
|
+
|
|
97
|
+
// Length scoring
|
|
98
|
+
if (password.length < 6) {
|
|
99
|
+
feedback.push('Password should be at least 6 characters long');
|
|
100
|
+
} else if (password.length < 8) {
|
|
101
|
+
score += 1;
|
|
102
|
+
feedback.push('Consider using at least 8 characters for better security');
|
|
103
|
+
} else if (password.length < 12) {
|
|
104
|
+
score += 3;
|
|
105
|
+
} else {
|
|
106
|
+
score += 4;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Character variety scoring
|
|
110
|
+
if (details.hasLowercase) score += 1;
|
|
111
|
+
else feedback.push('Include lowercase letters');
|
|
112
|
+
|
|
113
|
+
if (details.hasUppercase) score += 1;
|
|
114
|
+
else feedback.push('Include uppercase letters');
|
|
115
|
+
|
|
116
|
+
if (details.hasNumbers) score += 1;
|
|
117
|
+
else feedback.push('Include numbers');
|
|
118
|
+
|
|
119
|
+
if (details.hasSpecialChars) score += 2;
|
|
120
|
+
else feedback.push('Include special characters (!@#$%^&* etc.)');
|
|
121
|
+
|
|
122
|
+
// Common patterns: at most ONE -1, first match breaks (source parity).
|
|
123
|
+
for (const pattern of COMMON_PATTERNS) {
|
|
124
|
+
if (pattern.test(password)) {
|
|
125
|
+
details.hasCommonPatterns = true;
|
|
126
|
+
score -= 1;
|
|
127
|
+
feedback.push('Avoid common patterns and dictionary words');
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (COMMON_PASSWORDS.includes(password.toLowerCase())) {
|
|
133
|
+
details.isCommonPassword = true;
|
|
134
|
+
score = Math.max(0, score - 3);
|
|
135
|
+
feedback.push('This password is too common and easily guessed');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Strength class from the RAW running score (can sit below 0 here).
|
|
139
|
+
let strength: PasswordStrengthClass;
|
|
140
|
+
if (score < 2) strength = 'very-weak';
|
|
141
|
+
else if (score < 4) strength = 'weak';
|
|
142
|
+
else if (score < 6) strength = 'fair';
|
|
143
|
+
else if (score < 8) strength = 'good';
|
|
144
|
+
else strength = 'strong';
|
|
145
|
+
|
|
146
|
+
// Positive feedback for strong passwords (source-exact conditions).
|
|
147
|
+
if (score >= 7 && feedback.length === 0) {
|
|
148
|
+
feedback.push('Strong password! Consider using a password manager.');
|
|
149
|
+
} else if (score >= 5 && feedback.length <= 1) {
|
|
150
|
+
feedback.push('Good password strength. Consider adding more variety.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { score: Math.max(0, score), strength, feedback, details };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── generatePassword ──────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export interface GeneratePasswordOptions {
|
|
159
|
+
/** Password length (default 12; minimum 4 — below throws). */
|
|
160
|
+
length?: number;
|
|
161
|
+
includeLowercase?: boolean;
|
|
162
|
+
includeUppercase?: boolean;
|
|
163
|
+
includeNumbers?: boolean;
|
|
164
|
+
includeSpecialChars?: boolean;
|
|
165
|
+
/** Custom character pool; when set, the include* flags and the
|
|
166
|
+
* one-per-class guarantee are skipped (source parity). */
|
|
167
|
+
customChars?: string;
|
|
168
|
+
/** Strip look-alikes: i/l, I/O/L, 0/1, | (source's exact strip sets). */
|
|
169
|
+
excludeAmbiguous?: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Uniform integer in [0, max) via rejection sampling over crypto RNG. */
|
|
173
|
+
function randomIndex(max: number): number {
|
|
174
|
+
if (max <= 0) throw new Error('randomIndex: empty range');
|
|
175
|
+
const limit = Math.floor(0x100000000 / max) * max; // largest multiple of max
|
|
176
|
+
const buf = new Uint32Array(1);
|
|
177
|
+
for (;;) {
|
|
178
|
+
crypto.getRandomValues(buf);
|
|
179
|
+
const v = buf[0]!;
|
|
180
|
+
if (v < limit) return v % max;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function pick(pool: string): string {
|
|
185
|
+
return pool[randomIndex(pool.length)]!;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Generate a password. Option semantics are web-mojo-exact (see header);
|
|
190
|
+
* randomness is crypto-grade (the one documented deviation).
|
|
191
|
+
*/
|
|
192
|
+
export function generatePassword(options: GeneratePasswordOptions = {}): string {
|
|
193
|
+
const config = {
|
|
194
|
+
length: 12,
|
|
195
|
+
includeLowercase: true,
|
|
196
|
+
includeUppercase: true,
|
|
197
|
+
includeNumbers: true,
|
|
198
|
+
includeSpecialChars: true,
|
|
199
|
+
customChars: '',
|
|
200
|
+
excludeAmbiguous: false,
|
|
201
|
+
...options,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (config.length < 4) {
|
|
205
|
+
throw new Error('Password length must be at least 4 characters');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
|
209
|
+
let uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
210
|
+
let numbers = '0123456789';
|
|
211
|
+
let specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
|
212
|
+
|
|
213
|
+
if (config.excludeAmbiguous) {
|
|
214
|
+
lowercase = lowercase.replace(/[il]/g, '');
|
|
215
|
+
uppercase = uppercase.replace(/[IOL]/g, '');
|
|
216
|
+
numbers = numbers.replace(/[01]/g, '');
|
|
217
|
+
specialChars = specialChars.replace(/[|]/g, '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let charPool = '';
|
|
221
|
+
const requiredChars: string[] = [];
|
|
222
|
+
|
|
223
|
+
if (config.customChars) {
|
|
224
|
+
charPool = config.customChars;
|
|
225
|
+
} else {
|
|
226
|
+
if (config.includeLowercase) { charPool += lowercase; requiredChars.push(pick(lowercase)); }
|
|
227
|
+
if (config.includeUppercase) { charPool += uppercase; requiredChars.push(pick(uppercase)); }
|
|
228
|
+
if (config.includeNumbers) { charPool += numbers; requiredChars.push(pick(numbers)); }
|
|
229
|
+
if (config.includeSpecialChars) { charPool += specialChars; requiredChars.push(pick(specialChars)); }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!charPool) {
|
|
233
|
+
throw new Error('No character types selected for password generation');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const chars: string[] = [...requiredChars];
|
|
237
|
+
while (chars.length < config.length) chars.push(pick(charPool));
|
|
238
|
+
|
|
239
|
+
// Fisher–Yates (web-mojo's sort(() => Math.random() - 0.5) is biased).
|
|
240
|
+
for (let i = chars.length - 1; i > 0; i--) {
|
|
241
|
+
const j = randomIndex(i + 1);
|
|
242
|
+
[chars[i], chars[j]] = [chars[j]!, chars[i]!];
|
|
243
|
+
}
|
|
244
|
+
return chars.join('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── PasswordStrengthMeter ─────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
const STRENGTH_LABEL: Record<PasswordStrengthClass, string> = {
|
|
250
|
+
invalid: 'Enter a password',
|
|
251
|
+
'very-weak': 'Very weak',
|
|
252
|
+
weak: 'Weak',
|
|
253
|
+
fair: 'Fair',
|
|
254
|
+
good: 'Good',
|
|
255
|
+
strong: 'Strong',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const STRENGTH_SEGMENTS: Record<PasswordStrengthClass, number> = {
|
|
259
|
+
invalid: 0, 'very-weak': 1, weak: 2, fair: 3, good: 4, strong: 5,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export interface PasswordStrengthMeterProps {
|
|
263
|
+
password: string;
|
|
264
|
+
/** Show the scoring feedback lines under the bar. Default true. */
|
|
265
|
+
showFeedback?: boolean;
|
|
266
|
+
className?: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Live strength meter: 5-segment bar + label + feedback, colored by tokens
|
|
271
|
+
* (very-weak/weak → --bad, fair → --warn, good → --info, strong → --ok) via
|
|
272
|
+
* the `data-strength` attribute — both themes for free. Scoring is exactly
|
|
273
|
+
* `checkPasswordStrength`; the ONE presentation nicety is that an empty
|
|
274
|
+
* password renders as an unlit idle state instead of the scorer's
|
|
275
|
+
* "must be a non-empty string" complaint.
|
|
276
|
+
*/
|
|
277
|
+
export function PasswordStrengthMeter({ password, showFeedback = true, className }: PasswordStrengthMeterProps) {
|
|
278
|
+
const result = checkPasswordStrength(password);
|
|
279
|
+
const empty = password.length === 0;
|
|
280
|
+
const strength: PasswordStrengthClass = empty ? 'invalid' : result.strength;
|
|
281
|
+
const lit = STRENGTH_SEGMENTS[strength];
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div className={`pw-meter${className ? ` ${className}` : ''}`} data-strength={strength}>
|
|
285
|
+
<div className="pw-meter-bars" role="meter" aria-label="Password strength"
|
|
286
|
+
aria-valuemin={0} aria-valuemax={5} aria-valuenow={lit} aria-valuetext={STRENGTH_LABEL[strength]}>
|
|
287
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
288
|
+
<span key={n} className={`pw-meter-seg${n <= lit ? ' pw-meter-lit' : ''}`} />
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
<div className="pw-meter-row">
|
|
292
|
+
<span className="pw-meter-label">{STRENGTH_LABEL[strength]}</span>
|
|
293
|
+
{!empty && <span className="pw-meter-score">{result.score}</span>}
|
|
294
|
+
</div>
|
|
295
|
+
{showFeedback && !empty && result.feedback.length > 0 && (
|
|
296
|
+
<ul className="pw-meter-feedback">
|
|
297
|
+
{result.feedback.map((line) => <li key={line}>{line}</li>)}
|
|
298
|
+
</ul>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from 'react';
|
|
2
|
+
import type { Field, FieldValue, FormData } from '../client/types';
|
|
3
|
+
import { declaredFieldPatch, getPath, resolveShowWhen, validateFieldValue } from './form-autosave';
|
|
4
|
+
import { emptyFieldValue, resolveFieldRenderer, warnUnknownFieldType } from './field-registry';
|
|
5
|
+
import { toDisplay } from './form-autosave';
|
|
6
|
+
import type { FileFieldOwnerResult } from './FileField';
|
|
7
|
+
|
|
8
|
+
export type ValidationProfile = 'schema-form' | 'wizard';
|
|
9
|
+
|
|
10
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
11
|
+
const BUILTIN_TYPES = new Set(['text', 'email', 'tel', 'select', 'switch', 'textarea']);
|
|
12
|
+
const warnedSelectValues = new Set<string>();
|
|
13
|
+
|
|
14
|
+
export function isEmptyFieldValue(value: FieldValue | undefined): boolean {
|
|
15
|
+
return value == null || value === '' || (Array.isArray(value) && value.length === 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function seedFormValues(fields: Field[], initial: FormData = {}): FormData {
|
|
19
|
+
const values: FormData = {};
|
|
20
|
+
for (const field of fields) values[field.name] = field.name in initial
|
|
21
|
+
? toDisplay(field, initial[field.name])
|
|
22
|
+
: emptyFieldValue(field);
|
|
23
|
+
return values;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function visibleSchemaFields(fields: Field[], values: FormData): Field[] {
|
|
27
|
+
return fields.filter((field) => resolveShowWhen(field.showWhen, values));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function schemaPayload(fields: Field[], values: FormData): FormData {
|
|
31
|
+
const payload: FormData = {};
|
|
32
|
+
for (const field of visibleSchemaFields(fields, values)) {
|
|
33
|
+
if (field.name in values) payload[field.name] = values[field.name]!;
|
|
34
|
+
}
|
|
35
|
+
return payload;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function reconcileFormValues(previous: FormData, fields: Field[], initial: FormData = {}): FormData {
|
|
39
|
+
const next: FormData = {};
|
|
40
|
+
for (const field of fields) next[field.name] = field.name in previous
|
|
41
|
+
? previous[field.name]!
|
|
42
|
+
: field.name in initial ? toDisplay(field, initial[field.name]) : emptyFieldValue(field);
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateSchemaFields(fields: Field[], values: FormData, profile: ValidationProfile): Record<string, string> {
|
|
47
|
+
const errors: Record<string, string> = {};
|
|
48
|
+
for (const field of visibleSchemaFields(fields, values)) {
|
|
49
|
+
const value = values[field.name];
|
|
50
|
+
if (field.required && isEmptyFieldValue(value)) {
|
|
51
|
+
errors[field.name] = `${field.label} is required`;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (field.type === 'email' && typeof value === 'string' && value && !EMAIL_RE.test(value)) {
|
|
55
|
+
errors[field.name] = 'Enter a valid email address';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (profile === 'wizard') {
|
|
59
|
+
const issue = validateFieldValue(field, value ?? emptyFieldValue(field));
|
|
60
|
+
if (issue) errors[field.name] = issue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SchemaFormState {
|
|
67
|
+
values: FormData;
|
|
68
|
+
errors: Record<string, string>;
|
|
69
|
+
formError: string;
|
|
70
|
+
visibleFields: Field[];
|
|
71
|
+
setValue: (name: string, value: FieldValue) => void;
|
|
72
|
+
patchValues: (patch: FormData) => void;
|
|
73
|
+
setErrors: (errors: Record<string, string>) => void;
|
|
74
|
+
setFormError: (message: string) => void;
|
|
75
|
+
validate: (fields?: Field[]) => Record<string, string>;
|
|
76
|
+
payload: (fields?: Field[]) => FormData;
|
|
77
|
+
registerFocusTarget: (name: string) => (node: HTMLElement | null) => void;
|
|
78
|
+
focusField: (name: string) => void;
|
|
79
|
+
uploadPending: boolean;
|
|
80
|
+
setUploadPending: (name: string, pending: boolean) => void;
|
|
81
|
+
ownerResults: Record<string, FileFieldOwnerResult>;
|
|
82
|
+
settleOwnerSave: (row: unknown, failed?: boolean, requested?: FormData) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useSchemaFormState({ fields, initial = {}, profile, reconcile = false, resetKey, deferReconcile = false }: {
|
|
86
|
+
fields: Field[];
|
|
87
|
+
initial?: FormData;
|
|
88
|
+
profile: ValidationProfile;
|
|
89
|
+
reconcile?: boolean;
|
|
90
|
+
resetKey?: unknown;
|
|
91
|
+
deferReconcile?: boolean;
|
|
92
|
+
}): SchemaFormState {
|
|
93
|
+
const [values, setValues] = useState<FormData>(() => seedFormValues(fields, initial));
|
|
94
|
+
const [errors, setErrorsState] = useState<Record<string, string>>({});
|
|
95
|
+
const [formError, setFormError] = useState('');
|
|
96
|
+
const initialRef = useRef(initial);
|
|
97
|
+
const resetRef = useRef(resetKey);
|
|
98
|
+
const mountedRef = useRef(false);
|
|
99
|
+
const focusTargets = useRef(new Map<string, HTMLElement>());
|
|
100
|
+
const pendingUploads = useRef(new Set<string>());
|
|
101
|
+
const [uploadPending, setUploadPendingState] = useState(false);
|
|
102
|
+
const [ownerResults, setOwnerResults] = useState<Record<string, FileFieldOwnerResult>>({});
|
|
103
|
+
const ownerGeneration = useRef(0);
|
|
104
|
+
initialRef.current = initial;
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!mountedRef.current) {
|
|
108
|
+
mountedRef.current = true;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (deferReconcile) return;
|
|
112
|
+
const resetChanged = !Object.is(resetRef.current, resetKey);
|
|
113
|
+
resetRef.current = resetKey;
|
|
114
|
+
if (resetChanged) {
|
|
115
|
+
setValues(seedFormValues(fields, initialRef.current));
|
|
116
|
+
setErrorsState({});
|
|
117
|
+
setFormError('');
|
|
118
|
+
} else if (reconcile) {
|
|
119
|
+
setValues((current) => reconcileFormValues(current, fields, initialRef.current));
|
|
120
|
+
setErrorsState((current) => {
|
|
121
|
+
const names = new Set(fields.map((field) => field.name));
|
|
122
|
+
return Object.fromEntries(Object.entries(current).filter(([name]) => names.has(name)));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}, [fields, reconcile, resetKey, deferReconcile]);
|
|
126
|
+
|
|
127
|
+
const setValue = useCallback((name: string, value: FieldValue) => {
|
|
128
|
+
setValues((current) => {
|
|
129
|
+
const next = { ...current, [name]: value };
|
|
130
|
+
setErrorsState((currentErrors) => {
|
|
131
|
+
const nextErrors = { ...currentErrors };
|
|
132
|
+
delete nextErrors[name];
|
|
133
|
+
for (const field of fields) {
|
|
134
|
+
if (!resolveShowWhen(field.showWhen, next)) delete nextErrors[field.name];
|
|
135
|
+
}
|
|
136
|
+
return nextErrors;
|
|
137
|
+
});
|
|
138
|
+
return next;
|
|
139
|
+
});
|
|
140
|
+
}, [fields]);
|
|
141
|
+
|
|
142
|
+
const patchValues = useCallback((patch: FormData) => {
|
|
143
|
+
const safe = declaredFieldPatch(fields, patch);
|
|
144
|
+
if (Object.keys(safe).length === 0) return;
|
|
145
|
+
setValues((current) => {
|
|
146
|
+
const next = { ...current, ...safe };
|
|
147
|
+
setErrorsState((currentErrors) => {
|
|
148
|
+
const nextErrors = { ...currentErrors };
|
|
149
|
+
for (const name of Object.keys(safe)) delete nextErrors[name];
|
|
150
|
+
for (const field of fields) {
|
|
151
|
+
if (!resolveShowWhen(field.showWhen, next)) delete nextErrors[field.name];
|
|
152
|
+
}
|
|
153
|
+
return nextErrors;
|
|
154
|
+
});
|
|
155
|
+
return next;
|
|
156
|
+
});
|
|
157
|
+
}, [fields]);
|
|
158
|
+
|
|
159
|
+
const validate = useCallback((subset = fields) => {
|
|
160
|
+
const next = validateSchemaFields(subset, values, profile);
|
|
161
|
+
setErrorsState((current) => {
|
|
162
|
+
const subsetNames = new Set(subset.map((field) => field.name));
|
|
163
|
+
const kept = Object.fromEntries(Object.entries(current).filter(([name]) => !subsetNames.has(name)));
|
|
164
|
+
return { ...kept, ...next };
|
|
165
|
+
});
|
|
166
|
+
return next;
|
|
167
|
+
}, [fields, profile, values]);
|
|
168
|
+
|
|
169
|
+
const registerFocusTarget = useCallback((name: string) => (node: HTMLElement | null) => {
|
|
170
|
+
if (node) focusTargets.current.set(name, node);
|
|
171
|
+
else focusTargets.current.delete(name);
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
const focusField = useCallback((name: string) => {
|
|
175
|
+
window.setTimeout(() => focusTargets.current.get(name)?.focus(), 0);
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const setUploadPending = useCallback((name: string, pending: boolean) => {
|
|
179
|
+
if (pending) pendingUploads.current.add(name);
|
|
180
|
+
else pendingUploads.current.delete(name);
|
|
181
|
+
setUploadPendingState(pendingUploads.current.size > 0);
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
const settleOwnerSave = useCallback((row: unknown, failed = false, requested: FormData = values) => {
|
|
185
|
+
const generation = ++ownerGeneration.current;
|
|
186
|
+
setOwnerResults(Object.fromEntries(fields
|
|
187
|
+
.filter((field) => field.type === 'file' || field.type === 'image')
|
|
188
|
+
.map((field) => [field.name, {
|
|
189
|
+
generation,
|
|
190
|
+
status: failed ? 'failed' as const : 'success' as const,
|
|
191
|
+
requestedValue: requested[field.name],
|
|
192
|
+
...(failed ? {} : { authoritativeValue: getPath(row, field.name) }),
|
|
193
|
+
}])));
|
|
194
|
+
}, [fields, values]);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
values,
|
|
198
|
+
errors,
|
|
199
|
+
formError,
|
|
200
|
+
visibleFields: visibleSchemaFields(fields, values),
|
|
201
|
+
setValue,
|
|
202
|
+
patchValues,
|
|
203
|
+
setErrors: setErrorsState,
|
|
204
|
+
setFormError,
|
|
205
|
+
validate,
|
|
206
|
+
payload: (subset = fields) => schemaPayload(subset, values),
|
|
207
|
+
registerFocusTarget,
|
|
208
|
+
focusField,
|
|
209
|
+
uploadPending,
|
|
210
|
+
setUploadPending,
|
|
211
|
+
ownerResults,
|
|
212
|
+
settleOwnerSave,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function SchemaSelect({ field, value, invalid, disabled, id, describedBy, focusTarget, onChange }: {
|
|
217
|
+
field: Field;
|
|
218
|
+
value: string;
|
|
219
|
+
invalid: boolean;
|
|
220
|
+
disabled?: boolean;
|
|
221
|
+
id?: string;
|
|
222
|
+
describedBy?: string;
|
|
223
|
+
focusTarget?: (node: HTMLSelectElement | null) => void;
|
|
224
|
+
onChange: (value: string) => void;
|
|
225
|
+
}) {
|
|
226
|
+
const options = field.options ?? [];
|
|
227
|
+
const known = value === '' || options.some((option) => String(option.value) === value);
|
|
228
|
+
if (!known) {
|
|
229
|
+
const key = `${field.name}:${value}`;
|
|
230
|
+
if (!warnedSelectValues.has(key)) {
|
|
231
|
+
warnedSelectValues.add(key);
|
|
232
|
+
console.warn(`SchemaForm: select "${field.name}" value "${value}" is not among its options — showing placeholder`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const shown = known ? value : '';
|
|
236
|
+
return (
|
|
237
|
+
<select ref={focusTarget} id={id} aria-invalid={invalid || undefined} aria-describedby={describedBy} disabled={disabled || field.disabled} className={`input${invalid ? ' input-invalid' : ''}`} value={shown} onChange={(event) => onChange(event.target.value)}>
|
|
238
|
+
{shown === '' && <option value="" disabled>{field.placeholder ?? 'Select…'}</option>}
|
|
239
|
+
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
240
|
+
</select>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function SchemaField({ field, state, disabled = false }: { field: Field; state: SchemaFormState; disabled?: boolean }) {
|
|
245
|
+
const uid = useId().replace(/:/g, '');
|
|
246
|
+
const controlId = `schema-${uid}`;
|
|
247
|
+
const labelId = `${controlId}-label`;
|
|
248
|
+
const errorId = `${controlId}-error`;
|
|
249
|
+
const helpId = `${controlId}-help`;
|
|
250
|
+
const error = state.errors[field.name];
|
|
251
|
+
const describedBy = error ? errorId : field.help ? helpId : undefined;
|
|
252
|
+
const value = state.values[field.name] ?? emptyFieldValue(field);
|
|
253
|
+
const set = (next: FieldValue) => state.setValue(field.name, next);
|
|
254
|
+
const Registered = BUILTIN_TYPES.has(field.type) ? null : resolveFieldRenderer(field.type);
|
|
255
|
+
|
|
256
|
+
if (field.type === 'switch') {
|
|
257
|
+
return (
|
|
258
|
+
<>
|
|
259
|
+
<label className="switch-row" htmlFor={controlId}>
|
|
260
|
+
<input ref={state.registerFocusTarget(field.name)} id={controlId} aria-invalid={!!error || undefined} aria-describedby={describedBy} type="checkbox" role="switch" className="switch" checked={value === true} disabled={disabled || field.disabled} onChange={(event) => set(event.target.checked)} />
|
|
261
|
+
<span className="field-label">{field.label}</span>
|
|
262
|
+
</label>
|
|
263
|
+
{error && <span id={errorId} className="field-error">{error}</span>}
|
|
264
|
+
{field.help && !error && <span id={helpId} className="field-help">{field.help}</span>}
|
|
265
|
+
</>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (Registered) {
|
|
270
|
+
return (
|
|
271
|
+
<div className="field">
|
|
272
|
+
<span id={labelId} className="field-label">{field.label}{field.required && <em> *</em>}</span>
|
|
273
|
+
<div ref={state.registerFocusTarget(field.name)} tabIndex={-1} aria-labelledby={labelId} aria-describedby={describedBy} aria-invalid={!!error || undefined}>
|
|
274
|
+
<Registered field={field} value={value} invalid={!!error} disabled={disabled} controlId={controlId} ariaDescribedBy={describedBy} focusTarget={state.registerFocusTarget(field.name)} commit={set} commitPatch={state.patchValues} onPendingChange={(pending) => state.setUploadPending(field.name, pending)} ownerResult={state.ownerResults[field.name]} />
|
|
275
|
+
</div>
|
|
276
|
+
{error && <span id={errorId} className="field-error">{error}</span>}
|
|
277
|
+
{field.help && !error && <span id={helpId} className="field-help">{field.help}</span>}
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!BUILTIN_TYPES.has(field.type)) warnUnknownFieldType('SchemaForm', field);
|
|
283
|
+
return (
|
|
284
|
+
<label className="field" htmlFor={controlId}>
|
|
285
|
+
<span className="field-label">{field.label}{field.required && <em> *</em>}</span>
|
|
286
|
+
{field.type === 'select' ? (
|
|
287
|
+
<SchemaSelect field={field} value={String(value ?? '')} invalid={!!error} disabled={disabled} id={controlId} describedBy={describedBy} focusTarget={state.registerFocusTarget(field.name)} onChange={set} />
|
|
288
|
+
) : field.type === 'textarea' ? (
|
|
289
|
+
<textarea ref={state.registerFocusTarget(field.name)} id={controlId} aria-invalid={!!error || undefined} aria-describedby={describedBy} disabled={disabled || field.disabled} className={`input${error ? ' input-invalid' : ''}`} rows={3} placeholder={field.placeholder} value={String(value ?? '')} onChange={(event) => set(event.target.value)} />
|
|
290
|
+
) : (
|
|
291
|
+
<input ref={state.registerFocusTarget(field.name)} id={controlId} aria-invalid={!!error || undefined} aria-describedby={describedBy} disabled={disabled || field.disabled} className={`input${error ? ' input-invalid' : ''}`} type={BUILTIN_TYPES.has(field.type) ? field.type : 'text'} placeholder={field.placeholder} value={String(value ?? '')} onChange={(event) => set(event.target.value)} />
|
|
292
|
+
)}
|
|
293
|
+
{error && <span id={errorId} className="field-error">{error}</span>}
|
|
294
|
+
{field.help && !error && <span id={helpId} className="field-help">{field.help}</span>}
|
|
295
|
+
</label>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function SchemaFieldGrid({ fields, state, disabled = false }: { fields: Field[]; state: SchemaFormState; disabled?: boolean }) {
|
|
300
|
+
return (
|
|
301
|
+
<div className="form-grid">
|
|
302
|
+
{fields.filter((field) => resolveShowWhen(field.showWhen, state.values)).map((field) => (
|
|
303
|
+
<div key={field.name} className={field.columns === 6 ? 'col-6' : 'col-12'}>
|
|
304
|
+
<SchemaField field={field} state={state} disabled={disabled} />
|
|
305
|
+
</div>
|
|
306
|
+
))}
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function firstErrorName(fields: Field[], errors: Record<string, string>): string | null {
|
|
312
|
+
return fields.find((field) => errors[field.name])?.name ?? null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type SubmitEvent = FormEvent<HTMLFormElement>;
|