@cosmicdrift/kumiko-bundled-features 0.341.0 → 0.342.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.
Files changed (31) hide show
  1. package/dist/audit/constants.d.ts +3 -0
  2. package/dist/audit/constants.js +3 -0
  3. package/dist/audit/feature.js +34 -7
  4. package/dist/audit/run-escape-hatch-retention.d.ts +10 -0
  5. package/dist/audit/run-escape-hatch-retention.js +27 -0
  6. package/dist/auth-email-password/bootstrap.d.ts +8 -2
  7. package/dist/auth-email-password/bootstrap.js +7 -2
  8. package/dist/auth-email-password/web/auth-form-primitives.d.ts +7 -1
  9. package/dist/auth-email-password/web/auth-form-primitives.js +5 -5
  10. package/dist/auth-email-password/web/confirm-account-unlock-screen.js +3 -3
  11. package/dist/auth-email-password/web/forgot-password-screen.js +2 -2
  12. package/dist/auth-email-password/web/invite-accept-screen.js +9 -9
  13. package/dist/auth-email-password/web/login-screen.js +8 -8
  14. package/dist/auth-email-password/web/request-account-unlock-screen.js +2 -2
  15. package/dist/auth-email-password/web/reset-password-screen.js +3 -3
  16. package/dist/auth-email-password/web/session-bootstrap-error.js +3 -3
  17. package/dist/auth-email-password/web/signup-complete-screen.js +3 -3
  18. package/dist/auth-email-password/web/signup-screen.js +2 -2
  19. package/dist/auth-email-password/web/verify-email-screen.js +3 -3
  20. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +5 -5
  21. package/dist/auth-mfa/web/mfa-verify-screen.js +3 -3
  22. package/dist/channel-email/email-channel.js +7 -1
  23. package/dist/channel-email/pii-guard.js +6 -5
  24. package/dist/channel-email/smtp-transport.js +22 -1
  25. package/dist/channel-email/types.d.ts +1 -0
  26. package/dist/waitlist/handlers/submit.write.js +1 -1
  27. package/dist/waitlist/options.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/auth-email-password/changes.json +12 -0
  30. package/src/channel-email/changes.json +6 -0
  31. package/src/waitlist/changes.json +6 -0
