@antelopejs/dms 0.8.3 → 0.9.1
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/CHANGELOG.md +36 -0
- package/data/module-store/catalog.json +71 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/implementations/dms/frontend-backend-proxies.d.ts +3 -0
- package/dist/implementations/dms/frontend-backend-proxies.js +39 -0
- package/dist/implementations/dms/frontend-backend-proxies.js.map +1 -0
- package/dist/implementations/dms/frontend-bootstrap.d.ts +1 -0
- package/dist/implementations/dms/frontend-bootstrap.js +13 -0
- package/dist/implementations/dms/frontend-bootstrap.js.map +1 -1
- package/dist/implementations/dms/frontend-modules.d.ts +6 -2
- package/dist/implementations/dms/frontend-modules.js +37 -8
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/frontend-ready.d.ts +8 -0
- package/dist/implementations/dms/frontend-ready.js +34 -0
- package/dist/implementations/dms/frontend-ready.js.map +1 -0
- package/dist/implementations/dms/manifest.d.ts +7 -0
- package/dist/implementations/dms/manifest.js +13 -0
- package/dist/implementations/dms/manifest.js.map +1 -1
- package/dist/implementations/dms/menu-categories.d.ts +16 -0
- package/dist/implementations/dms/menu-categories.js +30 -0
- package/dist/implementations/dms/menu-categories.js.map +1 -0
- package/dist/implementations/dms/nav-badges.d.ts +0 -8
- package/dist/implementations/dms/nav-badges.js +0 -16
- package/dist/implementations/dms/nav-badges.js.map +1 -1
- package/dist/implementations/dms/page.d.ts +13 -5
- package/dist/implementations/dms/page.js +72 -41
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/implementations/dms-html-render/index.js +5 -2
- package/dist/implementations/dms-html-render/index.js.map +1 -1
- package/dist/implementations/dms-notifications/index.d.ts +4 -3
- package/dist/implementations/dms-notifications/index.js +14 -6
- package/dist/implementations/dms-notifications/index.js.map +1 -1
- package/dist/pages/settings/settings-index.js +3 -2
- package/dist/pages/settings/settings-index.js.map +1 -1
- package/dist/pages/settings/users/invites.js +2 -2
- package/dist/pages/settings/users/invites.js.map +1 -1
- package/dist/pages/settings/users/member-change-notifications.js +17 -6
- package/dist/pages/settings/users/member-change-notifications.js.map +1 -1
- package/dist/pages/settings/users/member-invite-form.d.ts +3 -2
- package/dist/pages/settings/users/member-invite-form.js +7 -4
- package/dist/pages/settings/users/member-invite-form.js.map +1 -1
- package/dist/pages/settings/users/member-roles-field.js +2 -1
- package/dist/pages/settings/users/member-roles-field.js.map +1 -1
- package/dist/pages/settings/users/members.js +4 -1
- package/dist/pages/settings/users/members.js.map +1 -1
- package/dist/pages/settings/users/notifications.d.ts +6 -0
- package/dist/pages/settings/users/notifications.js +23 -15
- package/dist/pages/settings/users/notifications.js.map +1 -1
- package/dist/pages/settings/users/profile.js +1 -1
- package/dist/pages/settings/users/profile.js.map +1 -1
- package/dist/pages/settings/users/region-form.d.ts +1 -1
- package/dist/pages/settings/users/region-form.js +2 -1
- package/dist/pages/settings/users/region-form.js.map +1 -1
- package/dist/pages/settings/users/role-editor-store.d.ts +3 -1
- package/dist/pages/settings/users/role-editor-store.js +17 -4
- package/dist/pages/settings/users/role-editor-store.js.map +1 -1
- package/dist/pages/settings/users/roles.js +1 -0
- package/dist/pages/settings/users/roles.js.map +1 -1
- package/dist/realtime/registry.d.ts +1 -0
- package/dist/realtime/registry.js +5 -0
- package/dist/realtime/registry.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/realtime.d.ts +5 -1
- package/dist/routes/realtime.js +16 -9
- package/dist/routes/realtime.js.map +1 -1
- package/dist/routes/store.d.ts +21 -0
- package/dist/routes/store.js +130 -0
- package/dist/routes/store.js.map +1 -0
- package/dist/store/activity.d.ts +2 -0
- package/dist/store/activity.js +12 -0
- package/dist/store/activity.js.map +1 -0
- package/dist/store/cache.d.ts +8 -0
- package/dist/store/cache.js +29 -0
- package/dist/store/cache.js.map +1 -0
- package/dist/store/catalog.d.ts +4 -0
- package/dist/store/catalog.js +69 -0
- package/dist/store/catalog.js.map +1 -0
- package/dist/store/compatibility.d.ts +3 -0
- package/dist/store/compatibility.js +45 -0
- package/dist/store/compatibility.js.map +1 -0
- package/dist/store/declarations.d.ts +18 -0
- package/dist/store/declarations.js +116 -0
- package/dist/store/declarations.js.map +1 -0
- package/dist/store/frontend-compatibility.d.ts +18 -0
- package/dist/store/frontend-compatibility.js +93 -0
- package/dist/store/frontend-compatibility.js.map +1 -0
- package/dist/store/interfaces.d.ts +6 -0
- package/dist/store/interfaces.js +126 -0
- package/dist/store/interfaces.js.map +1 -0
- package/dist/store/listing.d.ts +2 -0
- package/dist/store/listing.js +180 -0
- package/dist/store/listing.js.map +1 -0
- package/dist/store/operations.d.ts +8 -0
- package/dist/store/operations.js +254 -0
- package/dist/store/operations.js.map +1 -0
- package/dist/store/registry.d.ts +4 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/registry.js.map +1 -0
- package/dist/store/requirements.d.ts +14 -0
- package/dist/store/requirements.js +81 -0
- package/dist/store/requirements.js.map +1 -0
- package/dist/store/runtime.d.ts +11 -0
- package/dist/store/runtime.js +78 -0
- package/dist/store/runtime.js.map +1 -0
- package/dist/store/tarball.d.ts +2 -0
- package/dist/store/tarball.js +80 -0
- package/dist/store/tarball.js.map +1 -0
- package/dist/store/types.d.ts +98 -0
- package/dist/store/types.js +3 -0
- package/dist/store/types.js.map +1 -0
- package/dist/test/integration/account-deletion.test.js +2 -2
- package/dist/test/integration/account-deletion.test.js.map +1 -1
- package/dist/test/integration/auth/auth-key-rotation.test.js +2 -1
- package/dist/test/integration/auth/auth-key-rotation.test.js.map +1 -1
- package/dist/test/integration/auth/current-password.test.js +2 -1
- package/dist/test/integration/auth/current-password.test.js.map +1 -1
- package/dist/test/integration/auth/session-revocation.test.js +2 -1
- package/dist/test/integration/auth/session-revocation.test.js.map +1 -1
- package/dist/test/integration/auth/sign-in-country.test.js +2 -1
- package/dist/test/integration/auth/sign-in-country.test.js.map +1 -1
- package/dist/test/integration/auth/two-factor-codes.test.js +2 -1
- package/dist/test/integration/auth/two-factor-codes.test.js.map +1 -1
- package/dist/test/integration/auth/two-factor-password.test.js +3 -2
- package/dist/test/integration/auth/two-factor-password.test.js.map +1 -1
- package/dist/test/integration/email-change.test.js +2 -1
- package/dist/test/integration/email-change.test.js.map +1 -1
- package/dist/test/integration/files/component-files.test.js +2 -2
- package/dist/test/integration/files/component-files.test.js.map +1 -1
- package/dist/test/integration/member-settings-access.test.js +8 -7
- package/dist/test/integration/member-settings-access.test.js.map +1 -1
- package/dist/test/integration/menu-access-events.test.d.ts +1 -0
- package/dist/test/integration/menu-access-events.test.js +148 -0
- package/dist/test/integration/menu-access-events.test.js.map +1 -0
- package/dist/test/integration/notification-inbox.test.js +4 -1
- package/dist/test/integration/notification-inbox.test.js.map +1 -1
- package/dist/test/integration/notification-locked-subjects.test.js +3 -2
- package/dist/test/integration/notification-locked-subjects.test.js.map +1 -1
- package/dist/test/integration/notification-unread-badge.test.js +51 -5
- package/dist/test/integration/notification-unread-badge.test.js.map +1 -1
- package/dist/test/integration/page-layout-route-guard.test.d.ts +1 -0
- package/dist/test/integration/page-layout-route-guard.test.js +56 -0
- package/dist/test/integration/page-layout-route-guard.test.js.map +1 -0
- package/dist/test/integration/permission-warnings.test.js +3 -2
- package/dist/test/integration/permission-warnings.test.js.map +1 -1
- package/dist/test/integration/removed-member-notifications.test.js +3 -2
- package/dist/test/integration/removed-member-notifications.test.js.map +1 -1
- package/dist/test/integration/role-delete-reassign.test.js +2 -1
- package/dist/test/integration/role-delete-reassign.test.js.map +1 -1
- package/dist/test/integration/role-permission-ancestors.test.js +2 -1
- package/dist/test/integration/role-permission-ancestors.test.js.map +1 -1
- package/dist/test/unit/frontend-bootstrap.test.js +13 -0
- package/dist/test/unit/frontend-bootstrap.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/category-slug-registry.test.js +14 -5
- package/dist/test/unit/implementations/dms/category-slug-registry.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/frontend-backend-proxies.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/frontend-backend-proxies.test.js +66 -0
- package/dist/test/unit/implementations/dms/frontend-backend-proxies.test.js.map +1 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js +26 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/nav-badges.test.js +23 -9
- package/dist/test/unit/implementations/dms/nav-badges.test.js.map +1 -1
- package/dist/test/unit/implementations/dms-html-render/generate-html.test.js +22 -4
- package/dist/test/unit/implementations/dms-html-render/generate-html.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js +12 -0
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -1
- package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
- package/dist/test/unit/pages/settings/member-roles-field.test.js +1 -1
- package/dist/test/unit/pages/settings/member-roles-field.test.js.map +1 -1
- package/dist/test/unit/pages/settings/role-delete-confirm.test.js.map +1 -1
- package/dist/test/unit/realtime/menu-topics.test.d.ts +1 -0
- package/dist/test/unit/realtime/menu-topics.test.js +26 -0
- package/dist/test/unit/realtime/menu-topics.test.js.map +1 -0
- package/dist/test/unit/store/store.test.d.ts +1 -0
- package/dist/test/unit/store/store.test.js +127 -0
- package/dist/test/unit/store/store.test.js.map +1 -0
- package/dist/validation/store.schema.d.ts +39 -0
- package/dist/validation/store.schema.js +27 -0
- package/dist/validation/store.schema.js.map +1 -0
- package/frontend-vue/deferred-plugins.ts +3 -4
- package/frontend-vue/dms.email.ts +5 -1
- package/frontend-vue/dms.frontend.ts +15 -5
- package/frontend-vue/dms.server.ts +74 -0
- package/frontend-vue/layers/dms-auth/app/composables/useLogout.ts +1 -1
- package/frontend-vue/layers/dms-auth/app/composables/usePostLoginRedirect.ts +16 -3
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/2fa.vue +3 -1
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/backup.vue +3 -1
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/forgot-validation.vue +1 -1
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/oauth-complete.vue +26 -3
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/recover.vue +1 -1
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/signup.vue +22 -25
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/validate.vue +1 -1
- package/frontend-vue/layers/dms-auth/app/middleware/auth.ts +5 -7
- package/frontend-vue/layers/dms-auth/app/plugins/permissions-preload.ts +17 -0
- package/frontend-vue/layers/dms-auth/server/accounts.ts +135 -0
- package/frontend-vue/layers/dms-auth/server/actions.ts +251 -0
- package/frontend-vue/layers/dms-auth/server/oauth.ts +260 -0
- package/frontend-vue/layers/dms-auth/server/session.ts +158 -0
- package/frontend-vue/layers/dms-core/app/build/utils/asyncDataScope.ts +47 -0
- package/frontend-vue/layers/dms-core/app/build/utils/permission-preview.ts +6 -2
- package/frontend-vue/layers/dms-core/app/composables/auth/useApiData.ts +148 -0
- package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +7 -20
- package/frontend-vue/layers/dms-core/app/composables/auth/useCurrentUser.ts +2 -2
- package/frontend-vue/layers/dms-core/app/composables/auth/useMultiAccount.ts +18 -5
- package/frontend-vue/layers/dms-core/app/composables/auth/usePermissions.ts +35 -6
- package/frontend-vue/layers/dms-core/app/composables/auth/useSessionRecovery.ts +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +20 -14
- package/frontend-vue/layers/dms-core/app/composables/period/periodClock.ts +121 -0
- package/frontend-vue/layers/dms-core/app/composables/period/periodRenderNow.ts +42 -0
- package/frontend-vue/layers/dms-core/app/composables/period/periodTimeZone.ts +31 -0
- package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +48 -19
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +33 -4
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +78 -25
- package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +3 -0
- package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +36 -6
- package/frontend-vue/layers/dms-core/app/composables/user/useUserRegionalPreferences.ts +1 -1
- package/frontend-vue/layers/dms-core/app/plugins/async-data-scope.ts +26 -0
- package/frontend-vue/layers/dms-core/app/utils/pageRoutes.ts +22 -0
- package/frontend-vue/layers/dms-core/app/utils/regional.ts +36 -3
- package/frontend-vue/layers/dms-core/app/utils/trailingDeduper.ts +10 -1
- package/frontend-vue/layers/dms-core/shared/types/app-config.ts +48 -4
- package/frontend-vue/layers/dms-layout/app/app.config.ts +42 -23
- package/frontend-vue/layers/dms-layout/app/assets/css/main.css +72 -20
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardFrame.vue +9 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +0 -3
- package/frontend-vue/layers/dms-layout/app/build/components/notification/NotificationPopover.vue +9 -3
- package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreDependencyPicker.vue +136 -0
- package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreRequirements.vue +98 -0
- package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreTile.vue +247 -29
- package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleTile.vue +19 -2
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/members/MemberRolePicker.vue +13 -12
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +17 -7
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationPreferencesForm.vue +0 -1
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +36 -33
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfileSecuritySummary.vue +1 -2
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/roles/role-types.ts +5 -4
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +35 -28
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityStatus.vue +0 -1
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +1 -1
- package/frontend-vue/layers/dms-layout/app/build/composables/dev-reload/useDevReloadHolder.ts +7 -6
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/sidePanelState.ts +1 -1
- package/frontend-vue/layers/dms-layout/app/build/composables/navigation/useVisitNavBadges.ts +26 -0
- package/frontend-vue/layers/dms-layout/app/build/composables/notification/useNotificationCatalog.ts +25 -23
- package/frontend-vue/layers/dms-layout/app/build/composables/notification/useNotificationPreferences.ts +44 -23
- package/frontend-vue/layers/dms-layout/app/build/composables/security/useSessionHandoff.ts +1 -1
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/preferencesSummary.ts +1 -1
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +3 -2
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useProfileAvatar.ts +1 -18
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useProfilePreferences.ts +19 -23
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +6 -3
- package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityOverview.ts +13 -18
- package/frontend-vue/layers/dms-layout/app/build/utils/module-store-catalog.ts +186 -120
- package/frontend-vue/layers/dms-layout/app/build/utils/module-store-install.ts +146 -0
- package/frontend-vue/layers/dms-layout/app/build/utils/modules-catalog.ts +8 -6
- package/frontend-vue/layers/dms-layout/app/build/utils/publicErrorMessage.ts +19 -0
- package/frontend-vue/layers/dms-layout/app/components/navigation/CollapsedMenuItem.vue +0 -11
- package/frontend-vue/layers/dms-layout/app/components/navigation/NavigationMenu.vue +3 -13
- package/frontend-vue/layers/dms-layout/app/composables/general/useSystemState.ts +7 -11
- package/frontend-vue/layers/dms-layout/app/composables/notification/useNotifications.ts +103 -22
- package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +109 -0
- package/frontend-vue/layers/dms-layout/app/composables/page/useModulesListing.ts +25 -102
- package/frontend-vue/layers/dms-layout/app/composables/page/usePageComponentPreload.ts +0 -10
- package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +177 -96
- package/frontend-vue/layers/dms-layout/app/composables/profile/useUserLanguage.ts +3 -2
- package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +341 -62
- package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +31 -11
- package/frontend-vue/layers/dms-layout/app/error.vue +5 -2
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +1 -1
- package/frontend-vue/layers/dms-layout/app/middleware/page-leave-guard.global.ts +2 -2
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +12 -9
- package/frontend-vue/layers/dms-layout/app/plugins/dms-menu-sync.client.ts +31 -3
- package/frontend-vue/layers/dms-layout/app/plugins/favicon.ts +58 -0
- package/frontend-vue/layers/dms-layout/app/plugins/notification-stream.client.ts +15 -7
- package/frontend-vue/layers/dms-layout/app/plugins/profile-sync.client.ts +5 -1
- package/frontend-vue/layers/dms-layout/app/plugins/regional-preferences.ts +10 -4
- package/frontend-vue/layers/dms-layout/app/plugins/time-zone-cookie.client.ts +12 -0
- package/frontend-vue/layers/dms-layout/app/types/module-store.ts +134 -0
- package/frontend-vue/layers/dms-layout/app/types/page.ts +13 -0
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +107 -9
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +107 -9
- package/frontend-vue/layers/dms-layout/server/html-render.ts +58 -0
- package/frontend-vue/layers/dms-layout/server/render-token.ts +95 -0
- package/frontend-vue/layers/dms-onboarding/app/composables/onboarding/useOnboarding.ts +25 -21
- package/frontend-vue/layers/dms-onboarding/app/middleware/onboarding.global.ts +1 -1
- package/frontend-vue/layers/dms-onboarding/app/plugins/onboarding-preload.ts +9 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +4 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/files/useFileMetadata.ts +162 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +23 -4
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageDataRefresh.ts +101 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/navigation/useNavBadges.ts +33 -6
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +19 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +40 -20
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableFooter.ts +6 -8
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +42 -14
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +19 -8
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +5 -2
- package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +9 -9
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +20 -18
- package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +26 -132
- package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +40 -105
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -0
- package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -4
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +9 -13
- package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +2 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/FilePreview.vue +12 -24
- package/frontend-vue/layers/dms-ui/app/components/table-view/ImagePreview.vue +17 -29
- package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +7 -3
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +20 -8
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +8 -11
- package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +146 -182
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +8 -2
- package/frontend-vue/layers/dms-ui/app/composables/useFileReadUrls.ts +11 -99
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +32 -3
- package/frontend-vue/package.json +3 -3
- package/frontend-vue/pnpm-lock.yaml +16 -22
- package/frontend-vue/server/private-options.ts +27 -0
- package/frontend-vue/tests/async-data-scope.test.ts +121 -0
- package/frontend-vue/tests/auth-middleware-error.test.ts +3 -3
- package/frontend-vue/tests/banner-block-fetch.test.ts +3 -0
- package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +3 -0
- package/frontend-vue/tests/block-items-period-realtime.test.ts +3 -0
- package/frontend-vue/tests/block-not-found.test.ts +16 -2
- package/frontend-vue/tests/block-page-refresh.test.ts +3 -0
- package/frontend-vue/tests/block-route-params.test.ts +3 -0
- package/frontend-vue/tests/branding-defaults.test.ts +34 -0
- package/frontend-vue/tests/builtin-emails.test.ts +23 -2
- package/frontend-vue/tests/chart-live-pause.test.ts +4 -1
- package/frontend-vue/tests/chart-value-propagation.test.ts +4 -1
- package/frontend-vue/tests/composed-text-blocks.test.ts +3 -0
- package/frontend-vue/tests/email-layout.test.ts +66 -4
- package/frontend-vue/tests/error-page-message.test.ts +25 -2
- package/frontend-vue/tests/favicon.test.ts +131 -0
- package/frontend-vue/tests/file-fields-ssr.test.ts +145 -0
- package/frontend-vue/tests/file-read-urls.test.ts +145 -34
- package/frontend-vue/tests/file-unavailable.test.ts +5 -1
- package/frontend-vue/tests/fixtures/dataEngine.ts +44 -0
- package/frontend-vue/tests/form-load-error.test.ts +55 -1
- package/frontend-vue/tests/helpers/api-data-runtime.ts +77 -0
- package/frontend-vue/tests/image-hydration-ids.test.ts +21 -16
- package/frontend-vue/tests/image-reference-preservation.test.ts +59 -53
- package/frontend-vue/tests/input-number-adjust.test.ts +14 -0
- package/frontend-vue/tests/key-value-list-copy.test.ts +3 -0
- package/frontend-vue/tests/member-role-picker.test.ts +4 -2
- package/frontend-vue/tests/menu-sync-reconnect.test.ts +47 -0
- package/frontend-vue/tests/metadata-retry.test.ts +5 -1
- package/frontend-vue/tests/module-routing.test.ts +2 -5
- package/frontend-vue/tests/module-store-catalog.test.ts +270 -51
- package/frontend-vue/tests/modules-catalog.test.ts +17 -4
- package/frontend-vue/tests/modules-listing-transfer.test.ts +95 -0
- package/frontend-vue/tests/multi-account-validate.test.ts +23 -1
- package/frontend-vue/tests/nav-badge-tones.test.ts +66 -3
- package/frontend-vue/tests/notification-counts-from-writes.test.ts +223 -0
- package/frontend-vue/tests/notification-counts-transfer.test.ts +88 -0
- package/frontend-vue/tests/notification-inbox-display.test.ts +57 -5
- package/frontend-vue/tests/notification-popover-errors.test.ts +21 -1
- package/frontend-vue/tests/oauth-complete-handoff.test.ts +209 -0
- package/frontend-vue/tests/onboarding-middleware.test.ts +1 -1
- package/frontend-vue/tests/onboarding-transfer.test.ts +106 -0
- package/frontend-vue/tests/page-data-refresh.test.ts +141 -0
- package/frontend-vue/tests/page-fill-height.test.ts +16 -3
- package/frontend-vue/tests/page-no-visible-blocks.test.ts +12 -2
- package/frontend-vue/tests/page-realtime-remount.test.ts +1 -1
- package/frontend-vue/tests/page-record-label.test.ts +0 -7
- package/frontend-vue/tests/page-validation-redirect.test.ts +12 -2
- package/frontend-vue/tests/period-hydration-now.test.ts +185 -0
- package/frontend-vue/tests/period-live-pause.test.ts +3 -0
- package/frontend-vue/tests/period-reader-zone.test.ts +137 -0
- package/frontend-vue/tests/period-scope-first-fetch.test.ts +17 -1
- package/frontend-vue/tests/period-ssr.test.ts +195 -0
- package/frontend-vue/tests/permission-preview-partial.test.ts +0 -2
- package/frontend-vue/tests/permissions-transfer.test.ts +194 -0
- package/frontend-vue/tests/post-login-redirect.test.ts +10 -5
- package/frontend-vue/tests/profile-personal-info-ssr.test.ts +150 -0
- package/frontend-vue/tests/profile-preferences-summary.test.ts +15 -11
- package/frontend-vue/tests/realtime-reconnect.test.ts +22 -3
- package/frontend-vue/tests/roles-name-errors.test.ts +1 -1
- package/frontend-vue/tests/row-side-panel.test.ts +1 -1
- package/frontend-vue/tests/security-sessions-load-error.test.ts +3 -1
- package/frontend-vue/tests/server-routes-vs-pages.test.ts +26 -0
- package/frontend-vue/tests/server-routes.test.ts +582 -0
- package/frontend-vue/tests/session-handoff.test.ts +1 -1
- package/frontend-vue/tests/settings-reads-ssr.test.ts +232 -0
- package/frontend-vue/tests/side-panels.test.ts +8 -1
- package/frontend-vue/tests/sign-in-exit-history.test.ts +219 -0
- package/frontend-vue/tests/signup-invitation-link.test.ts +56 -8
- package/frontend-vue/tests/site-layout-categories.test.ts +42 -0
- package/frontend-vue/tests/site-layout-hydration.test.ts +3 -3
- package/frontend-vue/tests/site-layout-rendered.test.ts +103 -0
- package/frontend-vue/tests/site-layout-resync.test.ts +87 -0
- package/frontend-vue/tests/ssr-guard-preload.test.ts +188 -0
- package/frontend-vue/tests/table-cell-tones.test.ts +36 -1
- package/frontend-vue/tests/table-data-ssr.test.ts +195 -0
- package/frontend-vue/tests/table-list-data-requests.test.ts +154 -0
- package/frontend-vue/tests/table-retry-loading.test.ts +37 -0
- package/frontend-vue/tests/table-source-navigation.test.ts +0 -1
- package/frontend-vue/tests/table-source-period-scope.test.ts +0 -1
- package/frontend-vue/tests/table-view-views.test.ts +1 -1
- package/frontend-vue/tests/top-list-card-actions.test.ts +3 -0
- package/frontend-vue/tests/top-list-card-period-badge-ssr.test.ts +212 -0
- package/frontend-vue/tests/top-list-card-period-badge.test.ts +79 -0
- package/frontend-vue/tests/top-list-card-value-suffix.test.ts +3 -0
- package/frontend-vue/tests/trailing-deduper.test.ts +22 -0
- package/frontend-vue/tests/use-api-data-update.test.ts +119 -0
- package/frontend-vue/tests/use-api-data.test.ts +410 -0
- package/frontend-vue/tests/user-language.test.ts +8 -5
- package/frontend-vue/tests/widget-label-i18n.test.ts +4 -1
- package/frontend-vue/vitest.config.ts +6 -0
- package/package.json +2 -2
- package/skills/dms/SKILL.md +2 -1
- package/skills/dms-module/SKILL.md +1 -1
- package/skills/dms-pages/SKILL.md +4 -1
- package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +0 -9
- package/frontend-vue/layers/dms-layout/app/composables/page/usePrefetch.ts +0 -21
- package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +0 -10
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useServerRenderedAsyncData.ts +0 -23
- package/frontend-vue/tests/control-error-path.test.ts +0 -11
- package/frontend-vue/tests/modules-listing-per-request.test.ts +0 -104
- package/frontend-vue/tests/page-layout-background-refresh.test.ts +0 -321
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { DmsServerEvent } from "#dms/server";
|
|
2
|
+
import {
|
|
3
|
+
isPersistent,
|
|
4
|
+
persistAccountSession,
|
|
5
|
+
readAccount,
|
|
6
|
+
removeAccount,
|
|
7
|
+
storeAccount,
|
|
8
|
+
type StoredAccount,
|
|
9
|
+
} from "./accounts";
|
|
10
|
+
import { isRefusal, type SessionTokens, sessionFrom } from "./session";
|
|
11
|
+
|
|
12
|
+
// Carries the "keep me signed in" choice of a login across its 2FA step, whose
|
|
13
|
+
// request names only the challenge. Lives as long as the challenge token.
|
|
14
|
+
const PENDING_LOGIN = "dms_pending_login";
|
|
15
|
+
const PENDING_LOGIN_PATH = "/auth/verify-2fa";
|
|
16
|
+
const PENDING_LOGIN_LIFETIME_S = 300;
|
|
17
|
+
|
|
18
|
+
// Absolute backend API paths only: no scheme, no authority, no query string,
|
|
19
|
+
// and no segment that could climb out of `/api/`.
|
|
20
|
+
const BACKEND_API_PATH =
|
|
21
|
+
/^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
|
|
22
|
+
|
|
23
|
+
type Input = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
interface EstablishResult extends SessionTokens {
|
|
26
|
+
requires_2fa?: boolean;
|
|
27
|
+
requires_tenant_assignment?: boolean;
|
|
28
|
+
two_factor_token?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PendingLogin {
|
|
32
|
+
token: string;
|
|
33
|
+
persistent: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Calls a backend endpoint that opens a session and, unless it asks for a
|
|
38
|
+
* second step, writes the session and stores the account.
|
|
39
|
+
*/
|
|
40
|
+
async function establishWith(
|
|
41
|
+
event: DmsServerEvent,
|
|
42
|
+
endpoint: string,
|
|
43
|
+
payload: unknown,
|
|
44
|
+
persistent = true,
|
|
45
|
+
) {
|
|
46
|
+
const result = await event.backend<EstablishResult>(endpoint, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
body: payload,
|
|
49
|
+
});
|
|
50
|
+
if (result.requires_2fa && typeof result.two_factor_token === "string")
|
|
51
|
+
event.cookies.writeSealed(
|
|
52
|
+
PENDING_LOGIN,
|
|
53
|
+
{ token: result.two_factor_token, persistent },
|
|
54
|
+
{ maxAge: PENDING_LOGIN_LIFETIME_S, path: PENDING_LOGIN_PATH },
|
|
55
|
+
);
|
|
56
|
+
if (result.requires_2fa || result.requires_tenant_assignment) return result;
|
|
57
|
+
if (event.cookies.readSealed(PENDING_LOGIN))
|
|
58
|
+
event.cookies.clear(PENDING_LOGIN, { path: PENDING_LOGIN_PATH });
|
|
59
|
+
const account = persistAccountSession(event, sessionFrom(result, persistent));
|
|
60
|
+
return { user: result.user, account };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `POST /auth/login`: signs in with a password. Only a login that asked to be
|
|
65
|
+
* kept signed in outlives the browser; any other ends when the browser closes.
|
|
66
|
+
*/
|
|
67
|
+
export async function login(event: DmsServerEvent) {
|
|
68
|
+
const input = await event.readJson<Input>();
|
|
69
|
+
return establishWith(
|
|
70
|
+
event,
|
|
71
|
+
"/api/auth/login",
|
|
72
|
+
input,
|
|
73
|
+
input.keep_login === true,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `POST /auth/register`: the signup page itself is `/auth/signup`. */
|
|
78
|
+
export async function signup(event: DmsServerEvent) {
|
|
79
|
+
return establishWith(event, "/api/auth/signup", await event.readJson());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `POST /auth/verify-2fa`: finishes a login on its 2FA step, with the lifetime
|
|
84
|
+
* the login chose. A challenge that no login of this browser left a choice
|
|
85
|
+
* for, such as the one an OAuth sign-in hands over, keeps the default.
|
|
86
|
+
*/
|
|
87
|
+
export async function verifyTwoFactor(event: DmsServerEvent) {
|
|
88
|
+
const input = await event.readJson<Input>();
|
|
89
|
+
const pending = event.cookies.readSealed<PendingLogin>(PENDING_LOGIN);
|
|
90
|
+
const persistent =
|
|
91
|
+
typeof input.token === "string" && pending?.token === input.token
|
|
92
|
+
? isPersistent(pending)
|
|
93
|
+
: true;
|
|
94
|
+
return establishWith(event, "/api/auth/verify-2fa", input, persistent);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `POST /auth/request-2fa-email`: relayed to the backend as is. */
|
|
98
|
+
export async function requestTwoFactorEmail(event: DmsServerEvent) {
|
|
99
|
+
return event.backend("/api/auth/request-2fa-email", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: await event.readJson(),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a caller-named endpoint is one of the declared ones. The grammar is
|
|
107
|
+
* checked on the caller's value too, so a declaration that was never meant to
|
|
108
|
+
* be a prefix cannot be widened by a traversal or a query string smuggled
|
|
109
|
+
* into the request.
|
|
110
|
+
*/
|
|
111
|
+
export function isAllowedEstablishEndpoint(
|
|
112
|
+
endpoint: unknown,
|
|
113
|
+
allowed: readonly string[],
|
|
114
|
+
): endpoint is string {
|
|
115
|
+
return (
|
|
116
|
+
typeof endpoint === "string" &&
|
|
117
|
+
BACKEND_API_PATH.test(endpoint) &&
|
|
118
|
+
allowed.includes(endpoint)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `POST /auth/establish`: opens a session from a backend endpoint that mints a
|
|
124
|
+
* token pair, the generic half of `/auth/login`. A module whose own flow ends
|
|
125
|
+
* in an authenticated user (a registration completing, an invitation being
|
|
126
|
+
* redeemed) declares the backend route that finishes it
|
|
127
|
+
* (`authEstablishEndpoints`, or `DMS_AUTH_ESTABLISH_ENDPOINTS`); the browser
|
|
128
|
+
* names it and a payload, and never carries a token.
|
|
129
|
+
*/
|
|
130
|
+
export async function establish(event: DmsServerEvent) {
|
|
131
|
+
const input = await event.readJson<Input>();
|
|
132
|
+
if (
|
|
133
|
+
!isAllowedEstablishEndpoint(
|
|
134
|
+
input.endpoint,
|
|
135
|
+
event.app.authEstablishEndpoints,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
139
|
+
return establishWith(event, input.endpoint, input.payload ?? {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `POST /auth/remove-account`: forgets a stored account. */
|
|
143
|
+
export async function removeStoredAccount(event: DmsServerEvent) {
|
|
144
|
+
const input = await event.readJson<Input>();
|
|
145
|
+
removeAccount(event, input.accountId);
|
|
146
|
+
return { success: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `POST /auth/switch-tenant`: the same account, in another tenant. */
|
|
150
|
+
export async function switchTenant(event: DmsServerEvent) {
|
|
151
|
+
const session = event.session.read();
|
|
152
|
+
const input = await event.readJson<Input>();
|
|
153
|
+
if (!session)
|
|
154
|
+
return Response.json({ message: "Not authenticated" }, { status: 401 });
|
|
155
|
+
if (!input.tenantId)
|
|
156
|
+
return Response.json({ message: "tenantId required" }, { status: 400 });
|
|
157
|
+
const tokens = await event.backend<SessionTokens>("/api/auth/switch-tenant", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
token: session.accessToken,
|
|
160
|
+
body: { refreshToken: session.refreshToken, tenantId: input.tenantId },
|
|
161
|
+
});
|
|
162
|
+
const user = await event.backend<SessionTokens["user"]>("/api/auth/me", {
|
|
163
|
+
token: tokens.access_token,
|
|
164
|
+
});
|
|
165
|
+
const switched = sessionFrom({ ...tokens, user }, isPersistent(session));
|
|
166
|
+
persistAccountSession(event, switched, session.accountId);
|
|
167
|
+
return { success: true, user };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `POST /auth/switch-account`: signs in as another stored account. */
|
|
171
|
+
export async function switchAccount(event: DmsServerEvent) {
|
|
172
|
+
const input = await event.readJson<Input>();
|
|
173
|
+
const account = readAccount(event, input.accountId);
|
|
174
|
+
if (!account)
|
|
175
|
+
return Response.json({ message: "Account expired" }, { status: 401 });
|
|
176
|
+
try {
|
|
177
|
+
const tokens = await event.backend<SessionTokens>("/api/auth/refresh", {
|
|
178
|
+
method: "POST",
|
|
179
|
+
body: { token: account.refreshToken },
|
|
180
|
+
});
|
|
181
|
+
const user = await event.backend<SessionTokens["user"]>("/api/auth/me", {
|
|
182
|
+
token: tokens.access_token,
|
|
183
|
+
});
|
|
184
|
+
const session = sessionFrom({ ...tokens, user }, isPersistent(account));
|
|
185
|
+
const descriptor = persistAccountSession(
|
|
186
|
+
event,
|
|
187
|
+
session,
|
|
188
|
+
input.accountId as string,
|
|
189
|
+
);
|
|
190
|
+
return { success: true, user, account: descriptor };
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (isRefusal(error)) removeAccount(event, input.accountId);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Keep the tokens a validation refreshed, without changing which account is
|
|
199
|
+
* signed in. Another account only has its own cookie updated. The current one
|
|
200
|
+
* also has the session updated, since a refresh may have rotated the token the
|
|
201
|
+
* session holds; it keeps its user as the backend gave it.
|
|
202
|
+
*/
|
|
203
|
+
function storeValidatedAccount(
|
|
204
|
+
event: DmsServerEvent,
|
|
205
|
+
account: StoredAccount,
|
|
206
|
+
accountId: string,
|
|
207
|
+
tokens: SessionTokens,
|
|
208
|
+
) {
|
|
209
|
+
const current = event.session.read();
|
|
210
|
+
if (current?.accountId === accountId) {
|
|
211
|
+
persistAccountSession(
|
|
212
|
+
event,
|
|
213
|
+
sessionFrom({ ...tokens, user: current.user }, isPersistent(current)),
|
|
214
|
+
accountId,
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const user = {
|
|
219
|
+
_id: account.userId,
|
|
220
|
+
email: account.email,
|
|
221
|
+
name: account.name,
|
|
222
|
+
};
|
|
223
|
+
storeAccount(
|
|
224
|
+
event,
|
|
225
|
+
sessionFrom({ ...tokens, user }, isPersistent(account)),
|
|
226
|
+
accountId,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `POST /auth/validate-account`: whether a stored account can still sign in,
|
|
232
|
+
* keeping the tokens the check refreshed. `null` when the backend could not
|
|
233
|
+
* tell.
|
|
234
|
+
*/
|
|
235
|
+
export async function validateAccount(event: DmsServerEvent) {
|
|
236
|
+
const input = await event.readJson<Input>();
|
|
237
|
+
const account = readAccount(event, input.accountId);
|
|
238
|
+
if (!account) return { valid: false };
|
|
239
|
+
try {
|
|
240
|
+
const tokens = await event.backend<SessionTokens>("/api/auth/refresh", {
|
|
241
|
+
method: "POST",
|
|
242
|
+
body: { token: account.refreshToken },
|
|
243
|
+
});
|
|
244
|
+
storeValidatedAccount(event, account, input.accountId as string, tokens);
|
|
245
|
+
return { valid: true };
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const rejected = isRefusal(error);
|
|
248
|
+
if (rejected) removeAccount(event, input.accountId);
|
|
249
|
+
return { valid: rejected ? false : null };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { DmsServerEvent } from "#dms/server";
|
|
3
|
+
import { privateSecret } from "../../../server/private-options";
|
|
4
|
+
import { persistAccountSession } from "./accounts";
|
|
5
|
+
import { type SessionTokens, sessionFrom } from "./session";
|
|
6
|
+
|
|
7
|
+
// The provider sign-in, on the frontend's origin: the backend builds the
|
|
8
|
+
// provider's URL and its callback (`relay.ts`, `config.ts`) and checks the
|
|
9
|
+
// relay header this server sends, from the DMS module's private options.
|
|
10
|
+
|
|
11
|
+
const FLOW = "dms_oauth_flow";
|
|
12
|
+
const HANDOFF = "dms_oauth_handoff";
|
|
13
|
+
const FLOW_PATH = "/auth/oauth";
|
|
14
|
+
const COMPLETE = "/auth/oauth/complete";
|
|
15
|
+
const SIGN_IN = "/auth";
|
|
16
|
+
const OAUTH_ERROR = "error.oauth.";
|
|
17
|
+
const FAILED = "error.oauth.failed";
|
|
18
|
+
const FLOW_LIFETIME_MS = 10 * 60 * 1000;
|
|
19
|
+
const HANDOFF_LIFETIME_S = 300;
|
|
20
|
+
const RATE_WINDOW_MS = 60 * 1000;
|
|
21
|
+
const RATE_LIMIT = 10;
|
|
22
|
+
const MAX_RATE_LIMIT_CLIENTS = 10_000;
|
|
23
|
+
const MAX_CALLBACK_PARAMETER_LENGTH = 2048;
|
|
24
|
+
const PROVIDER = /^[a-z0-9-]{1,32}$/;
|
|
25
|
+
|
|
26
|
+
interface RateWindow {
|
|
27
|
+
count: number;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface OAuthFlow {
|
|
32
|
+
state: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
redirect: string;
|
|
35
|
+
language: string;
|
|
36
|
+
invite: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface OAuthResult extends SessionTokens {
|
|
40
|
+
requires_2fa?: boolean;
|
|
41
|
+
requires_tenant_assignment?: boolean;
|
|
42
|
+
two_factor_token?: string;
|
|
43
|
+
tenant_assignment_token?: string;
|
|
44
|
+
methods?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Process-local, like the session refresh: one frontend process per sticky
|
|
48
|
+
// client. A development reload of this file starts it over.
|
|
49
|
+
const attempts = new Map<string, RateWindow>();
|
|
50
|
+
|
|
51
|
+
function sweepAttempts(now: number) {
|
|
52
|
+
for (const [key, window] of attempts)
|
|
53
|
+
if (now - window.startedAt >= RATE_WINDOW_MS) attempts.delete(key);
|
|
54
|
+
while (attempts.size >= MAX_RATE_LIMIT_CLIENTS)
|
|
55
|
+
attempts.delete(attempts.keys().next().value as string);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Counts an attempt for `key`; false once over the limit of the window. */
|
|
59
|
+
export function hitOAuthRateLimitKey(key: string, now = Date.now()): boolean {
|
|
60
|
+
sweepAttempts(now);
|
|
61
|
+
const window = attempts.get(key);
|
|
62
|
+
if (!window || now - window.startedAt >= RATE_WINDOW_MS) {
|
|
63
|
+
attempts.set(key, { count: 1, startedAt: now });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
window.count += 1;
|
|
67
|
+
return window.count <= RATE_LIMIT;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rateLimited(event: DmsServerEvent, scope: string): boolean {
|
|
71
|
+
return !hitOAuthRateLimitKey(`${scope}:${event.clientIp()}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isValidOAuthProvider(provider: unknown): provider is string {
|
|
75
|
+
return typeof provider === "string" && PROVIDER.test(provider);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safePath(value: string | null): string {
|
|
79
|
+
if (
|
|
80
|
+
typeof value !== "string" ||
|
|
81
|
+
!value.startsWith("/") ||
|
|
82
|
+
value.startsWith("//") ||
|
|
83
|
+
value.includes("\\")
|
|
84
|
+
)
|
|
85
|
+
return "";
|
|
86
|
+
try {
|
|
87
|
+
const decoded = decodeURIComponent(value);
|
|
88
|
+
return decoded.startsWith("//") || decoded.includes("\\") ? "" : value;
|
|
89
|
+
} catch {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function redirect(location: string): Response {
|
|
95
|
+
return new Response(null, { status: 302, headers: { location } });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function errorRedirect(key: string): Response {
|
|
99
|
+
return redirect(`${SIGN_IN}?oauth_error=${encodeURIComponent(key)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The backend's own OAuth key says why a provider sign-in failed; any other
|
|
103
|
+
// failure keeps the generic one.
|
|
104
|
+
function failureRedirect(error: unknown): Response {
|
|
105
|
+
const message = error instanceof Error ? error.message : "";
|
|
106
|
+
const status = (error as { status?: unknown }).status;
|
|
107
|
+
return errorRedirect(
|
|
108
|
+
typeof status === "number" && message.startsWith(OAUTH_ERROR)
|
|
109
|
+
? message
|
|
110
|
+
: FAILED,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function relayHeaders(event: DmsServerEvent): Record<string, string> {
|
|
115
|
+
const secret = privateSecret(
|
|
116
|
+
event,
|
|
117
|
+
"oauth",
|
|
118
|
+
"relaySecret",
|
|
119
|
+
"DMS_OAUTH_RELAY_SECRET",
|
|
120
|
+
);
|
|
121
|
+
return { "x-dms-oauth-relay": secret ?? "" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `GET /auth/oauth/:provider/start`: on to the provider's sign-in page. */
|
|
125
|
+
export async function oauthStart(event: DmsServerEvent): Promise<Response> {
|
|
126
|
+
const { provider } = event.params;
|
|
127
|
+
if (
|
|
128
|
+
!isValidOAuthProvider(provider) ||
|
|
129
|
+
event.request.headers.get("sec-fetch-site") === "cross-site" ||
|
|
130
|
+
rateLimited(event, "start")
|
|
131
|
+
)
|
|
132
|
+
return errorRedirect(FAILED);
|
|
133
|
+
try {
|
|
134
|
+
const result = await event.backend<{
|
|
135
|
+
state: string;
|
|
136
|
+
authorizeUrl: string;
|
|
137
|
+
}>(`/api/auth/oauth/${provider}/authorize-url`, {
|
|
138
|
+
headers: relayHeaders(event),
|
|
139
|
+
});
|
|
140
|
+
const query = event.url.searchParams;
|
|
141
|
+
event.cookies.writeSealed(
|
|
142
|
+
FLOW,
|
|
143
|
+
{
|
|
144
|
+
state: result.state,
|
|
145
|
+
expiresAt: Date.now() + FLOW_LIFETIME_MS,
|
|
146
|
+
redirect: safePath(query.get("redirect")),
|
|
147
|
+
language: query.get("language") ?? "",
|
|
148
|
+
invite: query.get("invite") ?? "",
|
|
149
|
+
} satisfies OAuthFlow,
|
|
150
|
+
{ maxAge: FLOW_LIFETIME_MS / 1000, path: FLOW_PATH },
|
|
151
|
+
);
|
|
152
|
+
return redirect(result.authorizeUrl);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return failureRedirect(error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sameState(left: unknown, right: unknown): boolean {
|
|
159
|
+
if (typeof left !== "string" || typeof right !== "string") return false;
|
|
160
|
+
const a = Buffer.from(left);
|
|
161
|
+
const b = Buffer.from(right);
|
|
162
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function validCallback(flow: OAuthFlow | undefined, query: URLSearchParams) {
|
|
166
|
+
const code = query.get("code");
|
|
167
|
+
const state = query.get("state");
|
|
168
|
+
return Boolean(
|
|
169
|
+
flow &&
|
|
170
|
+
flow.expiresAt >= Date.now() &&
|
|
171
|
+
typeof code === "string" &&
|
|
172
|
+
code.length <= MAX_CALLBACK_PARAMETER_LENGTH &&
|
|
173
|
+
typeof state === "string" &&
|
|
174
|
+
state.length <= MAX_CALLBACK_PARAMETER_LENGTH &&
|
|
175
|
+
sameState(state, flow.state),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function storeOAuthResult(
|
|
180
|
+
event: DmsServerEvent,
|
|
181
|
+
provider: string,
|
|
182
|
+
result: OAuthResult,
|
|
183
|
+
) {
|
|
184
|
+
const handoff = { maxAge: HANDOFF_LIFETIME_S, path: FLOW_PATH };
|
|
185
|
+
if (result.requires_2fa)
|
|
186
|
+
event.cookies.writeSealed(
|
|
187
|
+
HANDOFF,
|
|
188
|
+
{ kind: "2fa", token: result.two_factor_token, methods: result.methods },
|
|
189
|
+
handoff,
|
|
190
|
+
);
|
|
191
|
+
else if (result.requires_tenant_assignment)
|
|
192
|
+
event.cookies.writeSealed(
|
|
193
|
+
HANDOFF,
|
|
194
|
+
{
|
|
195
|
+
kind: "no-workspace",
|
|
196
|
+
token: result.tenant_assignment_token,
|
|
197
|
+
provider,
|
|
198
|
+
},
|
|
199
|
+
handoff,
|
|
200
|
+
);
|
|
201
|
+
else persistAccountSession(event, sessionFrom(result));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function completeOAuth(
|
|
205
|
+
event: DmsServerEvent,
|
|
206
|
+
provider: string,
|
|
207
|
+
flow: OAuthFlow,
|
|
208
|
+
): Promise<Response> {
|
|
209
|
+
const query = event.url.searchParams;
|
|
210
|
+
try {
|
|
211
|
+
const result = await event.backend<OAuthResult>(
|
|
212
|
+
`/api/auth/oauth/${provider}/callback`,
|
|
213
|
+
{
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: relayHeaders(event),
|
|
216
|
+
body: {
|
|
217
|
+
code: query.get("code"),
|
|
218
|
+
state: query.get("state"),
|
|
219
|
+
state_cookie: flow.state,
|
|
220
|
+
language: flow.language || undefined,
|
|
221
|
+
invite: flow.invite || undefined,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
storeOAuthResult(event, provider, result);
|
|
226
|
+
return redirect(
|
|
227
|
+
flow.redirect &&
|
|
228
|
+
!result.requires_2fa &&
|
|
229
|
+
!result.requires_tenant_assignment
|
|
230
|
+
? `${COMPLETE}?redirect=${encodeURIComponent(flow.redirect)}`
|
|
231
|
+
: COMPLETE,
|
|
232
|
+
);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return failureRedirect(error);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `GET /auth/oauth/:provider/callback`: back from the provider. */
|
|
239
|
+
export async function oauthCallback(event: DmsServerEvent): Promise<Response> {
|
|
240
|
+
const { provider } = event.params;
|
|
241
|
+
if (!isValidOAuthProvider(provider) || rateLimited(event, "callback"))
|
|
242
|
+
return errorRedirect(FAILED);
|
|
243
|
+
const flow = event.cookies.readSealed<OAuthFlow>(FLOW);
|
|
244
|
+
event.cookies.clear(FLOW, { path: FLOW_PATH });
|
|
245
|
+
const query = event.url.searchParams;
|
|
246
|
+
if (query.has("error")) return errorRedirect("error.oauth.cancelled");
|
|
247
|
+
if (!flow || !validCallback(flow, query))
|
|
248
|
+
return errorRedirect("error.oauth.invalid_state");
|
|
249
|
+
return completeOAuth(event, provider, flow);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `POST /auth/oauth/handoff`: what the provider sign-in left for the page to
|
|
254
|
+
* finish (a 2FA step, a workspace to pick), once.
|
|
255
|
+
*/
|
|
256
|
+
export function oauthHandoff(event: DmsServerEvent) {
|
|
257
|
+
const handoff = event.cookies.readSealed(HANDOFF) ?? { kind: "none" };
|
|
258
|
+
event.cookies.clear(HANDOFF, { path: FLOW_PATH });
|
|
259
|
+
return handoff;
|
|
260
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { DmsPublicSession, DmsServerEvent, DmsSession } from "#dms/server";
|
|
3
|
+
import { isPersistent, persistAccountSession, removeAccount } from "./accounts";
|
|
4
|
+
|
|
5
|
+
const REFRESH_TTL_MS = 15_000;
|
|
6
|
+
const REFUSED = [400, 401, 403];
|
|
7
|
+
|
|
8
|
+
/** A backend answer that opens a session: a token pair and the user. */
|
|
9
|
+
export interface SessionTokens {
|
|
10
|
+
access_token: string;
|
|
11
|
+
refresh_token?: string;
|
|
12
|
+
user?: DmsSession["user"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RefreshFlight {
|
|
16
|
+
promise: Promise<SessionTokens>;
|
|
17
|
+
expiresAt?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const refreshes = new Map<string, RefreshFlight>();
|
|
21
|
+
|
|
22
|
+
export function isRefusal(error: unknown): boolean {
|
|
23
|
+
const status = (error as { status?: unknown } | undefined)?.status;
|
|
24
|
+
return typeof status === "number" && REFUSED.includes(status);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The session a token pair opens.
|
|
29
|
+
*
|
|
30
|
+
* @param result Backend answer carrying the tokens and the user
|
|
31
|
+
* @param persistent Whether the session outlives the browser
|
|
32
|
+
*/
|
|
33
|
+
export function sessionFrom(
|
|
34
|
+
result: SessionTokens,
|
|
35
|
+
persistent = true,
|
|
36
|
+
): DmsSession {
|
|
37
|
+
const payload = JSON.parse(
|
|
38
|
+
Buffer.from(
|
|
39
|
+
result.access_token.split(".")[1] ?? "",
|
|
40
|
+
"base64url",
|
|
41
|
+
).toString() || "{}",
|
|
42
|
+
) as { tenantId?: string };
|
|
43
|
+
return {
|
|
44
|
+
user: result.user,
|
|
45
|
+
accessToken: result.access_token,
|
|
46
|
+
refreshToken: result.refresh_token,
|
|
47
|
+
activeTenantId: payload.tenantId ?? "default",
|
|
48
|
+
persistent,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a signed-in session shows the browser; the server adds `updatedAt`. */
|
|
53
|
+
export function publicSession(session: DmsSession): DmsPublicSession {
|
|
54
|
+
return {
|
|
55
|
+
user: session.user,
|
|
56
|
+
session: {
|
|
57
|
+
accountId: session.accountId,
|
|
58
|
+
activeTenantId: session.activeTenantId,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One backend refresh per refresh token at a time: the requests a page fires
|
|
65
|
+
* together after its access token expired share it, rather than spending the
|
|
66
|
+
* rotating refresh token several times over. Kept a while after it settles
|
|
67
|
+
* for the requests that were already on their way.
|
|
68
|
+
*/
|
|
69
|
+
function singleFlight(
|
|
70
|
+
token: string,
|
|
71
|
+
operation: () => Promise<SessionTokens>,
|
|
72
|
+
): Promise<SessionTokens> {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
for (const [key, entry] of refreshes)
|
|
75
|
+
if (entry.expiresAt && entry.expiresAt <= now) refreshes.delete(key);
|
|
76
|
+
const key = createHash("sha256").update(token).digest("base64url");
|
|
77
|
+
const current = refreshes.get(key);
|
|
78
|
+
if (current) return current.promise;
|
|
79
|
+
const entry: RefreshFlight = { promise: Promise.resolve().then(operation) };
|
|
80
|
+
refreshes.set(key, entry);
|
|
81
|
+
const retain = () => {
|
|
82
|
+
entry.expiresAt = Date.now() + REFRESH_TTL_MS;
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
if (refreshes.get(key) === entry) refreshes.delete(key);
|
|
85
|
+
}, REFRESH_TTL_MS).unref();
|
|
86
|
+
};
|
|
87
|
+
entry.promise.then(retain, retain);
|
|
88
|
+
return entry.promise;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Renews `session` with its refresh token, writes it and returns it; the rest
|
|
93
|
+
* of the request runs with it. A refused refresh signs the browser out.
|
|
94
|
+
* The frontend server calls it when the backend refuses a signed-in page
|
|
95
|
+
* visit, and `POST /api/_auth/session` when the browser asks.
|
|
96
|
+
*/
|
|
97
|
+
export async function renewSession(
|
|
98
|
+
event: DmsServerEvent,
|
|
99
|
+
session: DmsSession | undefined = event.session.read(),
|
|
100
|
+
): Promise<DmsSession | undefined> {
|
|
101
|
+
const refreshToken = session?.refreshToken;
|
|
102
|
+
if (!session || !refreshToken) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
const tokens = await singleFlight(refreshToken, () =>
|
|
105
|
+
event.backend<SessionTokens>("/api/auth/refresh", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
token: session.accessToken,
|
|
108
|
+
body: { token: refreshToken },
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const user = await event.backend<DmsSession["user"]>("/api/auth/me", {
|
|
112
|
+
token: tokens.access_token,
|
|
113
|
+
});
|
|
114
|
+
const refreshed = sessionFrom({ ...tokens, user }, isPersistent(session));
|
|
115
|
+
persistAccountSession(event, refreshed, session.accountId);
|
|
116
|
+
event.session.useForRequest(refreshed);
|
|
117
|
+
return refreshed;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (isRefusal(error)) event.session.clear();
|
|
120
|
+
event.session.useForRequest(undefined);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `GET /api/_auth/session`: the session as the browser sees it. */
|
|
126
|
+
export function readSessionRoute(event: DmsServerEvent) {
|
|
127
|
+
return event.session.view();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `POST /api/_auth/session`: renews the session, then shows it. */
|
|
131
|
+
export async function refreshSessionRoute(event: DmsServerEvent) {
|
|
132
|
+
await renewSession(event);
|
|
133
|
+
return event.session.view();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `DELETE /api/_auth/session`: signs out, on the backend too. */
|
|
137
|
+
export async function logoutRoute(event: DmsServerEvent) {
|
|
138
|
+
const session = event.session.read();
|
|
139
|
+
if (session?.refreshToken && session.accessToken)
|
|
140
|
+
await event
|
|
141
|
+
.backend("/api/auth/logout", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
token: session.accessToken,
|
|
144
|
+
body: { token: session.refreshToken },
|
|
145
|
+
})
|
|
146
|
+
.catch(() => {});
|
|
147
|
+
event.session.clear();
|
|
148
|
+
if (session?.accountId) removeAccount(event, session.accountId);
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Any other method on `/api/_auth/session`. */
|
|
153
|
+
export function sessionMethodNotAllowed() {
|
|
154
|
+
return Response.json(
|
|
155
|
+
{ error: "Method Not Allowed" },
|
|
156
|
+
{ status: 405, headers: { allow: "GET, POST, DELETE" } },
|
|
157
|
+
);
|
|
158
|
+
}
|