@cosmicdrift/kumiko-samples 0.374.0 → 0.378.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +6 -4
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/constants.ts +1 -0
- package/packages/bundled-features/src/account-security/index.ts +1 -0
- package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
- package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
- package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
- package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
- package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/config/feature.ts +2 -2
- package/packages/bundled-features/src/config/web/i18n.ts +1 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
- package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
- package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
- package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
- package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
- package/packages/bundled-features/src/host-routing/README.md +7 -0
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/constants.ts +4 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
- package/packages/bundled-features/src/host-routing/feature.ts +122 -41
- package/packages/bundled-features/src/host-routing/host.ts +28 -1
- package/packages/bundled-features/src/host-routing/index.ts +3 -0
- package/packages/bundled-features/src/host-routing/options.ts +45 -2
- package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
- package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
- package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
- package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
- package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
- package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
- package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
- package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/page-render/branding.ts +1 -1
- package/packages/bundled-features/src/page-render/layout.ts +2 -2
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +4 -4
- package/packages/bundled-features/src/seo/sitemap.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
- package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +38 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
- package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +36 -0
- package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +44 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +79 -34
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +134 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
- package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -110
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
- package/packages/bundled-features/src/template-resolver/index.ts +8 -1
- package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
- package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +32 -10
- package/packages/bundled-features/src/template-resolver/template-validation.ts +32 -11
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +43 -5
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +9 -33
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +30 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
- package/packages/bundled-features/src/waitlist/changes.json +6 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
- package/samples/apps/admin-console/package.json +0 -3
- package/samples/apps/admin-console/src/app/client.tsx +1 -1
- package/samples/apps/admin-console/src/app/shell.tsx +1 -1
- package/samples/apps/config-demo/package.json +1 -3
- package/samples/apps/config-demo/src/app/client.tsx +1 -1
- package/samples/apps/config-demo/src/app/shell.tsx +2 -2
- package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
- package/samples/apps/config-demo/tsconfig.json +0 -12
- package/samples/apps/marketing-demo/package.json +1 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
- package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
- package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
- package/samples/apps/showcase/package.json +1 -3
- package/samples/apps/showcase/src/app/client.tsx +1 -1
- package/samples/apps/showcase/src/app/shell.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
- package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
- package/samples/apps/styleguide/package.json +1 -3
- package/samples/apps/styleguide/src/app/client.tsx +1 -1
- package/samples/apps/styleguide/src/app/shell.tsx +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
- package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
- package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
- package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
- package/samples/apps/styleguide/tsconfig.json +0 -12
- package/samples/apps/ui-walkthrough/package.json +1 -3
- package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +7 -1
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
- package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
- package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +3 -1
- package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
- package/samples/apps/use-all-bundled/bin/main.ts +3 -2
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
- package/samples/apps/use-all-bundled/package.json +1 -3
- package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
- package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
- package/samples/apps/workspaces/package.json +1 -3
- package/samples/apps/workspaces/src/app/client.tsx +1 -1
- package/samples/apps/workspaces/src/app/shell.tsx +1 -1
- package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
- package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-landing/src/feature.ts +1 -1
- package/samples/recipes/apex-surface-auth/README.md +1 -1
- package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
- package/samples/recipes/compose-stacks/README.md +7 -4
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/embedded/README.md +8 -0
- package/samples/recipes/embedded/src/feature.ts +8 -0
- package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
- package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
- package/samples/recipes/embedded-entity-form/package.json +0 -1
- package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/record-detail-layout/package.json +0 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/user-profile/README.md +1 -1
- package/samples/recipes/wizard-form/package.json +0 -1
- package/samples/recipes/wizard-form/src/web/index.ts +2 -2
- package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
- package/samples/recipes/writeform-section/package.json +0 -1
|
@@ -19,6 +19,8 @@ export type HostClass =
|
|
|
19
19
|
| { readonly kind: "fixed"; readonly surface: HostRoutingSurface }
|
|
20
20
|
| { readonly kind: "apex" }
|
|
21
21
|
| { readonly kind: "subdomain"; readonly key: string }
|
|
22
|
+
// tenantBy resolver mode: any host that is neither fixed nor apex.
|
|
23
|
+
| { readonly kind: "resolved"; readonly hostname: string }
|
|
22
24
|
| { readonly kind: "unknown" };
|
|
23
25
|
|
|
24
26
|
export function classifyHost(host: string, options: HostRoutingOptions): HostClass {
|
|
@@ -31,8 +33,9 @@ export function classifyHost(host: string, options: HostRoutingOptions): HostCla
|
|
|
31
33
|
? options.hosts[hostname]
|
|
32
34
|
: undefined;
|
|
33
35
|
if (fixed !== undefined) return { kind: "fixed", surface: fixed };
|
|
36
|
+
if (isApexHostname(hostname, options.baseDomain)) return { kind: "apex" };
|
|
34
37
|
const baseDomain = options.baseDomain.toLowerCase();
|
|
35
|
-
if (
|
|
38
|
+
if (typeof options.tenantBy === "function") return { kind: "resolved", hostname };
|
|
36
39
|
const suffix = `.${baseDomain}`;
|
|
37
40
|
if (!hostname.endsWith(suffix)) return { kind: "unknown" };
|
|
38
41
|
const label = hostname.slice(0, -suffix.length);
|
|
@@ -40,3 +43,27 @@ export function classifyHost(host: string, options: HostRoutingOptions): HostCla
|
|
|
40
43
|
if (!isTenantKeyCandidate(label)) return { kind: "unknown" };
|
|
41
44
|
return { kind: "subdomain", key: label };
|
|
42
45
|
}
|
|
46
|
+
|
|
47
|
+
export function splitFirstSegment(path: string): {
|
|
48
|
+
readonly segment: string;
|
|
49
|
+
readonly rest: string;
|
|
50
|
+
} {
|
|
51
|
+
const withoutLeading = path.replace(/^\/+/, "");
|
|
52
|
+
const slash = withoutLeading.indexOf("/");
|
|
53
|
+
if (slash === -1) return { segment: withoutLeading, rest: "/" };
|
|
54
|
+
return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// undefined: path is outside the prefix; null: bare prefix without a key.
|
|
58
|
+
export function tenantKeyFromPath(prefix: string, path: string): string | null | undefined {
|
|
59
|
+
if (path !== prefix && !path.startsWith(`${prefix}/`)) return undefined;
|
|
60
|
+
const { segment } = splitFirstSegment(path.slice(prefix.length));
|
|
61
|
+
return segment === "" ? null : segment;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Apex hostnames are the only ones that may carry a path-named tenant or an
|
|
65
|
+
// anonymous anchor tenant.
|
|
66
|
+
export function isApexHostname(hostname: string, baseDomain: string): boolean {
|
|
67
|
+
const base = baseDomain.toLowerCase();
|
|
68
|
+
return hostname === base || hostname === `www.${base}`;
|
|
69
|
+
}
|
|
@@ -16,6 +16,9 @@ export type {
|
|
|
16
16
|
HostRoutingApexSurface,
|
|
17
17
|
HostRoutingLocales,
|
|
18
18
|
HostRoutingOptions,
|
|
19
|
+
HostRoutingRedirect,
|
|
19
20
|
HostRoutingSurface,
|
|
20
21
|
HostRoutingTenantMode,
|
|
22
|
+
HostTenantResolver,
|
|
21
23
|
} from "./options.ts";
|
|
24
|
+
export { createHostTenantIdResolver } from "./tenant-lookup.ts";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
|
|
3
4
|
// What a host (or a tenant) gets served on the HTML fallback.
|
|
@@ -20,11 +21,43 @@ export type HostRoutingLocales = {
|
|
|
20
21
|
readonly prefixed: readonly string[];
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
// Maps a normalized hostname (lowercase, no port) that is neither the apex nor
|
|
25
|
+
// a fixed host to the tenant it belongs to, e.g. a subdomain or custom domain
|
|
26
|
+
// stored in an app table. Null means "no tenant here": the dispatch answers
|
|
27
|
+
// 404 and the API answers tenant_required, never a default tenant. The
|
|
28
|
+
// result still has to be an enabled, active tenant. Runs on every request
|
|
29
|
+
// that reaches such a host, so keep it to one indexed lookup.
|
|
30
|
+
export type HostTenantResolver = (
|
|
31
|
+
host: string,
|
|
32
|
+
db: DbConnection | TenantDb,
|
|
33
|
+
) => Promise<TenantId | null> | TenantId | null;
|
|
34
|
+
|
|
23
35
|
export type HostRoutingTenantMode =
|
|
24
36
|
// <key>.<baseDomain> names the tenant.
|
|
25
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
readonly tenantBy: "host";
|
|
39
|
+
// Also serve the tenant surface on the apex under <tenantPathPrefix>/<key>/...
|
|
40
|
+
readonly tenantPathPrefix?: string;
|
|
41
|
+
}
|
|
26
42
|
// <baseDomain><tenantPathPrefix>/<key>/... names the tenant.
|
|
27
|
-
| { readonly tenantBy: "path"; readonly tenantPathPrefix: string }
|
|
43
|
+
| { readonly tenantBy: "path"; readonly tenantPathPrefix: string }
|
|
44
|
+
// The app resolves every other host itself (custom domains, profile subdomains).
|
|
45
|
+
| {
|
|
46
|
+
readonly tenantBy: HostTenantResolver;
|
|
47
|
+
readonly tenantPathPrefix?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Fixed, app-configured redirect. `to` is a same-origin path or an https URL;
|
|
51
|
+
// nothing from the request ever picks the target, only the matched remainder
|
|
52
|
+
// of the path is appended.
|
|
53
|
+
export type HostRoutingRedirect = {
|
|
54
|
+
// Path (exact or as a segment prefix, e.g. "/old" matches "/old" and "/old/x").
|
|
55
|
+
readonly from: string;
|
|
56
|
+
readonly to: string;
|
|
57
|
+
// Limits the rule to one lowercase hostname without port. Omit for every host.
|
|
58
|
+
readonly host?: string;
|
|
59
|
+
readonly status?: 301 | 302;
|
|
60
|
+
};
|
|
28
61
|
|
|
29
62
|
export type HostRoutingOptions = HostRoutingTenantMode & {
|
|
30
63
|
// Apex host without port, e.g. "example.com". "www.<baseDomain>" counts as apex.
|
|
@@ -40,4 +73,14 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
|
|
|
40
73
|
// Tenant for anonymous API calls on the apex (e.g. SYSTEM_TENANT_ID for
|
|
41
74
|
// public signup forms). Omit and the apex answers tenant_required.
|
|
42
75
|
readonly apexAnonymousTenantId?: TenantId;
|
|
76
|
+
// Per apex hostname ("example.com" or "www.example.com"): the only
|
|
77
|
+
// /api/query and /api/stream types that get the apexAnonymousTenantId anchor. Other anonymous
|
|
78
|
+
// queries on that host get no tenant (tenant_required). "example.com" and
|
|
79
|
+
// "www.example.com" share one entry: a host without its own uses the other's.
|
|
80
|
+
// Other paths (e.g. /api/batch) keep the anchor for everything.
|
|
81
|
+
// Not allowed with tenantBy "path" (boot error): there the client's X-Tenant
|
|
82
|
+
// wins over the resolver, so the allowlist would be ineffective.
|
|
83
|
+
readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
|
|
84
|
+
// Checked first, before any host classification.
|
|
85
|
+
readonly redirects?: readonly HostRoutingRedirect[];
|
|
43
86
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { normalizeHostname } from "./host.ts";
|
|
2
|
+
import type { HostRoutingRedirect } from "./options.ts";
|
|
3
|
+
|
|
4
|
+
// Same-origin targets collapse leading slashes and backslashes so an appended
|
|
5
|
+
// "//evil.example" remainder can never turn into a protocol-relative URL.
|
|
6
|
+
export function sameOriginPath(path: string): string {
|
|
7
|
+
return `/${path.replace(/^[/\\]+/, "")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function remainderAfter(from: string, path: string): string | null {
|
|
11
|
+
if (path === from) return "";
|
|
12
|
+
const prefix = from.endsWith("/") ? from : `${from}/`;
|
|
13
|
+
return path.startsWith(prefix) ? path.slice(prefix.length - 1) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function matchRedirect(
|
|
17
|
+
redirects: readonly HostRoutingRedirect[],
|
|
18
|
+
host: string,
|
|
19
|
+
path: string,
|
|
20
|
+
search: string,
|
|
21
|
+
): { readonly to: string; readonly status: 301 | 302 } | undefined {
|
|
22
|
+
const hostname = normalizeHostname(host);
|
|
23
|
+
for (const rule of redirects) {
|
|
24
|
+
if (rule.host !== undefined && rule.host !== hostname) continue;
|
|
25
|
+
const remainder = remainderAfter(rule.from, path);
|
|
26
|
+
if (remainder === null) continue;
|
|
27
|
+
const target = rule.to.startsWith("https://")
|
|
28
|
+
? `${rule.to.replace(/\/$/, "")}${remainder}`
|
|
29
|
+
: sameOriginPath(`${rule.to.replace(/\/$/, "")}${remainder}`);
|
|
30
|
+
return { to: `${target}${search}`, status: rule.status ?? 301 };
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import {
|
|
5
|
+
isTenantServingPublicContent,
|
|
6
|
+
type TenantLifecycleStatus,
|
|
7
|
+
tenantTable,
|
|
8
|
+
} from "../tenant/index.ts";
|
|
9
|
+
import { classifyHost } from "./host.ts";
|
|
10
|
+
import type { HostRoutingOptions } from "./options.ts";
|
|
11
|
+
|
|
12
|
+
type TenantRow = {
|
|
13
|
+
readonly id: TenantId;
|
|
14
|
+
readonly isEnabled: boolean;
|
|
15
|
+
readonly status: TenantLifecycleStatus;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Disabled tenants and tenants in any destroy phase are not routable, the same
|
|
19
|
+
// rule public files and pages follow.
|
|
20
|
+
export async function servingTenantIdByKey(
|
|
21
|
+
db: DbConnection | TenantDb,
|
|
22
|
+
key: string,
|
|
23
|
+
): Promise<TenantId | null> {
|
|
24
|
+
const row = await fetchOne<TenantRow>(db, tenantTable, { key });
|
|
25
|
+
return row !== undefined && isTenantServingPublicContent(row) ? row.id : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function isTenantServing(db: DbConnection | TenantDb, id: TenantId): Promise<boolean> {
|
|
29
|
+
const row = await fetchOne<TenantRow>(db, tenantTable, { id });
|
|
30
|
+
return row !== undefined && isTenantServingPublicContent(row);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function servingTenantIdByResolver(
|
|
34
|
+
options: HostRoutingOptions,
|
|
35
|
+
db: DbConnection | TenantDb,
|
|
36
|
+
hostname: string,
|
|
37
|
+
): Promise<TenantId | null> {
|
|
38
|
+
if (typeof options.tenantBy !== "function") return null;
|
|
39
|
+
const resolved = await options.tenantBy(hostname, db);
|
|
40
|
+
return resolved !== null && (await isTenantServing(db, resolved)) ? resolved : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The tenant a Host header belongs to, without any request: subdomain key,
|
|
44
|
+
// resolver result or the apex anchor. Fixed and unknown hosts have none.
|
|
45
|
+
// Meant for features that serve per-tenant content by host, e.g. the
|
|
46
|
+
// `resolveApexTenant` option of managed-pages.
|
|
47
|
+
export function createHostTenantIdResolver(
|
|
48
|
+
options: HostRoutingOptions,
|
|
49
|
+
db: DbConnection | TenantDb,
|
|
50
|
+
): (host: string) => Promise<TenantId | null> {
|
|
51
|
+
return async (host) => {
|
|
52
|
+
const classified = classifyHost(host, options);
|
|
53
|
+
switch (classified.kind) {
|
|
54
|
+
case "subdomain":
|
|
55
|
+
return options.tenantBy === "host" ? servingTenantIdByKey(db, classified.key) : null;
|
|
56
|
+
case "resolved":
|
|
57
|
+
return servingTenantIdByResolver(options, db, classified.hostname);
|
|
58
|
+
case "apex":
|
|
59
|
+
return options.apexAnonymousTenantId ?? null;
|
|
60
|
+
case "fixed":
|
|
61
|
+
case "unknown":
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -1,9 +1,51 @@
|
|
|
1
|
+
import { isApexHostname } from "./host.ts";
|
|
1
2
|
import type { HostRoutingOptions } from "./options.ts";
|
|
2
3
|
|
|
3
4
|
const DOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
4
5
|
const PATH_PREFIX_PATTERN = /^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
|
|
5
6
|
const LOCALE_PATTERN = /^[a-z]{2}(?:-[a-z]{2})?$/;
|
|
6
7
|
|
|
8
|
+
const REDIRECT_PATH_PATTERN = /^\/[A-Za-z0-9._~/-]*$/;
|
|
9
|
+
// A redirect target is either a same-origin path or an https URL; neither may
|
|
10
|
+
// carry a query or fragment, because the request's query string is appended.
|
|
11
|
+
const REDIRECT_TARGET_PATTERN =
|
|
12
|
+
/^(?:\/(?!\/)[A-Za-z0-9._~/-]*|https:\/\/[a-z0-9.-]+(?::[0-9]{1,5})?(?:\/[A-Za-z0-9._~/-]*)?)$/;
|
|
13
|
+
|
|
14
|
+
function validateRedirects(options: HostRoutingOptions): void {
|
|
15
|
+
for (const rule of options.redirects ?? []) {
|
|
16
|
+
if (!REDIRECT_PATH_PATTERN.test(rule.from)) {
|
|
17
|
+
throw new Error(`host-routing: redirect from must be a path like "/old", got "${rule.from}"`);
|
|
18
|
+
}
|
|
19
|
+
if (!REDIRECT_TARGET_PATTERN.test(rule.to)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`host-routing: redirect to must be a same-origin path or an https URL without query, got "${rule.to}"`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (rule.host !== undefined && !DOMAIN_PATTERN.test(rule.host)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`host-routing: redirect host must be a lowercase hostname, got "${rule.host}"`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateAnonymousQueryAllowlist(options: HostRoutingOptions): void {
|
|
33
|
+
if (options.apexAnonymousQueryAllowlist !== undefined && options.tenantBy === "path") {
|
|
34
|
+
// Path mode resolves as fallback-only: a client-sent X-Tenant or cookie wins
|
|
35
|
+
// before the resolver runs, so the allowlist could never hold.
|
|
36
|
+
throw new Error(
|
|
37
|
+
'host-routing: apexAnonymousQueryAllowlist cannot be used with tenantBy "path", because the client names its tenant there and bypasses the allowlist',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
for (const host of Object.keys(options.apexAnonymousQueryAllowlist ?? {})) {
|
|
41
|
+
if (!isApexHostname(host, options.baseDomain)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`host-routing: apexAnonymousQueryAllowlist key must be the apex or www.<apex> hostname, got "${host}"`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
7
49
|
// Checked once when the app composes its features, so a config typo fails
|
|
8
50
|
// the boot instead of routing every request to a 404.
|
|
9
51
|
export function validateHostRoutingOptions(options: HostRoutingOptions): void {
|
|
@@ -17,11 +59,16 @@ export function validateHostRoutingOptions(options: HostRoutingOptions): void {
|
|
|
17
59
|
throw new Error(`host-routing: hosts key must be a lowercase hostname, got "${host}"`);
|
|
18
60
|
}
|
|
19
61
|
}
|
|
20
|
-
if (
|
|
62
|
+
if (
|
|
63
|
+
options.tenantPathPrefix !== undefined &&
|
|
64
|
+
!PATH_PREFIX_PATTERN.test(options.tenantPathPrefix)
|
|
65
|
+
) {
|
|
21
66
|
throw new Error(
|
|
22
67
|
`host-routing: tenantPathPrefix must look like "/app" (leading slash, no trailing slash), got "${options.tenantPathPrefix}"`,
|
|
23
68
|
);
|
|
24
69
|
}
|
|
70
|
+
validateRedirects(options);
|
|
71
|
+
validateAnonymousQueryAllowlist(options);
|
|
25
72
|
const { locales } = options;
|
|
26
73
|
if (locales !== undefined) {
|
|
27
74
|
for (const locale of [locales.default, ...locales.prefixed]) {
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.378.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Mailbox list accepts the screen's limit",
|
|
6
|
+
"detail": "account:list accepted an empty payload only, so the projectionList screen's limit was rejected. It now takes limit up to MAX_LIST_LIMIT and returns the visible mailboxes ordered by id."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.377.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Declarative mailbox list and rename screens",
|
|
12
|
+
"detail": "The feature registers the projectionList mail-accounts (name, address, provider, status, connected; row actions rename and disconnect) and the actionForm mail-account-edit, both restricted to SystemAdmin and TenantAdmin like the handlers. The list shows several mailboxes per tenant, never a credential. Screens are dormant, so the app places the list in its own nav. MAIL_ACCOUNT_LIST_SCREEN_ID and MAIL_ACCOUNT_EDIT_SCREEN_ID are exported.",
|
|
13
|
+
"migration": "Place mail-accounts in the app nav with r.nav; no other change."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.370.0",
|
|
4
17
|
"type": "breaking",
|
|
@@ -79,6 +79,7 @@ import { ingestMessageHandler } from "./handlers/ingest-message.write.ts";
|
|
|
79
79
|
import { listAccountsQuery } from "./handlers/list-accounts.query.ts";
|
|
80
80
|
import { listMessagesQuery } from "./handlers/list-messages.query.ts";
|
|
81
81
|
import { updateAccountHandler } from "./handlers/update-account.write.ts";
|
|
82
|
+
import { INBOUND_MAIL_FOUNDATION_I18N } from "./i18n.ts";
|
|
82
83
|
import {
|
|
83
84
|
applyInboundMessageReceived,
|
|
84
85
|
applyMailAccountConnected,
|
|
@@ -90,6 +91,7 @@ import {
|
|
|
90
91
|
mailThreadsProjectionTable,
|
|
91
92
|
} from "./projection.ts";
|
|
92
93
|
import { runInboundMailRetention } from "./retention-sweep.ts";
|
|
94
|
+
import { mailAccountEditScreen, mailAccountListScreen } from "./screens.ts";
|
|
93
95
|
import {
|
|
94
96
|
INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON,
|
|
95
97
|
inboundMessageTenantDestroyHook,
|
|
@@ -195,6 +197,12 @@ export function createInboundMailFoundationFeature() {
|
|
|
195
197
|
r.queryHandler(listAccountsQuery);
|
|
196
198
|
r.queryHandler(listMessagesQuery);
|
|
197
199
|
|
|
200
|
+
// Declarative mailbox admin: list (rename, disconnect) and rename form.
|
|
201
|
+
// The app places `mailAccountListScreen` via r.nav in its settings area.
|
|
202
|
+
r.screen(mailAccountListScreen);
|
|
203
|
+
r.screen(mailAccountEditScreen);
|
|
204
|
+
r.translations({ keys: INBOUND_MAIL_FOUNDATION_I18N });
|
|
205
|
+
|
|
198
206
|
// Entity-genaue GDPR-Destroy-Hooks — der gdpr-storage-Boot-Validator
|
|
199
207
|
// prueft die EXT_TENANT_DATA-Registrierung pro tenant-subject-Entity.
|
|
200
208
|
r.useExtension(EXT_TENANT_DATA, "mail-account", {
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts
CHANGED
|
@@ -11,13 +11,16 @@ import {
|
|
|
11
11
|
configuredPiiSubjectKms,
|
|
12
12
|
decryptPiiFieldValues,
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
14
|
-
import type
|
|
14
|
+
import { MAX_LIST_LIMIT, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import * as z from "zod";
|
|
16
16
|
import { MAIL_ACCOUNT_PII_FIELDS } from "../entities.ts";
|
|
17
17
|
import { mailAccountsProjectionTable } from "../projection.ts";
|
|
18
18
|
import { isVisibleToCaller } from "./scope-visibility.ts";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// The projectionList screen always sends `limit`; unknown keys stay rejected.
|
|
21
|
+
const listAccountsSchema = z
|
|
22
|
+
.object({ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT) })
|
|
23
|
+
.strict();
|
|
21
24
|
|
|
22
25
|
export const listAccountsQuery: QueryHandlerDef = {
|
|
23
26
|
name: "account:list",
|
|
@@ -25,11 +28,15 @@ export const listAccountsQuery: QueryHandlerDef = {
|
|
|
25
28
|
access: { roles: ["SystemAdmin", "TenantAdmin", "User"] },
|
|
26
29
|
description:
|
|
27
30
|
"Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers; use it for the mail connect and settings views.",
|
|
28
|
-
handler: async (
|
|
31
|
+
handler: async (query, ctx) => {
|
|
32
|
+
const { limit } = listAccountsSchema.parse(query.payload);
|
|
29
33
|
const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
|
|
30
34
|
tenantId: ctx.user.tenantId,
|
|
31
35
|
});
|
|
32
|
-
const rows = allRows
|
|
36
|
+
const rows = allRows
|
|
37
|
+
.filter((row) => isVisibleToCaller(row, ctx.user))
|
|
38
|
+
.sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))
|
|
39
|
+
.slice(0, limit);
|
|
33
40
|
const piiKms = configuredPiiSubjectKms();
|
|
34
41
|
if (!piiKms) return { rows };
|
|
35
42
|
const decryptedRows = await Promise.all(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
3
|
+
import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.ts";
|
|
4
|
+
import {
|
|
5
|
+
MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
6
|
+
MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
7
|
+
MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX,
|
|
8
|
+
} from "./screens.ts";
|
|
9
|
+
|
|
10
|
+
const formFieldKey = (field: string): string =>
|
|
11
|
+
fieldLabelKey(INBOUND_MAIL_FOUNDATION_FEATURE, ACTION_FORM_ENTITY, field);
|
|
12
|
+
|
|
13
|
+
export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en: string }>> = {
|
|
14
|
+
[`screen:${MAIL_ACCOUNT_LIST_SCREEN_ID}.title`]: { en: "Mailboxes" },
|
|
15
|
+
[`screen:${MAIL_ACCOUNT_EDIT_SCREEN_ID}.title`]: { en: "Edit mailbox" },
|
|
16
|
+
"inboundMail.accounts.col.name": { en: "Name" },
|
|
17
|
+
"inboundMail.accounts.col.address": { en: "Address" },
|
|
18
|
+
"inboundMail.accounts.col.provider": { en: "Provider" },
|
|
19
|
+
"inboundMail.accounts.col.status": { en: "Status" },
|
|
20
|
+
"inboundMail.accounts.col.connected": { en: "Connected" },
|
|
21
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.active}`]: {
|
|
22
|
+
en: "Active",
|
|
23
|
+
},
|
|
24
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.authError}`]: {
|
|
25
|
+
en: "Sign-in failed",
|
|
26
|
+
},
|
|
27
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.degraded}`]: {
|
|
28
|
+
en: "Interrupted",
|
|
29
|
+
},
|
|
30
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.disconnected}`]: {
|
|
31
|
+
en: "Disconnected",
|
|
32
|
+
},
|
|
33
|
+
"inboundMail.accounts.edit": { en: "Rename" },
|
|
34
|
+
"inboundMail.accounts.edit.submit": { en: "Save" },
|
|
35
|
+
"inboundMail.accounts.disconnect": { en: "Disconnect" },
|
|
36
|
+
"inboundMail.accounts.disconnect.confirm": {
|
|
37
|
+
en: "Disconnect this mailbox? No new mail is fetched; stored mail is kept.",
|
|
38
|
+
},
|
|
39
|
+
[formFieldKey("accountId")]: { en: "Mailbox" },
|
|
40
|
+
[formFieldKey("displayName")]: { en: "Name" },
|
|
41
|
+
[formFieldKey("reason")]: { en: "Reason" },
|
|
42
|
+
};
|
|
@@ -77,6 +77,10 @@ export {
|
|
|
77
77
|
resolveInboundProviderForAccount,
|
|
78
78
|
resolveInboundProviderForKey,
|
|
79
79
|
} from "./provider-factory.ts";
|
|
80
|
+
export {
|
|
81
|
+
MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
82
|
+
MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
83
|
+
} from "./screens.ts";
|
|
80
84
|
export {
|
|
81
85
|
InboundAuthError,
|
|
82
86
|
InboundCursorInvalidError,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
i18nKey,
|
|
3
|
+
MAX_LIST_LIMIT,
|
|
4
|
+
type ProjectionListScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import {
|
|
8
|
+
InboundMailAccountStatuses,
|
|
9
|
+
InboundMailFoundationHandlers,
|
|
10
|
+
InboundMailFoundationQueries,
|
|
11
|
+
} from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
export const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
|
|
14
|
+
export const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
|
|
15
|
+
|
|
16
|
+
export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
|
|
17
|
+
|
|
18
|
+
// Same roles as the update/disconnect handlers: a screen rule wider than the
|
|
19
|
+
// handlers would only render actions that 403.
|
|
20
|
+
const MAIL_ACCOUNT_ADMIN_ACCESS = { roles: ["SystemAdmin", "TenantAdmin"] } as const;
|
|
21
|
+
|
|
22
|
+
// Several mailboxes per tenant, one row each. The bound query already filters
|
|
23
|
+
// personal mailboxes the caller neither owns nor administers and returns no
|
|
24
|
+
// credential: the secret lives in the secrets feature, never in the row.
|
|
25
|
+
// dormant: the consuming app places the list in its own settings nav.
|
|
26
|
+
export const mailAccountListScreen: ProjectionListScreenDefinition = {
|
|
27
|
+
id: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
28
|
+
type: "projectionList",
|
|
29
|
+
agent: { expose: false },
|
|
30
|
+
query: InboundMailFoundationQueries.listAccounts,
|
|
31
|
+
pagination: false,
|
|
32
|
+
pageSize: MAX_LIST_LIMIT,
|
|
33
|
+
columns: [
|
|
34
|
+
{ field: "displayName", label: i18nKey("inboundMail.accounts.col.name"), sortable: false },
|
|
35
|
+
{ field: "address", label: i18nKey("inboundMail.accounts.col.address"), sortable: false },
|
|
36
|
+
{
|
|
37
|
+
field: "provider",
|
|
38
|
+
label: i18nKey("inboundMail.accounts.col.provider"),
|
|
39
|
+
sortable: false,
|
|
40
|
+
hideOnNarrow: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
field: "status",
|
|
44
|
+
label: i18nKey("inboundMail.accounts.col.status"),
|
|
45
|
+
sortable: false,
|
|
46
|
+
renderer: { format: "enumOption", keyPrefix: MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
field: "connectedAt",
|
|
50
|
+
label: i18nKey("inboundMail.accounts.col.connected"),
|
|
51
|
+
sortable: false,
|
|
52
|
+
hideOnNarrow: true,
|
|
53
|
+
renderer: { format: "timestamp" },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
rowActions: [
|
|
57
|
+
{
|
|
58
|
+
kind: "navigate",
|
|
59
|
+
id: "edit",
|
|
60
|
+
label: i18nKey("inboundMail.accounts.edit"),
|
|
61
|
+
screen: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
62
|
+
params: { map: { accountId: "id", displayName: "displayName" } },
|
|
63
|
+
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: "writeHandler",
|
|
67
|
+
id: "disconnect",
|
|
68
|
+
label: i18nKey("inboundMail.accounts.disconnect"),
|
|
69
|
+
handler: InboundMailFoundationHandlers.disconnectAccount,
|
|
70
|
+
payload: { map: { accountId: "id" } },
|
|
71
|
+
style: "danger",
|
|
72
|
+
confirm: i18nKey("inboundMail.accounts.disconnect.confirm"),
|
|
73
|
+
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
access: MAIL_ACCOUNT_ADMIN_ACCESS,
|
|
77
|
+
dormant: true,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// The reason is the audit label update-account requires; it is not in the
|
|
81
|
+
// layout, so the default is what gets written.
|
|
82
|
+
export const mailAccountEditScreen: ActionFormScreenDefinition = {
|
|
83
|
+
id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
84
|
+
type: "actionForm",
|
|
85
|
+
agent: { expose: false },
|
|
86
|
+
handler: InboundMailFoundationHandlers.updateAccount,
|
|
87
|
+
fields: {
|
|
88
|
+
accountId: { type: "text", required: true },
|
|
89
|
+
displayName: { type: "text", required: true, maxLength: 200 },
|
|
90
|
+
reason: { type: "text", default: "tenant_admin" },
|
|
91
|
+
},
|
|
92
|
+
layout: {
|
|
93
|
+
sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
|
|
94
|
+
},
|
|
95
|
+
submitLabel: i18nKey("inboundMail.accounts.edit.submit"),
|
|
96
|
+
redirect: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
97
|
+
cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
98
|
+
access: MAIL_ACCOUNT_ADMIN_ACCESS,
|
|
99
|
+
dormant: true,
|
|
100
|
+
};
|
|
@@ -21,7 +21,7 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
|
|
|
21
21
|
* config — tenant config reads (host/port/... of the plugins)
|
|
22
22
|
* registry — extension lookup in the factory (not plugin-internal)
|
|
23
23
|
* secrets — tenant secret reads (IMAP password, OAuth refresh token)
|
|
24
|
-
*
|
|
24
|
+
* auditUserId — audit identity for secret reads. Handler path: the dispatcher
|
|
25
25
|
* sets the caller user ID; worker path: system identity.
|
|
26
26
|
* metricsFor — metrics handle of a feature by name, same behavior as
|
|
27
27
|
* HandlerContext.metricsFor: unregistered metrics are a silent
|
|
@@ -31,7 +31,7 @@ export type InboundMailContext = {
|
|
|
31
31
|
readonly config?: ConfigAccessor;
|
|
32
32
|
readonly registry?: Registry;
|
|
33
33
|
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
34
|
-
readonly
|
|
34
|
+
readonly auditUserId?: string | undefined;
|
|
35
35
|
readonly metricsFor?: (featureName: string) => MetricsHandle;
|
|
36
36
|
};
|
|
37
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { JOBS_FEATURE } from "../constants.ts";
|
|
5
5
|
import { defaultTranslations } from "./i18n.ts";
|
|
6
6
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.376.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Legal layout receives alternates",
|
|
6
|
+
"detail": "createLegalPagesLayout passes alternates as second argument to headerHtml and footerHtml, and legal-pages routes provide the other-language paths of the same slug, so a layout can render a language switch."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
SYSTEM_TENANT_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import { escapeHtml } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
8
8
|
import {
|
|
9
9
|
cachedSecurePageResponse,
|
|
10
10
|
type PublicPageWrapLayout,
|
|
@@ -90,6 +90,17 @@ export type LegalPagesOptions = {
|
|
|
90
90
|
readonly terms?: boolean;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
// Only slugs served in more than one language get alternates.
|
|
94
|
+
function alternatesBySlugOf(
|
|
95
|
+
routes: readonly LegalPageRoute[],
|
|
96
|
+
): ReadonlyMap<string, Readonly<Record<string, string>>> {
|
|
97
|
+
const bySlug = new Map<string, Record<string, string>>();
|
|
98
|
+
for (const route of routes) {
|
|
99
|
+
bySlug.set(route.slug, { ...bySlug.get(route.slug), [route.lang]: route.path });
|
|
100
|
+
}
|
|
101
|
+
return new Map([...bySlug].filter(([, byLang]) => Object.keys(byLang).length > 1));
|
|
102
|
+
}
|
|
103
|
+
|
|
93
104
|
export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
|
|
94
105
|
const wrapLayout = opts.wrapLayout ?? wrapInLayout;
|
|
95
106
|
const routes: readonly LegalPageRoute[] = [
|
|
@@ -108,6 +119,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
108
119
|
);
|
|
109
120
|
}
|
|
110
121
|
}
|
|
122
|
+
const alternatesBySlug = alternatesBySlugOf(routes);
|
|
111
123
|
return defineFeature("legal-pages", (r) => {
|
|
112
124
|
r.describe(
|
|
113
125
|
"Opt-in wrapper around `template-resolver` text-blocks that registers public HTML routes (default: `/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required blocks (default: `imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Both are configurable via `routes`/`requiredBlocks` for apps with a different default language or additional pages. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.templateResolver` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `template-resolver:query:by-slug` directly.",
|
|
@@ -189,6 +201,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
189
201
|
: renderedBody,
|
|
190
202
|
lang: route.lang,
|
|
191
203
|
slug: route.slug,
|
|
204
|
+
alternates: alternatesBySlug.get(route.slug),
|
|
192
205
|
});
|
|
193
206
|
|
|
194
207
|
return cachedSecurePageResponse(c.req.raw, {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
1
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
2
2
|
import { type PublicPageWrapLayout, TENANT_CONTENT_ATTR } from "../page-render/index.ts";
|
|
3
3
|
|
|
4
4
|
export type LegalPagesLayoutOptions = {
|
|
@@ -8,8 +8,8 @@ export type LegalPagesLayoutOptions = {
|
|
|
8
8
|
readonly css?: string;
|
|
9
9
|
readonly stylesheetHrefs?: readonly string[];
|
|
10
10
|
// Trusted app chrome per language; the feature passes the route's `lang`.
|
|
11
|
-
readonly headerHtml?: (lang: string) => string;
|
|
12
|
-
readonly footerHtml?: (lang: string) => string;
|
|
11
|
+
readonly headerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
|
|
12
|
+
readonly footerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
|
|
13
13
|
readonly contentClass?: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
@@ -22,9 +22,9 @@ export function createLegalPagesLayout(options: LegalPagesLayoutOptions): Public
|
|
|
22
22
|
const contentClass = options.contentClass
|
|
23
23
|
? ` class="${escapeHtmlAttr(options.contentClass)}"`
|
|
24
24
|
: "";
|
|
25
|
-
return ({ title, bodyHtml, lang }) => {
|
|
26
|
-
const headerHtml = options.headerHtml?.(lang) ?? "";
|
|
27
|
-
const footerHtml = options.footerHtml?.(lang) ?? "";
|
|
25
|
+
return ({ title, bodyHtml, lang, alternates }) => {
|
|
26
|
+
const headerHtml = options.headerHtml?.(lang, alternates) ?? "";
|
|
27
|
+
const footerHtml = options.footerHtml?.(lang, alternates) ?? "";
|
|
28
28
|
return `<!doctype html>
|
|
29
29
|
<html lang="${escapeHtmlAttr(lang)}">
|
|
30
30
|
<head>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// fehlende Pflicht-Blocks.
|
|
18
18
|
|
|
19
19
|
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
20
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
21
21
|
import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants.ts";
|
|
22
22
|
|
|
23
23
|
const treeProvider: TreeChildrenSubscribe = () => (emit) => {
|