@@ -13,3 +13,6 @@ export declare const AuditQueries: {
13
13
  export declare const AUDIT_LOG_SCREEN_ID: "audit-log";
14
14
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
15
15
  export declare const AUDIT_LOG_DETAIL_SCREEN_ID: "audit-log-detail";
16
+ /** Config key (system scope) for how long `escapeHatchUse` audit events are kept. */
17
+ export declare const ESCAPE_HATCH_RETENTION_DAYS_KEY: "audit:config:escape-hatch-retention-days";
18
+ export declare const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -18,3 +18,6 @@ export const AuditQueries = {
18
18
  export const AUDIT_LOG_SCREEN_ID = "audit-log";
19
19
  /** Single-event detail screen, breadcrumb-linked to the audit-log list. */
20
20
  export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail";
21
+ /** Config key (system scope) for how long `escapeHatchUse` audit events are kept. */
22
+ export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days";
23
+ export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -1,9 +1,10 @@
1
- import { access, defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
2
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants.js";
1
+ import { access, createSystemConfig, defineFeature, i18nKey, SYSTEM_USER_ID, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries, DEFAULT_ESCAPE_HATCH_RETENTION_DAYS, } from "./constants.js";
3
3
  import { escapeHatchUsedSchema } from "./escape-hatch-audit-sink.js";
4
4
  import { detailsQuery } from "./handlers/details.query.js";
5
5
  import { listQuery } from "./handlers/list.query.js";
6
6
  import { AUDIT_I18N } from "./i18n.js";
7
+ import { runEscapeHatchRetention } from "./run-escape-hatch-retention.js";
7
8
  // Audit feature — exposes a filtered read over the framework's event log.
8
9
  //
9
10
  // Design: the event-store IS the audit trail (every entity write produces
@@ -12,10 +13,11 @@ import { AUDIT_I18N } from "./i18n.js";
12
13
  // the existing `events` table. See handlers/list.query.ts for the filter
13
14
  // surface.
14
15
  //
15
- // Retention lives elsewhere. Events are kept indefinitely as the source of
16
- // truth for state; archive or compress policies are a separate concern
17
- // (tracked with the snapshot/archive infrastructure that already exists in
18
- // the framework).
16
+ // Retention: only `escapeHatchUse` events are pruned (daily job, config key
17
+ // `escapeHatchRetentionDays`). All other events stay as the source of truth
18
+ // for state; archive or compress policies are a separate concern (tracked
19
+ // with the snapshot/archive infrastructure that already exists in the
20
+ // framework).
19
21
  export function createAuditFeature() {
20
22
  return defineFeature("audit", (r) => {
21
23
  r.describe("Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).");
@@ -25,9 +27,34 @@ export function createAuditFeature() {
25
27
  recommended: false,
26
28
  });
27
29
  r.translations({ keys: AUDIT_I18N });
28
- r.requires("tenant", "user");
30
+ r.requires("tenant", "user", "config");
29
31
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
30
32
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
33
+ r.config("escapeHatchRetentionDays", createSystemConfig("number", {
34
+ default: DEFAULT_ESCAPE_HATCH_RETENTION_DAYS,
35
+ bounds: { min: 1 },
36
+ }));
37
+ r.job({
38
+ name: "escape-hatch-retention",
39
+ trigger: { cron: "30 3 * * *" },
40
+ concurrency: "skip",
41
+ // pruneEvents needs a transaction-capable connection, which only unsafeRaw() hands a job.
42
+ escapeHatch: {
43
+ reason: "prunes expired escapeHatchUse audit events through pruneEvents",
44
+ grants: ["unsafeRaw"],
45
+ },
46
+ handler: async (_payload, ctx) => {
47
+ if (!ctx.registry) {
48
+ throw new Error("escape-hatch-retention: ctx.registry required (JobContext incomplete)");
49
+ }
50
+ await runEscapeHatchRetention({
51
+ db: ctx.db.unsafeRaw(),
52
+ registry: ctx.registry,
53
+ configResolver: ctx.configResolver,
54
+ userId: ctx._userId ?? SYSTEM_USER_ID,
55
+ });
56
+ },
57
+ });
31
58
  const queries = {
32
59
  list: r.queryHandler(listQuery),
33
60
  details: r.queryHandler(detailsQuery),
@@ -0,0 +1,10 @@
1
+ import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type ConfigResolver, type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type PruneEventsResult } from "@cosmicdrift/kumiko-framework/pipeline";
4
+ export declare function isDbConnection(runner: DbRunner): runner is DbConnection;
5
+ export declare function runEscapeHatchRetention(args: {
6
+ readonly db: DbRunner;
7
+ readonly registry: Registry;
8
+ readonly configResolver: ConfigResolver | undefined;
9
+ readonly userId: string;
10
+ }): Promise<PruneEventsResult>;
@@ -0,0 +1,27 @@
1
+ import { SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
3
+ import { createConfigAccessor } from "../config/index.js";
4
+ import { DEFAULT_ESCAPE_HATCH_RETENTION_DAYS, ESCAPE_HATCH_RETENTION_DAYS_KEY, } from "./constants.js";
5
+ import { ESCAPE_HATCH_USE_AGGREGATE_TYPE } from "./escape-hatch-audit-sink.js";
6
+ // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
7
+ export function isDbConnection(runner) {
8
+ return "begin" in runner && typeof runner.begin === "function";
9
+ }
10
+ async function resolveRetentionDays(args) {
11
+ if (!args.configResolver)
12
+ return DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
13
+ const config = createConfigAccessor(args.registry, args.configResolver, SYSTEM_TENANT_ID, args.userId, args.db);
14
+ const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
15
+ return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
16
+ }
17
+ export async function runEscapeHatchRetention(args) {
18
+ const { db } = args;
19
+ if (!isDbConnection(db)) {
20
+ throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
21
+ }
22
+ const olderThanDays = await resolveRetentionDays({ ...args, db });
23
+ return pruneEvents(db, {
24
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
25
+ olderThanDays,
26
+ });
27
+ }
@@ -1,6 +1,6 @@
1
- import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
1
+ import { makeDispatchSystemWrite, type SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import { type TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
5
5
  import type { Redis } from "ioredis";
6
6
  import type { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
@@ -15,6 +15,12 @@ export type BootstrapSeedDeps = {
15
15
  readonly tenantId: TenantId;
16
16
  readonly db: DbConnection;
17
17
  readonly dispatchSystemWrite: ReturnType<typeof makeDispatchSystemWrite>;
18
+ /** Writes as the system user with the given tenant roles in the seeded
19
+ * tenant; use it when the app gates tenant data on its own roles rather
20
+ * than SystemAdmin. */
21
+ readonly dispatchWriteAs: (args: Pick<SystemDispatchArgs, "handlerQn" | "payload"> & {
22
+ readonly roles: readonly string[];
23
+ }) => Promise<WriteResult>;
18
24
  };
19
25
  export type BootstrapConfigValue = string | number | boolean;
20
26
  export type BootstrapTenant = {
@@ -4,7 +4,7 @@
4
4
  // exactly as for any other writer. Nobody gets a role before accepting: the
5
5
  // invitation row holds membership role + global roles until the invitee
6
6
  // accepts through the regular invite-accept routes.
7
- import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-framework/api";
7
+ import { makeDispatchSystemWrite, } from "@cosmicdrift/kumiko-framework/api";
8
8
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
9
9
  import { ConfigScopes, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
@@ -140,7 +140,12 @@ export async function bootstrapTenants(deps, plan) {
140
140
  const outcome = await ensureTenant(deps, tenant);
141
141
  const shouldSeed = outcome === "created" && plan.seed !== undefined;
142
142
  if (shouldSeed) {
143
- await plan.seed?.({ tenantId: tenant.id, db: deps.db, dispatchSystemWrite });
143
+ await plan.seed?.({
144
+ tenantId: tenant.id,
145
+ db: deps.db,
146
+ dispatchSystemWrite,
147
+ dispatchWriteAs: ({ handlerQn, payload, roles }) => deps.dispatcher.write(handlerQn, payload, createSystemUser(tenant.id, roles)),
148
+ });
144
149
  }
145
150
  const configApplied = await applyTenantConfig(deps, tenant);
146
151
  tenants.push({ id: tenant.id, outcome, seeded: shouldSeed, configApplied });
@@ -8,8 +8,14 @@ export declare function useAuthShell(): AuthShellRenderer | null;
8
8
  export type AuthCardProps = {
9
9
  readonly title?: string;
10
10
  readonly subtitle?: ReactNode;
11
+ /** Merged onto the Card (default `w-full max-w-sm`). */
12
+ readonly className?: string;
13
+ readonly headerClassName?: string;
14
+ readonly titleClassName?: string;
15
+ /** Merged onto the body wrapper (default `p-6 pt-0 flex flex-col gap-4`). */
16
+ readonly bodyClassName?: string;
11
17
  readonly children: ReactNode;
12
18
  };
13
- export declare function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode;
19
+ export declare function AuthCard({ title, subtitle, className, headerClassName, titleClassName, bodyClassName, children, }: AuthCardProps): ReactNode;
14
20
  export declare function parseUrlToken(paramName?: string): string;
15
21
  export declare function useUrlToken(override?: string, paramName?: string): string;
@@ -15,7 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  // Link-Styles laufen über das Link-Primitive (variant="button"/"muted") —
16
16
  // die früheren authButtonClass/authMutedLinkClass sind dorthin gewandert.
17
17
  import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
18
- import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
18
+ import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer-web";
19
19
  import { createContext, useContext, useEffect, useState } from "react";
20
20
  const defaultAuthShell = (card) => (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-background px-4", children: card }));
21
21
  const AuthShellContext = createContext(null);
@@ -26,14 +26,14 @@ export function useAuthShell() {
26
26
  return useContext(AuthShellContext);
27
27
  }
28
28
  // kumiko-lint-ignore no-custom-primitives Wraps usePrimitives().Card, pure domain name for the auth-screen chrome, not a primitive reimplementation
29
- export function AuthCard({ title, subtitle, children }) {
29
+ export function AuthCard({ title, subtitle, className, headerClassName, titleClassName, bodyClassName, children, }) {
30
30
  const { Card } = usePrimitives();
31
31
  const shell = useAuthShell() ?? defaultAuthShell;
32
32
  // h1 (Seiten-Hauptüberschrift) via Header-Slot erhalten — die Card-Default-
33
33
  // Header wäre h3. padded:false = Form sitzt randlos wie bisher (bare form).
34
- const card = (_jsx(Card, { className: "w-full max-w-sm", options: { padded: false }, slots: {
35
- header: title !== undefined || subtitle !== undefined ? (_jsxs("div", { className: "flex flex-col space-y-1.5 p-6 pb-4", children: [title !== undefined && (_jsx("h1", { className: "text-xl font-semibold tracking-tight", children: title })), subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: subtitle }))] })) : undefined,
36
- }, children: _jsx(BareFormProvider, { children: children }) }));
34
+ const card = (_jsx(Card, { className: cn("w-full max-w-sm", className), options: { padded: false }, slots: {
35
+ header: title !== undefined || subtitle !== undefined ? (_jsxs("div", { className: cn("flex flex-col space-y-1.5 p-6 pb-4", headerClassName), children: [title !== undefined && (_jsx("h1", { className: cn("text-xl font-semibold tracking-tight", titleClassName), children: title })), subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: subtitle }))] })) : undefined,
36
+ }, children: _jsx(BareFormProvider, { children: _jsx("div", { className: cn("p-6 pt-0 flex flex-col gap-4", bodyClassName), children: children }) }) }));
37
37
  return shell(card);
38
38
  }
39
39
  // Liest `?<paramName>=<value>` aus der aktuellen URL — typisches
@@ -30,14 +30,14 @@ export function ConfirmAccountUnlockScreen({ title, token: tokenProp, loginHref
30
30
  })();
31
31
  }, [status, token]);
32
32
  if (status === "missing-token") {
33
- return (_jsx(AuthCard, { title: title ?? t("auth.unlockAccount.errorTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.unlockAccount.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.unlockAccount.goToLogin") })] }) }));
33
+ return (_jsxs(AuthCard, { title: title ?? t("auth.unlockAccount.errorTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.unlockAccount.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.unlockAccount.goToLogin") })] }));
34
34
  }
