@michaelthielemann/kestrel 1.2.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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- package/scripts/typecheck.mjs +51 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Grant, Role } from './policy'
|
|
2
|
+
|
|
3
|
+
// A process-singleton registry of EXTRA authorization grants, contributed by layers/extensions at boot
|
|
4
|
+
// (server plugin) on top of the hardcoded `POLICY`. This is the seam that lets an opt-in layer deliberately
|
|
5
|
+
// open a narrow hole in the default-deny guard — e.g. the proofing extension granting `anonymous → write →
|
|
6
|
+
// proofing` for its public back-channel. Inert until something registers: with an empty registry the guard
|
|
7
|
+
// behaves exactly as the hardcoded policy. The map is INJECTED into the (pure) guard, not read from inside
|
|
8
|
+
// it (symmetric with `publicResources`), so the policy stays deterministically node-testable. Foundation
|
|
9
|
+
// for a future configurable Users & Roles system.
|
|
10
|
+
const registered: Partial<Record<Role, Grant[]>> = {}
|
|
11
|
+
|
|
12
|
+
/** Add an extra grant for a role. Deliberate + explicit — it widens what that role may do. */
|
|
13
|
+
export function registerAccessGrant(role: Role, grant: Grant): void {
|
|
14
|
+
// Defense-in-depth: the public (anonymous) must never be handed a wildcard or draft-read through this
|
|
15
|
+
// seam — a legitimate public back-channel always names ONE specific resource and reads published-only.
|
|
16
|
+
// CRUCIAL: the evaluator (`resolveAccess`) defaults an OMITTED read scope to 'all' via `(scope ?? 'all')`,
|
|
17
|
+
// so an unscoped anonymous read would silently resolve to draft-read. The validator must use the SAME
|
|
18
|
+
// default, or a "safe"-looking `{action:'read', resource:'x'}` grant leaks drafts to anonymous visitors.
|
|
19
|
+
// (Write grants carry no read scope — `resolveAccess` only upgrades readScope for read grants — so an
|
|
20
|
+
// omitted scope on a write is fine, e.g. the proofing back-channel's anonymous write.)
|
|
21
|
+
const anonymousDraftRead = role === 'anonymous' && grant.action === 'read' && (grant.scope ?? 'all') === 'all'
|
|
22
|
+
if (role === 'anonymous' && (grant.resource === '*' || anonymousDraftRead)) {
|
|
23
|
+
throw new Error(`refusing an over-broad anonymous grant (${grant.action} ${grant.resource}${grant.scope ? ` scope=${grant.scope}` : ''}): name a specific resource and grant read with an explicit scope: 'published' (never draft-read)`)
|
|
24
|
+
}
|
|
25
|
+
(registered[role] ??= []).push(grant)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The full role → extra-grants map, for the guard to consult alongside `POLICY`. */
|
|
29
|
+
export function registeredGrants(): Readonly<Partial<Record<Role, Grant[]>>> {
|
|
30
|
+
return registered
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Test helper: drop all registered grants (the registry is a module singleton). */
|
|
34
|
+
export function clearAccessGrants(): void {
|
|
35
|
+
for (const role of Object.keys(registered) as Role[]) delete registered[role]
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isCrossSiteWrite, type CsrfHeaders } from './csrf'
|
|
2
|
+
import { verifySession } from '../../../auth/server/utils/session'
|
|
3
|
+
import { resolveAccess, actionForMethod, resourceForPath, isBootstrapPath, isPublicRenderPath, type Principal, type Grant, type Role } from './policy'
|
|
4
|
+
|
|
5
|
+
export interface AccessInput {
|
|
6
|
+
method: string
|
|
7
|
+
path: string
|
|
8
|
+
csrf: CsrfHeaders
|
|
9
|
+
cookie: string | undefined
|
|
10
|
+
secret: string
|
|
11
|
+
nowMs: number
|
|
12
|
+
isPrerender: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AccessDecision =
|
|
16
|
+
| { allow: true; principal: Principal; readScope: 'published' | 'all' }
|
|
17
|
+
| { allow: false; status: number; message: string }
|
|
18
|
+
|
|
19
|
+
export function derivePrincipal(input: AccessInput): Principal {
|
|
20
|
+
if (input.isPrerender) return { userId: 'renderer', role: 'renderer' }
|
|
21
|
+
if (verifySession(input.secret, input.cookie, input.nowMs).valid) return { userId: 'admin', role: 'admin' }
|
|
22
|
+
return { userId: null, role: 'anonymous' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function evaluateAccess(input: AccessInput, publicResources: readonly string[] = [], registeredGrants: Readonly<Partial<Record<Role, Grant[]>>> = {}): AccessDecision {
|
|
26
|
+
// CSRF first: every unsafe method (incl. login) must be same-site.
|
|
27
|
+
if (actionForMethod(input.method) === 'write' && isCrossSiteWrite(input.csrf)) {
|
|
28
|
+
return { allow: false, status: 403, message: 'Cross-origin write rejected' }
|
|
29
|
+
}
|
|
30
|
+
const principal = derivePrincipal(input)
|
|
31
|
+
// Auth bootstrap (login/session) is always permitted so the SPA can authenticate. Scope still follows
|
|
32
|
+
// the principal (mirrors isPublicRenderPath below) — an anonymous caller never gets draft-read scope.
|
|
33
|
+
if (isBootstrapPath(input.method, input.path)) {
|
|
34
|
+
return { allow: true, principal, readScope: principal.role === 'anonymous' ? 'published' : 'all' }
|
|
35
|
+
}
|
|
36
|
+
// The public render entry is readable by everyone; the scope follows the principal so an admin can
|
|
37
|
+
// preview drafts while anonymous (and the static render) stay published-only.
|
|
38
|
+
if (isPublicRenderPath(input.method, input.path)) {
|
|
39
|
+
return { allow: true, principal, readScope: principal.role === 'anonymous' ? 'published' : 'all' }
|
|
40
|
+
}
|
|
41
|
+
const { allowed, readScope } = resolveAccess(principal, actionForMethod(input.method), resourceForPath(input.path), publicResources, registeredGrants[principal.role] ?? [])
|
|
42
|
+
if (!allowed) return { allow: false, status: 401, message: 'Authentication required' }
|
|
43
|
+
return { allow: true, principal, readScope }
|
|
44
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type AllowlistMode = 'enforce' | 'log' | 'off'
|
|
2
|
+
|
|
3
|
+
/** A parsed IPv4 CIDR as `network base & mask`, both uint32. */
|
|
4
|
+
export interface Cidr {
|
|
5
|
+
base: number
|
|
6
|
+
mask: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the gate mode from `KESTREL_IP_ALLOWLIST_MODE` given the raw list.
|
|
11
|
+
*
|
|
12
|
+
* An empty list means there is nothing to enforce → `off`. When a list IS present, an unset/unknown mode
|
|
13
|
+
* defaults to `enforce`: a configured allow-list that silently let everyone through is the exact legacy
|
|
14
|
+
* footgun we are replacing. `log` is the opt-in calibration mode (observe, never block) used to confirm the
|
|
15
|
+
* resolved client IP + `KESTREL_TRUST_PROXY` depth before enforcing.
|
|
16
|
+
*/
|
|
17
|
+
export function allowlistMode(modeRaw: string | undefined, listRaw: string | undefined): AllowlistMode {
|
|
18
|
+
if (!listRaw || !listRaw.trim()) return 'off'
|
|
19
|
+
const m = (modeRaw ?? '').trim().toLowerCase()
|
|
20
|
+
if (m === 'off' || m === 'disabled' || m === 'false') return 'off'
|
|
21
|
+
if (m === 'log' || m === 'log-only' || m === 'dry-run') return 'log'
|
|
22
|
+
return 'enforce'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `1.2.3.4` / `::ffff:1.2.3.4` → uint32; `null` for out-of-range, real IPv6, or non-IPv4 input. */
|
|
26
|
+
export function ipv4ToInt(ip: string): number | null {
|
|
27
|
+
let s = ip.trim()
|
|
28
|
+
const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(s)
|
|
29
|
+
if (mapped) s = mapped[1]!
|
|
30
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s)
|
|
31
|
+
if (!m) return null
|
|
32
|
+
let n = 0
|
|
33
|
+
for (let i = 1; i <= 4; i++) {
|
|
34
|
+
const octet = Number(m[i])
|
|
35
|
+
if (octet > 255) return null
|
|
36
|
+
n = n * 256 + octet
|
|
37
|
+
}
|
|
38
|
+
return n >>> 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseCidr(token: string): Cidr | null {
|
|
42
|
+
const slash = token.indexOf('/')
|
|
43
|
+
const base = ipv4ToInt(slash === -1 ? token : token.slice(0, slash))
|
|
44
|
+
if (base === null) return null
|
|
45
|
+
const bits = slash === -1 ? 32 : Number(token.slice(slash + 1))
|
|
46
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null
|
|
47
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
|
48
|
+
return { base: (base & mask) >>> 0, mask }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse the allow-list env into CIDRs, tolerant of the legacy nginx block so it can be pasted verbatim:
|
|
53
|
+
* newline/comma/semicolon separated, `# comments` and a leading `allow`/`set_real_ip_from` keyword stripped.
|
|
54
|
+
* Invalid tokens are dropped, not thrown — one bad line must not disable the whole gate. `onReject`, when
|
|
55
|
+
* given, is called with each dropped token (this parser is IPv4-only, so an IPv6 CIDR pasted into an
|
|
56
|
+
* otherwise-valid list is silently a no-op unless the caller surfaces it).
|
|
57
|
+
*/
|
|
58
|
+
export function parseAllowlist(raw: string | undefined, onReject?: (token: string) => void): Cidr[] {
|
|
59
|
+
if (!raw) return []
|
|
60
|
+
const out: Cidr[] = []
|
|
61
|
+
for (const part of raw.split(/[\n,;]+/)) {
|
|
62
|
+
const token = part.replace(/#.*$/, '').trim().replace(/^(?:allow|set_real_ip_from)\s+/i, '').trim()
|
|
63
|
+
if (!token) continue
|
|
64
|
+
const cidr = parseCidr(token)
|
|
65
|
+
if (cidr) out.push(cidr)
|
|
66
|
+
else onReject?.(token)
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when `ip` falls in any CIDR. Fails closed: a non-IPv4 address or empty list never matches. */
|
|
72
|
+
export function ipAllowed(ip: string, cidrs: Cidr[]): boolean {
|
|
73
|
+
const n = ipv4ToInt(ip)
|
|
74
|
+
if (n === null) return false
|
|
75
|
+
return cidrs.some((c) => ((n & c.mask) >>> 0) === c.base)
|
|
76
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export type Role = 'admin' | 'renderer' | 'anonymous'
|
|
2
|
+
export type Action = 'read' | 'write'
|
|
3
|
+
export interface Principal { userId: string | null; role: Role }
|
|
4
|
+
export interface Grant { action: Action; resource: string; scope?: 'published' | 'all' }
|
|
5
|
+
export interface AccessResult { allowed: boolean; readScope: 'published' | 'all' }
|
|
6
|
+
|
|
7
|
+
/** Hardcoded authorization policy. Role -> grants. `resource: '*'` matches any. */
|
|
8
|
+
export const POLICY: Record<Role, Grant[]> = {
|
|
9
|
+
admin: [{ action: 'write', resource: '*' }, { action: 'read', resource: '*' }],
|
|
10
|
+
// The renderer is ONLY ever the build-time prerender or the runtime publisher; both produce the
|
|
11
|
+
// public static site, which is published-only by definition. Scope its read to 'published' so the
|
|
12
|
+
// generic /api/<collection> reads never leak drafts into the generated HTML (admin live preview is
|
|
13
|
+
// unaffected — it renders under the admin principal, scope 'all').
|
|
14
|
+
renderer: [{ action: 'read', resource: '*', scope: 'published' }],
|
|
15
|
+
// Anonymous published-read is data-driven from the registry (every page-like collection), not a
|
|
16
|
+
// literal allow-list — see the `publicResources` branch in `resolveAccess`.
|
|
17
|
+
anonymous: [],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveAccess(principal: Principal, action: Action, resource: string, publicResources: readonly string[] = [], extraGrants: readonly Grant[] = []): AccessResult {
|
|
21
|
+
let allowed = false
|
|
22
|
+
// readScope reflects the principal's READ permission for this resource, INDEPENDENT of the requested
|
|
23
|
+
// action, and is fail-closed to 'published': a granted WRITE must never silently confer draft-read
|
|
24
|
+
// ('all'). Only an explicit read grant with scope 'all' (i.e. admin) upgrades it.
|
|
25
|
+
let readScope: 'published' | 'all' = 'published'
|
|
26
|
+
// Hardcoded policy first, then any registry-contributed grants for this role (the opt-in seam). Extra
|
|
27
|
+
// grants only ever ADD permissions for the role they were registered under — they can't revoke.
|
|
28
|
+
for (const g of [...(POLICY[principal.role] ?? []), ...extraGrants]) {
|
|
29
|
+
if (g.resource !== '*' && g.resource !== resource) continue
|
|
30
|
+
if (g.action === action) allowed = true
|
|
31
|
+
if (g.action === 'read' && (g.scope ?? 'all') === 'all') readScope = 'all' // a draft-read grant wins
|
|
32
|
+
}
|
|
33
|
+
// An anonymous visitor gets published read on every page-like collection (the registry-driven public
|
|
34
|
+
// set), so a consumer's own pageLike collection is reachable without editing a literal allow-list.
|
|
35
|
+
// Published-only and gated to the public set — never widens to drafts or non-pageLike collections.
|
|
36
|
+
if (!allowed && principal.role === 'anonymous' && action === 'read' && publicResources.includes(resource)) {
|
|
37
|
+
allowed = true
|
|
38
|
+
readScope = 'published'
|
|
39
|
+
}
|
|
40
|
+
return { allowed, readScope }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether an anonymous visitor may read a collection — i.e. whether its pages are publicly reachable.
|
|
45
|
+
* The single source of truth for "what belongs in a public sitemap": a sitemap must list only URLs an
|
|
46
|
+
* unauthenticated crawler can actually fetch, so the sitemap filters pageLike collections through this.
|
|
47
|
+
*/
|
|
48
|
+
export function isPubliclyReadable(resource: string, publicResources: readonly string[] = []): boolean {
|
|
49
|
+
return resolveAccess({ userId: null, role: 'anonymous' }, 'read', resource, publicResources).allowed
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a read at this `readScope` must be limited to published rows. Fail-CLOSED: only the explicit
|
|
54
|
+
* `'all'` scope (admin / live preview) sees drafts; anything else — `'published'`, an unexpected value,
|
|
55
|
+
* or a missing scope (a guard regression) — stays published-only. Mirrors the public render path so the
|
|
56
|
+
* two read entry points can't drift to opposite defaults.
|
|
57
|
+
*/
|
|
58
|
+
export function publishedOnlyForScope(readScope: string | undefined): boolean {
|
|
59
|
+
return readScope !== 'all'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
63
|
+
export function actionForMethod(method: string): Action {
|
|
64
|
+
return SAFE_METHODS.has(method.toUpperCase()) ? 'read' : 'write'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resource for an /api path: the collection segment, except the tooling sub-routes
|
|
69
|
+
* (`/options`, `/translations`, `/:id/translations`, `/:id/dead-refs`) map to a distinct
|
|
70
|
+
* `<collection>/<tool>` resource so the public content grant (read on the bare collection) never covers
|
|
71
|
+
* admin-only tooling — a pageLike collection's `translations` / `dead-refs` maps must NOT be readable by
|
|
72
|
+
* anonymous visitors. The group-keyed and per-record translations routes share one resource: they expose
|
|
73
|
+
* the same sibling ids (drafts included), so they must be gated identically.
|
|
74
|
+
*/
|
|
75
|
+
export function resourceForPath(path: string): string {
|
|
76
|
+
const segs = (path.split('?')[0] ?? '').split('/').filter(Boolean)
|
|
77
|
+
if (segs[0] !== 'api' || !segs[1]) return ''
|
|
78
|
+
if (segs[2] === 'options' || segs[2] === 'translations') return `${segs[1]}/${segs[2]}`
|
|
79
|
+
if (segs[3] === 'translations' || segs[3] === 'dead-refs') return `${segs[1]}/${segs[3]}`
|
|
80
|
+
return segs[1]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Auth bootstrap endpoints, always permitted so the SPA can authenticate under default-deny. */
|
|
84
|
+
export function isBootstrapPath(method: string, path: string): boolean {
|
|
85
|
+
const clean = path.split('?')[0]
|
|
86
|
+
const m = method.toUpperCase()
|
|
87
|
+
return (m === 'POST' && clean === '/api/auth/login') || (m === 'GET' && clean === '/api/auth/session')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The public render entry (`GET /api/route`) the live page resolver calls. Readable by everyone — it is
|
|
92
|
+
* the runtime equivalent of serving a static page — but the read SCOPE follows the principal: anonymous
|
|
93
|
+
* gets published-only, an authenticated admin gets 'all' so the handler can surface a draft preview.
|
|
94
|
+
* The handler itself re-forces published-only for any static-generation render (prerender / publisher).
|
|
95
|
+
*/
|
|
96
|
+
export function isPublicRenderPath(method: string, path: string): boolean {
|
|
97
|
+
return method.toUpperCase() === 'GET' && path.split('?')[0] === '/api/route'
|
|
98
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { allCollections } from '../../../core/server/utils/registry'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collections an anonymous visitor may read (published only): every page-like collection. The single,
|
|
5
|
+
* registry-driven source for public reachability — consumed by the access guard (runtime serving) and
|
|
6
|
+
* the sitemap (which URLs to advertise), so a consumer's own pageLike collection is public without
|
|
7
|
+
* editing a literal allow-list. Page-like ⟺ has a `path`, i.e. it is routable to a static URL.
|
|
8
|
+
*/
|
|
9
|
+
export function publicReadableResources(): string[] {
|
|
10
|
+
return allCollections().filter((c) => c.def.pageLike).map((c) => c.def.name)
|
|
11
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Marks an in-process request chain as the runtime publisher's render, so the access guard grants it the
|
|
5
|
+
* same `renderer` principal that build-time prerender gets. The publisher's render does a nested
|
|
6
|
+
* `$fetch('/api/route')`, which is NOT a public endpoint; without this it would 401 at runtime. Set via
|
|
7
|
+
* AsyncLocalStorage — never a header — so an external HTTP request can't forge renderer access (and
|
|
8
|
+
* resolvePage still enforces published-only, so drafts never reach the output regardless).
|
|
9
|
+
*/
|
|
10
|
+
const ctx = new AsyncLocalStorage<true>()
|
|
11
|
+
|
|
12
|
+
/** Run a publish render (and its nested API fetches) as the renderer principal. */
|
|
13
|
+
export function runAsRenderer<T>(fn: () => T): T {
|
|
14
|
+
return ctx.run(true, fn)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True when executing inside a publisher render, so the access guard grants the renderer principal. */
|
|
18
|
+
export function isRendererContext(): boolean {
|
|
19
|
+
return ctx.getStore() === true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stageGate = new AsyncLocalStorage<true>()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Marks the current request's async context as classified by the stage IP gate, so the in-process
|
|
26
|
+
* sub-requests it spawns (the SSR page's own `$fetch`, the editor preview) are not gated again — they
|
|
27
|
+
* reach the middleware stack on a mocked socket with no peer address and could never match a CIDR.
|
|
28
|
+
*
|
|
29
|
+
* `enterWith` rather than `run` because a middleware has no handle on the rest of its own request; the
|
|
30
|
+
* mark is therefore reachable only from work descending from a request the gate already admitted, which
|
|
31
|
+
* is what makes it unforgeable — unlike "this request has no socket peer", a property whole listeners
|
|
32
|
+
* (unix domain sockets) have for every external client.
|
|
33
|
+
*/
|
|
34
|
+
export function markStageGatePassed(): void {
|
|
35
|
+
stageGate.enterWith(true)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True inside a request whose originating request was already classified by the stage IP gate. */
|
|
39
|
+
export function isStageGatePassedContext(): boolean {
|
|
40
|
+
return stageGate.getStore() === true
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createError, type H3Event } from 'h3'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-handler write-authorization backstop. The access-guard middleware is the PRIMARY enforcement, but
|
|
5
|
+
* it is gated on an `/api/` path-prefix string and is the only check — a future mutating route mounted
|
|
6
|
+
* outside `/api/` (a `server/routes/*` action, a webhook), or any guard-vs-router path divergence, would
|
|
7
|
+
* otherwise run unauthenticated. Every mutating handler calls this so authorization never depends solely
|
|
8
|
+
* on that prefix heuristic. Throws 401 unless the resolved principal is the admin (the only role with
|
|
9
|
+
* write grants — `renderer` is read-only, `anonymous` has no grants).
|
|
10
|
+
*/
|
|
11
|
+
export function requireAdmin(event: H3Event): void {
|
|
12
|
+
if (event.context.principal?.role !== 'admin') {
|
|
13
|
+
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent,
|
|
4
|
+
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuRadioGroup, DropdownMenuRadioItem,
|
|
5
|
+
DropdownMenuItemIndicator, DropdownMenuItem,
|
|
6
|
+
} from 'reka-ui'
|
|
7
|
+
|
|
8
|
+
// The rail's pseudo-account area: an avatar that opens an account menu (UI-language + sign out).
|
|
9
|
+
const { t, lang } = useT()
|
|
10
|
+
const { logout } = useAuth()
|
|
11
|
+
|
|
12
|
+
// Single-user admin for now — static initials. A natural seam for a real display name later.
|
|
13
|
+
const initials = 'AD'
|
|
14
|
+
|
|
15
|
+
// reka's DropdownMenuRadioGroup emits the broad `AcceptableValue`; accept `unknown` and coerce to our lang.
|
|
16
|
+
function selectLang(value: unknown) { lang.value = value as string }
|
|
17
|
+
function signOut() { logout() }
|
|
18
|
+
|
|
19
|
+
// Exposed for the component test: the menu itself is teleported (not rendered by happy-dom), so the
|
|
20
|
+
// load-bearing handlers are asserted directly.
|
|
21
|
+
defineExpose({ lang, selectLang, signOut })
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<DropdownMenuRoot>
|
|
26
|
+
<DropdownMenuTrigger as-child>
|
|
27
|
+
<button type="button" class="rail-account__trigger" :aria-label="t('account.label')" :title="t('account.label')">
|
|
28
|
+
<span class="rail-account__avatar" aria-hidden="true">{{ initials }}</span>
|
|
29
|
+
<span class="rail-account__name rail__label">{{ t('account.name') }}</span>
|
|
30
|
+
<UiIcon name="chevron-down" class="rail-account__caret rail__label" size="1rem" />
|
|
31
|
+
</button>
|
|
32
|
+
</DropdownMenuTrigger>
|
|
33
|
+
|
|
34
|
+
<DropdownMenuPortal>
|
|
35
|
+
<DropdownMenuContent
|
|
36
|
+
class="rail-account__menu"
|
|
37
|
+
side="top"
|
|
38
|
+
align="start"
|
|
39
|
+
:side-offset="6"
|
|
40
|
+
:collision-padding="8"
|
|
41
|
+
>
|
|
42
|
+
<DropdownMenuLabel class="rail-account__head">
|
|
43
|
+
<span class="rail-account__avatar rail-account__avatar--sm" aria-hidden="true">{{ initials }}</span>
|
|
44
|
+
<span class="rail-account__head-name">{{ t('account.name') }}</span>
|
|
45
|
+
</DropdownMenuLabel>
|
|
46
|
+
|
|
47
|
+
<DropdownMenuSeparator class="rail-account__sep" />
|
|
48
|
+
|
|
49
|
+
<DropdownMenuLabel class="rail-account__group-label">{{ t('lang.label') }}</DropdownMenuLabel>
|
|
50
|
+
<DropdownMenuRadioGroup :model-value="lang" @update:model-value="selectLang">
|
|
51
|
+
<DropdownMenuRadioItem
|
|
52
|
+
v-for="l in ADMIN_LANGS"
|
|
53
|
+
:key="l"
|
|
54
|
+
:value="l"
|
|
55
|
+
class="rail-account__item"
|
|
56
|
+
>
|
|
57
|
+
<span class="rail-account__check">
|
|
58
|
+
<DropdownMenuItemIndicator>
|
|
59
|
+
<UiIcon name="check" size="1rem" />
|
|
60
|
+
</DropdownMenuItemIndicator>
|
|
61
|
+
</span>
|
|
62
|
+
<span>{{ l.toUpperCase() }}</span>
|
|
63
|
+
</DropdownMenuRadioItem>
|
|
64
|
+
</DropdownMenuRadioGroup>
|
|
65
|
+
|
|
66
|
+
<DropdownMenuSeparator class="rail-account__sep" />
|
|
67
|
+
|
|
68
|
+
<DropdownMenuItem class="rail-account__item rail-account__item--danger" @select="signOut">
|
|
69
|
+
<UiIcon name="log-out" size="1rem" class="rail-account__item-icon" />
|
|
70
|
+
<span>{{ t('nav.signOut') }}</span>
|
|
71
|
+
</DropdownMenuItem>
|
|
72
|
+
</DropdownMenuContent>
|
|
73
|
+
</DropdownMenuPortal>
|
|
74
|
+
</DropdownMenuRoot>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<style lang="scss">
|
|
78
|
+
// Not scoped: Reka teleports DropdownMenuContent to <body>, where a scoped data-v selector no longer
|
|
79
|
+
// matches (same reason UiMenu's styles are global). The trigger lives in the rail; keeping the whole
|
|
80
|
+
// component's styles global is simplest and consistent with the other portaled overlays.
|
|
81
|
+
.rail-account__trigger {
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
gap: var(--space-3);
|
|
85
|
+
width: 100%;
|
|
86
|
+
padding: var(--space-2) var(--space-3);
|
|
87
|
+
margin-bottom: var(--space-1);
|
|
88
|
+
border: 0;
|
|
89
|
+
border-radius: var(--radius-md);
|
|
90
|
+
background: transparent;
|
|
91
|
+
color: var(--color-text);
|
|
92
|
+
font: inherit;
|
|
93
|
+
font-size: var(--text-sm);
|
|
94
|
+
text-align: left;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
|
|
97
|
+
&:hover {
|
|
98
|
+
background: var(--color-hover);
|
|
99
|
+
}
|
|
100
|
+
&:focus-visible {
|
|
101
|
+
outline: 2px solid var(--color-focus);
|
|
102
|
+
outline-offset: -2px;
|
|
103
|
+
}
|
|
104
|
+
&[data-state='open'] {
|
|
105
|
+
background: var(--color-active, var(--color-surface-2));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.rail-account__avatar {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: center;
|
|
113
|
+
flex-shrink: 0;
|
|
114
|
+
width: 1.75rem;
|
|
115
|
+
height: 1.75rem;
|
|
116
|
+
border-radius: var(--radius-full);
|
|
117
|
+
border: 1px solid var(--color-border-strong, var(--color-border));
|
|
118
|
+
background: var(--color-surface-2);
|
|
119
|
+
color: var(--color-text);
|
|
120
|
+
font-size: var(--text-xs, 0.75rem);
|
|
121
|
+
font-weight: var(--weight-bold);
|
|
122
|
+
letter-spacing: 0.02em;
|
|
123
|
+
|
|
124
|
+
&--sm {
|
|
125
|
+
width: 1.5rem;
|
|
126
|
+
height: 1.5rem;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.rail-account__name {
|
|
131
|
+
font-weight: var(--weight-medium);
|
|
132
|
+
}
|
|
133
|
+
.rail-account__caret {
|
|
134
|
+
margin-left: auto; // push the chevron to the right edge of the full-width trigger
|
|
135
|
+
flex-shrink: 0;
|
|
136
|
+
color: var(--color-text-muted);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Menu surface — mirrors .ui-menu so the account dropdown reads like the rest of the admin chrome.
|
|
140
|
+
.rail-account__menu {
|
|
141
|
+
min-width: 13rem;
|
|
142
|
+
background: var(--color-surface);
|
|
143
|
+
border: 1px solid var(--color-border);
|
|
144
|
+
border-radius: var(--radius-md);
|
|
145
|
+
box-shadow: var(--shadow-md);
|
|
146
|
+
padding: var(--space-1);
|
|
147
|
+
z-index: var(--z-dropdown);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.rail-account__head {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: var(--space-2);
|
|
154
|
+
padding: var(--space-2) var(--space-2);
|
|
155
|
+
}
|
|
156
|
+
.rail-account__head-name {
|
|
157
|
+
font-size: var(--text-sm);
|
|
158
|
+
font-weight: var(--weight-medium);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.rail-account__group-label {
|
|
162
|
+
padding: var(--space-1) var(--space-2);
|
|
163
|
+
font-size: var(--text-xs, 0.75rem);
|
|
164
|
+
font-weight: var(--weight-medium);
|
|
165
|
+
color: var(--color-text-muted);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.rail-account__sep {
|
|
169
|
+
height: 1px;
|
|
170
|
+
margin: var(--space-1) calc(var(--space-1) * -1);
|
|
171
|
+
background: var(--color-border);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.rail-account__item {
|
|
175
|
+
display: flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
gap: var(--space-2);
|
|
178
|
+
width: 100%;
|
|
179
|
+
padding: var(--space-1) var(--space-2);
|
|
180
|
+
border-radius: var(--radius-sm);
|
|
181
|
+
font-size: var(--text-sm);
|
|
182
|
+
color: var(--color-text);
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
user-select: none;
|
|
185
|
+
|
|
186
|
+
&[data-highlighted] {
|
|
187
|
+
background: var(--color-hover);
|
|
188
|
+
outline: none;
|
|
189
|
+
}
|
|
190
|
+
&--danger {
|
|
191
|
+
color: var(--color-danger);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.rail-account__check {
|
|
196
|
+
display: inline-flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
justify-content: center;
|
|
199
|
+
width: 1rem;
|
|
200
|
+
flex-shrink: 0;
|
|
201
|
+
color: var(--color-primary);
|
|
202
|
+
}
|
|
203
|
+
.rail-account__item-icon {
|
|
204
|
+
flex-shrink: 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Collapsed rail: drop the label + caret and centre the avatar (mirrors .rail__item's collapsed rule).
|
|
208
|
+
.admin--rail-collapsed .rail-account__trigger {
|
|
209
|
+
justify-content: center;
|
|
210
|
+
gap: 0;
|
|
211
|
+
}
|
|
212
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
3
|
+
|
|
4
|
+
const { t, lang } = useT()
|
|
5
|
+
const { collections, load } = useCollections()
|
|
6
|
+
const { collapsed } = useRailCollapsed()
|
|
7
|
+
const route = useRoute()
|
|
8
|
+
try {
|
|
9
|
+
await load()
|
|
10
|
+
} catch {
|
|
11
|
+
// Nav is non-critical chrome; a failed fetch leaves it empty rather than breaking the layout.
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<nav class="admin-nav" :aria-label="t('nav.collections')">
|
|
17
|
+
<NuxtLink
|
|
18
|
+
v-for="c in (collections ?? []).filter((c) => c.nav !== false)"
|
|
19
|
+
:key="c.name"
|
|
20
|
+
:to="`/admin/${c.name}`"
|
|
21
|
+
class="admin-nav__link rail__item"
|
|
22
|
+
:class="{ 'router-link-active': isNavItemActive(route.path, `/admin/${c.name}`) }"
|
|
23
|
+
:title="collapsed ? (resolveLocalized(c.label?.plural, lang) ?? c.name) : undefined"
|
|
24
|
+
>
|
|
25
|
+
<UiIcon :name="c.icon ?? 'file-text'" class="rail__icon" size="1.25rem" />
|
|
26
|
+
<span class="rail__label">{{ resolveLocalized(c.label?.plural, lang) ?? c.name }}</span>
|
|
27
|
+
</NuxtLink>
|
|
28
|
+
</nav>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<style lang="scss">
|
|
32
|
+
.admin-nav {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: var(--space-1);
|
|
36
|
+
|
|
37
|
+
// Layout, colour and active state come from the shared .rail__item (admin layout).
|
|
38
|
+
// Only the cosmetic capitalisation is nav-specific — kept CSS-only so the rendered
|
|
39
|
+
// text node stays the raw collection name (e.g. "settings").
|
|
40
|
+
&__link {
|
|
41
|
+
text-transform: capitalize;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
|
|
4
|
+
import type { LayoutNode } from '../../../core/server/utils/field-layout'
|
|
5
|
+
import type { BlockRow } from '../utils/block-tree'
|
|
6
|
+
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
7
|
+
|
|
8
|
+
// The fields pane for the currently selected block: delegates to the shared <FieldLayout> renderer,
|
|
9
|
+
// driven by the selected block's props. `def` is the selected block's resolved definition (the parent
|
|
10
|
+
// already has it). Errors are the id-keyed BlockErrorMap entry for this block (depth-independent); edits
|
|
11
|
+
// emit (key, value) for an id-addressed setProp on the shared tree.
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
block: BlockRow
|
|
14
|
+
def?: SerializedBlock
|
|
15
|
+
locale: string
|
|
16
|
+
errors?: Record<string, string>
|
|
17
|
+
/** Field keys in this block that hold a stale reference — shown as a non-blocking warning note. */
|
|
18
|
+
deadFields?: Set<string>
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
}>()
|
|
21
|
+
const emit = defineEmits<{ update: [key: string, value: unknown] }>()
|
|
22
|
+
|
|
23
|
+
const { t, lang } = useT()
|
|
24
|
+
const subFields = computed(() =>
|
|
25
|
+
Object.fromEntries(Object.entries(props.def?.fields ?? {}).map(([k, v]) => [k, asFieldDef(v)])),
|
|
26
|
+
)
|
|
27
|
+
// Block-level `fieldLayout` authoring is deferred (v1 ships collections + repeaters); the render path is
|
|
28
|
+
// already layout-ready via this cast, so a future serializeBlock that carries a layout flows through with
|
|
29
|
+
// no further change. One documented cast, same category as `asFieldDef`.
|
|
30
|
+
const blockLayout = computed(() => (props.def as { fieldLayout?: LayoutNode[] } | undefined)?.fieldLayout)
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="block-fields">
|
|
35
|
+
<p class="block-fields__title">{{ resolveLocalized(def?.label, lang) ?? block.type }}</p>
|
|
36
|
+
<FieldLayout
|
|
37
|
+
:layout="blockLayout"
|
|
38
|
+
:fields="subFields"
|
|
39
|
+
:values="block.props"
|
|
40
|
+
:errors="errors"
|
|
41
|
+
:dead-fields="deadFields"
|
|
42
|
+
:locale="locale"
|
|
43
|
+
:disabled="disabled"
|
|
44
|
+
@update="(key, value) => emit('update', key, value)"
|
|
45
|
+
/>
|
|
46
|
+
<p v-if="!Object.keys(subFields).length" class="block-fields__empty">{{ t('blocks.noFields') }}</p>
|
|
47
|
+
</div>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<style lang="scss">
|
|
51
|
+
.block-fields {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
gap: var(--space-3);
|
|
55
|
+
|
|
56
|
+
&__title {
|
|
57
|
+
margin: 0;
|
|
58
|
+
font-size: var(--text-sm);
|
|
59
|
+
font-weight: var(--weight-medium);
|
|
60
|
+
color: var(--color-text-muted);
|
|
61
|
+
text-transform: capitalize;
|
|
62
|
+
}
|
|
63
|
+
&__empty {
|
|
64
|
+
font-size: var(--text-sm);
|
|
65
|
+
color: var(--color-text-muted);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</style>
|