35
35
  if (status === "verifying") {
36
36
  return (_jsx(AuthCard, { children: _jsx("div", { className: "p-6", children: _jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t("auth.unlockAccount.verifying") }) }) }));
37
37
  }
38
38
  if (status === "success") {
39
- return (_jsx(AuthCard, { title: title ?? t("auth.unlockAccount.successTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.unlockAccount.successBody") }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.unlockAccount.goToLogin") })] }) }));
39
+ return (_jsxs(AuthCard, { title: title ?? t("auth.unlockAccount.successTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.unlockAccount.successBody") }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.unlockAccount.goToLogin") })] }));
40
40
  }
41
41
  // status === "error"
42
- return (_jsx(AuthCard, { title: title ?? t("auth.unlockAccount.errorTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.unlockAccount.errorBody") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.unlockAccount.goToLogin") })] }) }));
42
+ return (_jsxs(AuthCard, { title: title ?? t("auth.unlockAccount.errorTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.unlockAccount.errorBody") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.unlockAccount.goToLogin") })] }));
43
43
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
3
  // ForgotPasswordScreen — Form mit email-input. Submit triggert
4
4
  // /api/auth/request-password-reset (silent-success, kein account-
@@ -53,5 +53,5 @@ export function ForgotPasswordScreen({ title, subtitle, loginHref = "/login", })
53
53
  void doSubmit();
54
54
  };
55
55
  const effectiveTitle = title ?? t("auth.forgotPassword.title");
56
- return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.forgotPassword.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.forgotPassword.successBody") })] }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.forgotPassword.backToLogin") })] })) : (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.forgotPassword.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "forgot-email", label: t("auth.forgotPassword.email"), required: true, children: _jsx(Input, { kind: "text", id: "forgot-email", name: "forgot-email", value: email, onChange: setEmail, disabled: submitting, required: true }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.forgotPassword.submitting") : t("auth.forgotPassword.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.forgotPassword.backToLogin") })] })) }));
56
+ return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs(_Fragment, { children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.forgotPassword.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.forgotPassword.successBody") })] }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.forgotPassword.backToLogin") })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.forgotPassword.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "forgot-email", label: t("auth.forgotPassword.email"), required: true, children: _jsx(Input, { kind: "text", id: "forgot-email", name: "forgot-email", value: email, onChange: setEmail, disabled: submitting, required: true }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.forgotPassword.submitting") : t("auth.forgotPassword.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.forgotPassword.backToLogin") })] })) }));
57
57
  }
@@ -128,14 +128,14 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
128
128
  void acceptAnonNew();
129
129
  };
130
130
  if (token === "") {
131
- return (_jsx(AuthCard, { title: effectiveTitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.inviteAccept.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.inviteAccept.goToLogin") })] }) }));
131
+ return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.inviteAccept.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.inviteAccept.goToLogin") })] }));
132
132
  }
133
- return (_jsx(AuthCard, { title: effectiveTitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.inviteAccept.intro") }), mode === "loggedin" ? (_jsxs(Form, { onSubmit: onSubmit, children: [_jsx("p", { className: "text-sm", children: t("auth.inviteAccept.loggedInAs", { email: session.user?.email ?? "" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.acceptButton") }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => {
134
- setMode("anon-existing");
135
- }, children: t("auth.inviteAccept.useOtherAccount") })] })) : (_jsxs(Form, { onSubmit: onSubmit, children: [mode === "anon-existing" && (_jsx(Field, { id: "invite-email", label: t("auth.inviteAccept.email"), required: true, children: _jsx(Input, { kind: "email", id: "invite-email", name: "invite-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) })), _jsx(Field, { id: "invite-password", label: t("auth.inviteAccept.password"), required: true, children: _jsx(Input, { kind: "password", id: "invite-password", name: "invite-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: mode === "anon-existing" ? "current-password" : "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.submit") }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => {
136
- setMode(mode === "anon-existing" ? "anon-new" : "anon-existing");
137
- setError(null);
138
- }, children: mode === "anon-existing"
139
- ? t("auth.inviteAccept.toggleNew")
140
- : t("auth.inviteAccept.toggleExisting") })] }))] }) }));
133
+ return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.inviteAccept.intro") }), mode === "loggedin" ? (_jsxs(Form, { onSubmit: onSubmit, children: [_jsx("p", { className: "text-sm", children: t("auth.inviteAccept.loggedInAs", { email: session.user?.email ?? "" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.acceptButton") }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => {
134
+ setMode("anon-existing");
135
+ }, children: t("auth.inviteAccept.useOtherAccount") })] })) : (_jsxs(Form, { onSubmit: onSubmit, children: [mode === "anon-existing" && (_jsx(Field, { id: "invite-email", label: t("auth.inviteAccept.email"), required: true, children: _jsx(Input, { kind: "email", id: "invite-email", name: "invite-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) })), _jsx(Field, { id: "invite-password", label: t("auth.inviteAccept.password"), required: true, children: _jsx(Input, { kind: "password", id: "invite-password", name: "invite-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: mode === "anon-existing" ? "current-password" : "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.inviteAccept.submitting") : t("auth.inviteAccept.submit") }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => {
136
+ setMode(mode === "anon-existing" ? "anon-new" : "anon-existing");
137
+ setError(null);
138
+ }, children: mode === "anon-existing"
139
+ ? t("auth.inviteAccept.toggleNew")
140
+ : t("auth.inviteAccept.toggleExisting") })] }))] }));
141
141
  }
@@ -113,12 +113,12 @@ export function LoginScreen({ title, subtitle, submitLabel, forgotPasswordHref,
113
113
  };
114
114
  const effectiveTitle = title ?? t("auth.login.title");
115
115
  const effectiveSubmit = submitLabel ?? t("auth.login.submit");
116
- return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "login-email", label: t("auth.login.email"), required: true, children: _jsx(Input, { kind: "email", id: "login-email", name: "login-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), _jsx(Field, { id: "login-password", label: t("auth.login.password"), required: true, children: _jsx(Input, { kind: "password", id: "login-password", name: "login-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "current-password" }) }), session.signedOutReason === "session-ended" && (_jsx(Banner, { variant: "info", children: t("auth.login.sessionEnded") })), resendStatus.kind === "success" ? (_jsx(Banner, { variant: "info", children: t("auth.login.resendSuccess") })) : error !== null ? (_jsx(Banner, { variant: "error", children: _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: (() => {
117
- const { key, params } = reasonToKey(error);
118
- return t(key, params);
119
- })() }), error.reason === "email_not_verified" &&
120
- email.trim().length > 0 &&
121
- email === failedLoginEmail && (_jsx("span", { className: "self-start", children: _jsx(Button, { variant: "link", onClick: () => void onResend(), disabled: resendStatus.kind === "sending", children: resendStatus.kind === "sending"
122
- ? t("auth.login.submitting")
123
- : t("auth.login.resendVerification") }) })), error.reason === "account_locked" && unlockAccountHref !== undefined && (_jsx("span", { className: "self-start", children: _jsx(Link, { href: unlockAccountHref, variant: "default", children: t("auth.login.unlockAccount") }) })), resendStatus.kind === "rateLimited" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendRateLimited") })), resendStatus.kind === "error" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendError") }))] }) })) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.login.submitting") : effectiveSubmit })] }), forgotPasswordHref !== undefined && (_jsx(Link, { href: forgotPasswordHref, variant: "muted", className: "self-center", children: t("auth.login.forgotPassword") })), signupHref !== undefined && (_jsx(Link, { href: signupHref, variant: "muted", className: "self-center", children: t("auth.signup.title") })), legalLinks !== undefined && legalLinks.length > 0 && (_jsx("nav", { "data-testid": "login-legal-links", className: "flex items-center justify-center gap-3 pt-2 border-t border-border/50", children: legalLinks.map((link) => (_jsx(Link, { href: link.href, variant: "muted", className: "text-xs", children: link.label }, link.href))) }))] }) }));
116
+ return (_jsxs(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: [_jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "login-email", label: t("auth.login.email"), required: true, children: _jsx(Input, { kind: "email", id: "login-email", name: "login-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), _jsx(Field, { id: "login-password", label: t("auth.login.password"), required: true, children: _jsx(Input, { kind: "password", id: "login-password", name: "login-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "current-password" }) }), session.signedOutReason === "session-ended" && (_jsx(Banner, { variant: "info", children: t("auth.login.sessionEnded") })), resendStatus.kind === "success" ? (_jsx(Banner, { variant: "info", children: t("auth.login.resendSuccess") })) : error !== null ? (_jsx(Banner, { variant: "error", children: _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: (() => {
117
+ const { key, params } = reasonToKey(error);
118
+ return t(key, params);
119
+ })() }), error.reason === "email_not_verified" &&
120
+ email.trim().length > 0 &&
121
+ email === failedLoginEmail && (_jsx("span", { className: "self-start", children: _jsx(Button, { variant: "link", onClick: () => void onResend(), disabled: resendStatus.kind === "sending", children: resendStatus.kind === "sending"
122
+ ? t("auth.login.submitting")
123
+ : t("auth.login.resendVerification") }) })), error.reason === "account_locked" && unlockAccountHref !== undefined && (_jsx("span", { className: "self-start", children: _jsx(Link, { href: unlockAccountHref, variant: "default", children: t("auth.login.unlockAccount") }) })), resendStatus.kind === "rateLimited" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendRateLimited") })), resendStatus.kind === "error" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendError") }))] }) })) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.login.submitting") : effectiveSubmit })] }), forgotPasswordHref !== undefined && (_jsx(Link, { href: forgotPasswordHref, variant: "muted", className: "self-center", children: t("auth.login.forgotPassword") })), signupHref !== undefined && (_jsx(Link, { href: signupHref, variant: "muted", className: "self-center", children: t("auth.signup.title") })), legalLinks !== undefined && legalLinks.length > 0 && (_jsx("nav", { "data-testid": "login-legal-links", className: "flex items-center justify-center gap-3 pt-2 border-t border-border/50", children: legalLinks.map((link) => (_jsx(Link, { href: link.href, variant: "muted", className: "text-xs", children: link.label }, link.href))) }))] }));
124
124
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
3
  // RequestAccountUnlockScreen — form with an email input. Submit triggers
4
4
  // /api/auth/request-account-unlock (silent-success, no account
@@ -54,5 +54,5 @@ export function RequestAccountUnlockScreen({ title, subtitle, loginHref = "/logi
54
54
  void doSubmit();
55
55
  };
56
56
  const effectiveTitle = title ?? t("auth.requestUnlock.title");
57
- return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.requestUnlock.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.requestUnlock.successBody") })] }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.requestUnlock.backToLogin") })] })) : (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.requestUnlock.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "unlock-email", label: t("auth.requestUnlock.email"), required: true, children: _jsx(Input, { kind: "text", id: "unlock-email", name: "unlock-email", value: email, onChange: setEmail, disabled: submitting, required: true }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.requestUnlock.submitting") : t("auth.requestUnlock.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.requestUnlock.backToLogin") })] })) }));
57
+ return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs(_Fragment, { children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.requestUnlock.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.requestUnlock.successBody") })] }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.requestUnlock.backToLogin") })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.requestUnlock.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "unlock-email", label: t("auth.requestUnlock.email"), required: true, children: _jsx(Input, { kind: "text", id: "unlock-email", name: "unlock-email", value: email, onChange: setEmail, disabled: submitting, required: true }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.requestUnlock.submitting") : t("auth.requestUnlock.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.requestUnlock.backToLogin") })] })) }));
58
58
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
3
  // ResetPasswordScreen — liest `?token=...` aus der URL, zeigt Form mit
4
4
  // new + confirm-password. Submit triggert /api/auth/reset-password mit
@@ -62,7 +62,7 @@ export function ResetPasswordScreen({ title, token: tokenProp, loginHref = "/log
62
62
  // submitten würde nur den invalidResetToken-Error zeigen — das ist
63
63
  // verwirrend. Lieber upfront eine klare Message.
64
64
  if (token === "") {
65
- return (_jsx(AuthCard, { title: effectiveTitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.resetPassword.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.resetPassword.goToLogin") })] }) }));
65
+ return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.resetPassword.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.resetPassword.goToLogin") })] }));
66
66
  }
67
- return (_jsx(AuthCard, { title: effectiveTitle, children: done ? (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.resetPassword.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.resetPassword.successBody") })] }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.resetPassword.goToLogin") })] })) : (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.resetPassword.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "reset-new-password", label: t("auth.resetPassword.newPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-new-password", name: "reset-new-password", value: newPassword, onChange: setNewPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "reset-confirm-password", label: t("auth.resetPassword.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-confirm-password", name: "reset-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.resetPassword.submitting") : t("auth.resetPassword.submit") })] })] })) }));
67
+ return (_jsx(AuthCard, { title: effectiveTitle, children: done ? (_jsxs(_Fragment, { children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.resetPassword.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.resetPassword.successBody") })] }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.resetPassword.goToLogin") })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.resetPassword.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "reset-new-password", label: t("auth.resetPassword.newPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-new-password", name: "reset-new-password", value: newPassword, onChange: setNewPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "reset-confirm-password", label: t("auth.resetPassword.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-confirm-password", name: "reset-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.resetPassword.submitting") : t("auth.resetPassword.submit") })] })] })) }));
68
68
  }
@@ -24,7 +24,7 @@ export function SessionBootstrapErrorScreen({ failure, onRetry, onSignOut, }) {
24
24
  .then(onRetry)
25
25
  .then(() => setRetrying(false));
26
26
  };
27
- return (_jsx("div", { "data-testid": "session-bootstrap-error", "data-http-status": failure.httpStatus ?? "network", children: _jsx(AuthCard, { title: t("auth.sessionBootstrap.errorTitle"), children: _jsxs("div", { className: "flex flex-col gap-4 px-6 pb-4", children: [_jsx(Banner, { variant: "error", children: failure.httpStatus === 429
28
- ? t("auth.sessionBootstrap.rateLimitedBody")
29
- : t("auth.sessionBootstrap.errorBody") }), _jsx(Button, { variant: "primary", onClick: handleRetry, disabled: retrying, children: retrying ? t("auth.sessionBootstrap.retrying") : t("auth.sessionBootstrap.retry") }), onSignOut !== undefined && (_jsx(Button, { variant: "secondary", onClick: () => void onSignOut(), disabled: retrying, children: t("auth.sessionBootstrap.signOut") }))] }) }) }));
27
+ return (_jsx("div", { "data-testid": "session-bootstrap-error", "data-http-status": failure.httpStatus ?? "network", children: _jsxs(AuthCard, { title: t("auth.sessionBootstrap.errorTitle"), bodyClassName: "pb-4", children: [_jsx(Banner, { variant: "error", children: failure.httpStatus === 429
28
+ ? t("auth.sessionBootstrap.rateLimitedBody")
29
+ : t("auth.sessionBootstrap.errorBody") }), _jsx(Button, { variant: "primary", onClick: handleRetry, disabled: retrying, children: retrying ? t("auth.sessionBootstrap.retrying") : t("auth.sessionBootstrap.retry") }), onSignOut !== undefined && (_jsx(Button, { variant: "secondary", onClick: () => void onSignOut(), disabled: retrying, children: t("auth.sessionBootstrap.signOut") }))] }) }));
30
30
  }
@@ -77,10 +77,10 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
77
77
  // Kein Token in der URL → klare Message statt Form ohne Token
78
78
  // (würde nur invalidSignupToken zeigen, verwirrend).
79
79
  if (token === "") {
80
- return (_jsx(AuthCard, { title: effectiveTitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] }) }));
80
+ return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] }));
81
81
  }
82
82
  if (continueHref !== null) {
83
- return (_jsx(AuthCard, { title: effectiveTitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t("auth.signupComplete.activated") }), _jsx(Link, { href: continueHref, variant: "button", children: t("auth.signupComplete.continue") })] }) }));
83
+ return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t("auth.signupComplete.activated") }), _jsx(Link, { href: continueHref, variant: "button", children: t("auth.signupComplete.continue") })] }));
84
84
  }
85
- return (_jsx(AuthCard, { title: effectiveTitle, children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-password", label: t("auth.signupComplete.password"), required: true, children: _jsx(Input, { kind: "password", id: "signup-password", name: "signup-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "signup-confirm-password", label: t("auth.signupComplete.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "signup-confirm-password", name: "signup-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signupComplete.submitting") : t("auth.signupComplete.submit") })] })] }) }));
85
+ return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signupComplete.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-password", label: t("auth.signupComplete.password"), required: true, children: _jsx(Input, { kind: "password", id: "signup-password", name: "signup-password", value: password, onChange: setPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "signup-confirm-password", label: t("auth.signupComplete.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "signup-confirm-password", name: "signup-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signupComplete.submitting") : t("auth.signupComplete.submit") })] })] }));
86
86
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
3
  // SignupScreen — Magic-Link-Self-Signup, Step 1.
4
4
  //
@@ -67,5 +67,5 @@ export function SignupScreen({ title, subtitle, loginHref = "/login", }) {
67
67
  }
68
68
  };
69
69
  const effectiveTitle = title ?? t("auth.signup.title");
70
- return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.signup.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.signup.successBody") })] }), _jsx(Button, { variant: "secondary", onClick: onResend, disabled: submitting, children: t("auth.signup.resend") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] })) : (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signup.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-email", label: t("auth.signup.email"), required: true, children: _jsx(Input, { kind: "email", id: "signup-email", name: "signup-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signup.submitting") : t("auth.signup.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.signup.haveAccount") })] })) }));
70
+ return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs(_Fragment, { children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.signup.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.signup.successBody") })] }), _jsx(Button, { variant: "secondary", onClick: onResend, disabled: submitting, children: t("auth.signup.resend") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signup.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-email", label: t("auth.signup.email"), required: true, children: _jsx(Input, { kind: "email", id: "signup-email", name: "signup-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signup.submitting") : t("auth.signup.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.signup.haveAccount") })] })) }));
71
71
  }
@@ -33,14 +33,14 @@ export function VerifyEmailScreen({ title, token: tokenProp, loginHref = "/login
33
33
  })();
34
34
  }, [status, token]);
35
35
  if (status === "missing-token") {
36
- return (_jsx(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }) }));
36
+ return (_jsxs(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }));
37
37
  }
38
38
  if (status === "verifying") {
39
39
  return (_jsx(AuthCard, { children: _jsx("div", { className: "p-6", children: _jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t("auth.verifyEmail.verifying") }) }) }));
40
40
  }
41
41
  if (status === "success") {
42
- return (_jsx(AuthCard, { title: title ?? t("auth.verifyEmail.successTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.successBody") }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.verifyEmail.goToLogin") })] }) }));
42
+ return (_jsxs(AuthCard, { title: title ?? t("auth.verifyEmail.successTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.successBody") }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.verifyEmail.goToLogin") })] }));
43
43
  }
44
44
  // status === "error"
45
- return (_jsx(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.errorBody") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }) }));
45
+ return (_jsxs(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.errorBody") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }));
46
46
  }
@@ -127,9 +127,9 @@ export function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title,
127
127
  e?.preventDefault();
128
128
  void confirmSetup();
129
129
  };
130
- return (_jsx(AuthCard, { title: title ?? t("auth.mfa.setup.title"), subtitle: subtitle ?? t("auth.mfa.setup.subtitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [error !== null ? (_jsx(Banner, { variant: "error", children: error === "too_many_attempts" && retryAfterSeconds !== null
131
- ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
132
- : t(reasonToKey(error)) })) : null, !setup && (_jsx(Section, { testId: "mfa-setup-preauth-intro", actions: _jsx(Button, { variant: "primary", onClick: () => void startSetup(), loading: busy, disabled: busy, children: t("auth.mfa.setup.start") }), children: _jsx("span", { className: "text-sm text-muted-foreground", children: t("auth.mfa.setup.intro") }) })), setup && (_jsx(Form, { onSubmit: onSubmit, children: _jsxs(Section, { testId: "mfa-setup-preauth-setup", actions: _jsx(Button, { type: "submit", variant: "primary", loading: busy, disabled: busy || !acknowledged || code.length !== 6, children: t("auth.mfa.setup.confirm") }), children: [_jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.scanTitle") }), _jsx("div", { className: "h-40 w-40",
133
- // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
134
- dangerouslySetInnerHTML: { __html: setup.qrSvg } }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.manualEntry") }), _jsx("code", { className: "inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.secretParam })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.recoveryTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.recoveryHint") }), _jsx("code", { className: "block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.recoveryCodes.join("\n") }), _jsx(Field, { id: "mfa-setup-preauth-ack", label: t("auth.mfa.enable.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-setup-preauth-ack", name: "mfa-setup-preauth-ack", value: acknowledged, onChange: setAcknowledged }) })] }), _jsx(Field, { id: "mfa-setup-preauth-code", label: t("auth.mfa.enable.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-setup-preauth-code", name: "mfa-setup-preauth-code", value: code, onChange: setCode, disabled: busy || !acknowledged, autoComplete: "one-time-code" }) })] }) })), onCancel ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: busy, children: t("auth.mfa.verify.backToLogin") }) })) : null] }) }));
130
+ return (_jsxs(AuthCard, { title: title ?? t("auth.mfa.setup.title"), subtitle: subtitle ?? t("auth.mfa.setup.subtitle"), children: [error !== null ? (_jsx(Banner, { variant: "error", children: error === "too_many_attempts" && retryAfterSeconds !== null
131
+ ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
132
+ : t(reasonToKey(error)) })) : null, !setup && (_jsx(Section, { testId: "mfa-setup-preauth-intro", actions: _jsx(Button, { variant: "primary", onClick: () => void startSetup(), loading: busy, disabled: busy, children: t("auth.mfa.setup.start") }), children: _jsx("span", { className: "text-sm text-muted-foreground", children: t("auth.mfa.setup.intro") }) })), setup && (_jsx(Form, { onSubmit: onSubmit, children: _jsxs(Section, { testId: "mfa-setup-preauth-setup", actions: _jsx(Button, { type: "submit", variant: "primary", loading: busy, disabled: busy || !acknowledged || code.length !== 6, children: t("auth.mfa.setup.confirm") }), children: [_jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.scanTitle") }), _jsx("div", { className: "h-40 w-40",
133
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
134
+ dangerouslySetInnerHTML: { __html: setup.qrSvg } }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.manualEntry") }), _jsx("code", { className: "inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.secretParam })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.recoveryTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.recoveryHint") }), _jsx("code", { className: "block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.recoveryCodes.join("\n") }), _jsx(Field, { id: "mfa-setup-preauth-ack", label: t("auth.mfa.enable.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-setup-preauth-ack", name: "mfa-setup-preauth-ack", value: acknowledged, onChange: setAcknowledged }) })] }), _jsx(Field, { id: "mfa-setup-preauth-code", label: t("auth.mfa.enable.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-setup-preauth-code", name: "mfa-setup-preauth-code", value: code, onChange: setCode, disabled: busy || !acknowledged, autoComplete: "one-time-code" }) })] }) })), onCancel ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: busy, children: t("auth.mfa.verify.backToLogin") }) })) : null] }));
135
135
  }
@@ -52,7 +52,7 @@ export function MfaVerifyScreen({ challengeToken, title, subtitle, submitLabel,
52
52
  e?.preventDefault();
53
53
  void doSubmit();
54
54
  };
55
- return (_jsx(AuthCard, { title: title ?? t("auth.mfa.verify.title"), subtitle: subtitle ?? t("auth.mfa.verify.subtitle"), children: _jsx("div", { className: "p-6 pt-0 flex flex-col gap-4", children: _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "mfa-verify-code", label: t("auth.mfa.verify.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-verify-code", name: "mfa-verify-code", value: code, onChange: setCode, disabled: submitting, required: true, autoComplete: "one-time-code" }) }), error !== null ? _jsx(Banner, { variant: "error", children: t(reasonToKey(error)) }) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
56
- ? t("auth.mfa.verify.submitting")
57
- : (submitLabel ?? t("auth.mfa.verify.submit")) }), onCancel ? (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: submitting, children: t("auth.mfa.verify.backToLogin") })) : null] }) }) }));
55
+ return (_jsx(AuthCard, { title: title ?? t("auth.mfa.verify.title"), subtitle: subtitle ?? t("auth.mfa.verify.subtitle"), children: _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "mfa-verify-code", label: t("auth.mfa.verify.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-verify-code", name: "mfa-verify-code", value: code, onChange: setCode, disabled: submitting, required: true, autoComplete: "one-time-code" }) }), error !== null ? _jsx(Banner, { variant: "error", children: t(reasonToKey(error)) }) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
56
+ ? t("auth.mfa.verify.submitting")
57
+ : (submitLabel ?? t("auth.mfa.verify.submit")) }), onCancel ? (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: submitting, children: t("auth.mfa.verify.backToLogin") })) : null] }) }));
58
58
  }
@@ -44,6 +44,7 @@ function emailEnvelopeFrom(data) {
44
44
  if (!data)
45
45
  return {};
46
46
  const from = typeof data["from"] === "string" ? data["from"] : undefined;
47
+ const fromName = typeof data["fromName"] === "string" ? data["fromName"] : undefined;
47
48
  const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
48
49
  const autoHeaders = listUnsubscribeHeaders(data);
49
50
  const explicitHeaders = stringHeaders(data["headers"]);
@@ -55,7 +56,12 @@ function emailEnvelopeFrom(data) {
55
56
  const headers = (!overridesUnsubscribe && autoHeaders) || explicitHeaders
56
57
  ? { ...(overridesUnsubscribe ? undefined : autoHeaders), ...explicitHeaders }
57
58
  : undefined;
58
- return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
59
+ return {
60
+ ...(from && { from }),
61
+ ...(fromName && { fromName }),
62
+ ...(replyTo && { replyTo }),
63
+ ...(headers && { headers }),
64
+ };
59
65
  }
60
66
  export function createEmailChannel(options) {
61
67
  const { transport, renderer, resolveEmail } = options;
@@ -9,15 +9,16 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
9
9
  // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
10
10
  // in prod.
11
11
  export function guardEmailMessage(message) {
12
- // Every envelope address, not just `to`: a ciphertext From or Reply-To is
12
+ // Every envelope address, not just `to`: a ciphertext From, From-Name or Reply-To is
13
13
  // the same garbage as a ciphertext recipient — refuse rather than mail it.
14
14
  for (const [label, address] of [
15
- ["recipient", message.to],
16
- ["from", message.from],
17
- ["reply-to", message.replyTo],
15
+ ["recipient address", message.to],
16
+ ["from address", message.from],
17
+ ["from name", message.fromName],
18
+ ["reply-to address", message.replyTo],
18
19
  ]) {
19
20
  if (address?.includes(CIPHERTEXT_MARKER)) {
20
- throw new Error(`[channel-email] refusing to send: ${label} address is a PII ciphertext ` +
21
+ throw new Error(`[channel-email] refusing to send: ${label} is a PII ciphertext ` +
21
22
  `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`);
22
23
  }
23
24
  }
@@ -16,6 +16,27 @@
16
16
  // Volume-Apps muss der Caller eine eigene Implementation mit
17
17
  // dedizierter Queue (BullMQ + retry) drüberlegen.
18
18
  import { createTransport } from "nodemailer";
19
+ import addressparser from "nodemailer/lib/addressparser";
20
+ function isControlCharacter(char) {
21
+ const code = char.charCodeAt(0);
22
+ return code <= 0x1f || code === 0x7f;
23
+ }
24
+ // Control characters in a display name would let a caller inject headers.
25
+ function sanitizedDisplayName(name) {
26
+ const cleaned = name
27
+ ?.split("")
28
+ .map((char) => (isControlCharacter(char) ? " " : char))
29
+ .join("")
30
+ .trim();
31
+ return cleaned === undefined || cleaned === "" ? undefined : cleaned;
32
+ }
33
+ function fromHeader(address, fromName) {
34
+ const name = sanitizedDisplayName(fromName);
35
+ if (name === undefined)
36
+ return address;
37
+ const [parsed] = addressparser(address, { flatten: true });
38
+ return parsed?.address ? { name, address: parsed.address } : address;
39
+ }
19
40
  export function createSmtpTransport(options) {
20
41
  const transporter = createTransport({
21
42
  host: options.host,
@@ -29,7 +50,7 @@ export function createSmtpTransport(options) {
29
50
  return {
30
51
  async send(message) {
31
52
  await transporter.sendMail({
32
- from: message.from ?? options.from,
53
+ from: fromHeader(message.from ?? options.from, message.fromName),
33
54
  to: message.to,
34
55
  subject: message.subject,
35
56
  html: message.html,
@@ -3,6 +3,7 @@ export type EmailMessage = {
3
3
  readonly subject: string;
4
4
  readonly html: string;
5
5
  readonly from?: string;
6
+ readonly fromName?: string;
6
7
  readonly replyTo?: string;
7
8
  readonly headers?: Readonly<Record<string, string>>;
8
9
  };
@@ -89,7 +89,7 @@ export function createSubmitHandler(opts) {
89
89
  locale: resolveWaitlistMailLocale(payload.locale),
90
90
  }));
91
91
  await sendBestEffort(ctx, "admin notice", async (notify) => {
92
- const adminRecipient = opts.notifyRecipient?.(ctx);
92
+ const adminRecipient = await opts.notifyRecipient?.(ctx);
93
93
  // skip: no admin recipient configured
94
94
  if (!adminRecipient)
95
95
  return;
@@ -13,8 +13,9 @@ export type WaitlistInviteOptions = {
13
13
  };
14
14
  export type WaitlistOptions = {
15
15
  /** Address for an admin notification mail on each new entry. Null, an empty
16
- * string or an absent option sends none. Best effort, never fails the submit. */
17
- readonly notifyRecipient?: (ctx: HandlerContext) => string | null;
16
+ * string or an absent option sends none. Best effort, never fails the submit,
17
+ * also when the resolver throws or rejects. */
18
+ readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
18
19
  /** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
19
20
  readonly invite?: WaitlistInviteOptions;
20
21
  /** Default: 5 submits per IP per 10 minutes. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.341.0",
3
+ "version": "0.342.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -503,12 +503,12 @@
503
503
  }
504
504
  },
505
505
  "dependencies": {
506
- "@cosmicdrift/kumiko-dispatcher-live": "0.341.0",
507
- "@cosmicdrift/kumiko-framework": "0.341.0",
508
- "@cosmicdrift/kumiko-headless": "0.341.0",
509
- "@cosmicdrift/kumiko-renderer": "0.341.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.341.0",
511
- "@cosmicdrift/kumiko-types": "0.341.0",
506
+ "@cosmicdrift/kumiko-dispatcher-live": "0.342.0",
507
+ "@cosmicdrift/kumiko-framework": "0.342.0",
508
+ "@cosmicdrift/kumiko-headless": "0.342.0",
509
+ "@cosmicdrift/kumiko-renderer": "0.342.0",
510
+ "@cosmicdrift/kumiko-renderer-web": "0.342.0",
511
+ "@cosmicdrift/kumiko-types": "0.342.0",
512
512
  "@mollie/api-client": "^4.5.0",
513
513
  "@node-rs/argon2": "^2.0.2",
514
514
  "@types/mailparser": "^3.4.6",
@@ -1025,8 +1025,8 @@
1025
1025
  ],
1026
1026
  "devDependencies": {
1027
1027
  "@testing-library/user-event": "^14.6.1",
1028
- "@cosmicdrift/kumiko-locale-de": "0.341.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.341.0",
1028
+ "@cosmicdrift/kumiko-locale-de": "0.342.0",
1029
+ "@cosmicdrift/kumiko-locale-es": "0.342.0",
1030
1030
  "jsqr": "^1.4.0"
1031
1031
  }
1032
1032
  }
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "AuthCard styling hooks (className, headerClassName, titleClassName, bodyClassName)",
6
+ "detail": "AuthCard takes className, headerClassName, titleClassName and bodyClassName\n`AuthCard` renders the body wrapper (`p-6 pt-0 flex flex-col gap-4`) itself and accepts `className` (Card), `headerClassName`, `titleClassName` and `bodyClassName`, merged with tailwind-merge over the defaults. The auth-email-password and auth-mfa screens no longer carry their own body wrapper; the rendered DOM stays the same. The session bootstrap error screen keeps its former bottom padding through `bodyClassName`."
7
+ },
8
+ {
9
+ "version": "0.342.0",
10
+ "type": "improvement",
11
+ "title": "Bootstrap seed can write with app tenant roles",
12
+ "detail": "Bootstrap seed gets `dispatchWriteAs` to write with app tenant roles instead of SystemAdmin."
13
+ },
2
14
  {
3
15
  "version": "0.341.0",
4
16
  "type": "breaking",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "EmailMessage.fromName sets the From display name",
6
+ "detail": "channel-email: fromName sets the display name of the From header\n`EmailMessage` gets an optional `fromName`, and the email channel passes `data.fromName` through like `replyTo`. The SMTP transport combines it with the address of `message.from` or the transport default, so `from: \"App <noreply@x>\"` plus `fromName: \"Tenant via veridom\"` sends from `noreply@x` under the new name. Control characters in the name are replaced by spaces before sending, and an empty name behaves like no name. The PII ciphertext guard refuses a ciphertext `fromName`."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "Waitlist notifyRecipient may return a promise",
6
+ "detail": "Waitlist `notifyRecipient` may return a promise, so apps can read the admin address from config"
7
+ },
2
8
  {
3
9
  "version": "0.341.0",
4
10
  "type": "improvement",