@cosmicdrift/kumiko-bundled-features 0.341.0 → 0.343.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 (117) hide show
  1. package/dist/audit/constants.d.ts +7 -0
  2. package/dist/audit/constants.js +7 -0
  3. package/dist/audit/escape-hatch-audit-sink.d.ts +1 -0
  4. package/dist/audit/escape-hatch-audit-sink.js +1 -0
  5. package/dist/audit/feature.js +34 -7
  6. package/dist/audit/run-escape-hatch-retention.d.ts +11 -0
  7. package/dist/audit/run-escape-hatch-retention.js +91 -0
  8. package/dist/auth-email-password/bootstrap.d.ts +8 -2
  9. package/dist/auth-email-password/bootstrap.js +7 -2
  10. package/dist/auth-email-password/email-templates.d.ts +4 -0
  11. package/dist/auth-email-password/email-templates.js +69 -16
  12. package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
  13. package/dist/auth-email-password/handlers/signup-request.write.js +4 -1
  14. package/dist/auth-email-password/handlers/token-request-handler.js +3 -0
  15. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  16. package/dist/auth-email-password/magic-link-mail.js +2 -0
  17. package/dist/auth-email-password/web/auth-form-primitives.d.ts +7 -1
  18. package/dist/auth-email-password/web/auth-form-primitives.js +5 -5
  19. package/dist/auth-email-password/web/confirm-account-unlock-screen.js +3 -3
  20. package/dist/auth-email-password/web/forgot-password-screen.js +2 -2
  21. package/dist/auth-email-password/web/invite-accept-screen.js +9 -9
  22. package/dist/auth-email-password/web/login-screen.js +8 -8
  23. package/dist/auth-email-password/web/request-account-unlock-screen.js +2 -2
  24. package/dist/auth-email-password/web/reset-password-screen.js +3 -3
  25. package/dist/auth-email-password/web/session-bootstrap-error.js +3 -3
  26. package/dist/auth-email-password/web/signup-complete-screen.js +3 -3
  27. package/dist/auth-email-password/web/signup-screen.js +2 -2
  28. package/dist/auth-email-password/web/verify-email-screen.js +3 -3
  29. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +5 -5
  30. package/dist/auth-mfa/web/mfa-verify-screen.js +3 -3
  31. package/dist/channel-discord/feature.js +1 -0
  32. package/dist/channel-email/email-channel.js +7 -1
  33. package/dist/channel-email/feature.js +1 -0
  34. package/dist/channel-email/pii-guard.js +6 -5
  35. package/dist/channel-email/smtp-transport.js +22 -1
  36. package/dist/channel-email/types.d.ts +1 -0
  37. package/dist/channel-in-app/feature.js +1 -0
  38. package/dist/channel-push/feature.js +1 -0
  39. package/dist/channel-slack/feature.js +1 -0
  40. package/dist/channel-teams/feature.js +1 -0
  41. package/dist/channel-teams/teams-channel.d.ts +2 -1
  42. package/dist/channel-teams/teams-channel.js +12 -0
  43. package/dist/channel-telegram/feature.js +1 -0
  44. package/dist/delivery/chat-webhook-channel.d.ts +2 -1
  45. package/dist/delivery/chat-webhook-channel.js +5 -2
  46. package/dist/delivery/chat-webhook-sender.d.ts +8 -1
  47. package/dist/delivery/chat-webhook-sender.js +45 -4
  48. package/dist/delivery/constants.d.ts +1 -1
  49. package/dist/delivery/constants.js +1 -1
  50. package/dist/delivery/delivery-service.d.ts +2 -1
  51. package/dist/delivery/delivery-service.js +55 -4
  52. package/dist/delivery/events.d.ts +19 -1
  53. package/dist/delivery/events.js +11 -1
  54. package/dist/delivery/feature.js +29 -5
  55. package/dist/delivery/handlers/log.query.d.ts +4 -2
  56. package/dist/delivery/handlers/log.query.js +46 -18
  57. package/dist/delivery/i18n.js +36 -0
  58. package/dist/delivery/index.d.ts +2 -1
  59. package/dist/delivery/index.js +1 -0
  60. package/dist/delivery/jobs.js +24 -10
  61. package/dist/delivery/notify-factory.d.ts +5 -0
  62. package/dist/delivery/notify-factory.js +6 -0
  63. package/dist/delivery/public-names.d.ts +4 -0
  64. package/dist/delivery/public-names.js +4 -0
  65. package/dist/delivery/tables.js +3 -0
  66. package/dist/delivery/testing.d.ts +2 -0
  67. package/dist/delivery/testing.js +2 -1
  68. package/dist/delivery/types.d.ts +5 -3
  69. package/dist/delivery/web/client-plugin.js +9 -1
  70. package/dist/delivery/web/delivery-channel-cell.d.ts +3 -0
  71. package/dist/delivery/web/delivery-channel-cell.js +10 -0
  72. package/dist/delivery/web/delivery-error-cell.d.ts +3 -0
  73. package/dist/delivery/web/delivery-error-cell.js +16 -0
  74. package/dist/delivery/web/delivery-status-cell.d.ts +1 -1
  75. package/dist/delivery/web/delivery-status-cell.js +11 -1
  76. package/dist/delivery/web/delivery-time-cell.d.ts +3 -0
  77. package/dist/delivery/web/delivery-time-cell.js +6 -0
  78. package/dist/delivery/web/delivery-type-cell.d.ts +3 -0
  79. package/dist/delivery/web/delivery-type-cell.js +17 -0
  80. package/dist/secrets/constants.d.ts +6 -0
  81. package/dist/secrets/constants.js +8 -0
  82. package/dist/secrets/feature.d.ts +3 -0
  83. package/dist/secrets/feature.js +3 -0
  84. package/dist/secrets/i18n.js +4 -3
  85. package/dist/secrets/index.d.ts +3 -2
  86. package/dist/secrets/index.js +3 -2
  87. package/dist/secrets/write-gate.d.ts +1 -0
  88. package/dist/secrets/write-gate.js +14 -6
  89. package/dist/shared/index.d.ts +1 -0
  90. package/dist/shared/index.js +1 -0
  91. package/dist/shared/redact.d.ts +4 -0
  92. package/dist/shared/redact.js +21 -0
  93. package/dist/step-dispatcher/feature.js +2 -1
  94. package/dist/step-dispatcher/mail-runner.d.ts +0 -1
  95. package/dist/step-dispatcher/mail-runner.js +0 -5
  96. package/dist/tenant/web/member-roles-cell.js +1 -1
  97. package/dist/tenant/web/member-status-cell.js +1 -1
  98. package/dist/tenant-lifecycle/feature.js +8 -2
  99. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +2 -1
  100. package/dist/tenant-lifecycle/run-tenant-destroy.js +16 -7
  101. package/dist/user/index.d.ts +1 -0
  102. package/dist/user/index.js +1 -0
  103. package/dist/user/resolve-display-names.d.ts +2 -0
  104. package/dist/user/resolve-display-names.js +24 -0
  105. package/dist/waitlist/handlers/submit.write.js +1 -1
  106. package/dist/waitlist/options.d.ts +3 -2
  107. package/package.json +9 -9
  108. package/src/audit/changes.json +6 -0
  109. package/src/auth-email-password/changes.json +19 -0
  110. package/src/channel-email/changes.json +6 -0
  111. package/src/channel-teams/changes.json +8 -0
  112. package/src/delivery/changes.json +32 -0
  113. package/src/jobs/changes.json +6 -0
  114. package/src/secrets/changes.json +6 -0
  115. package/src/waitlist/changes.json +6 -0
  116. /package/dist/{tenant → shared}/web/translate-or-raw.d.ts +0 -0
  117. /package/dist/{tenant → shared}/web/translate-or-raw.js +0 -0
@@ -13,3 +13,10 @@ 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
+ /**
17
+ * Config key (system scope) for how long `escapeHatchUse` audit events are kept. Only a fallback
18
+ * for apps without compliance-profiles; with it mounted, the tenant profile's `auditLog.retention`
19
+ * applies (a tenant override can only lengthen it).
20
+ */
21
+ export declare const ESCAPE_HATCH_RETENTION_DAYS_KEY: "audit:config:escape-hatch-retention-days";
22
+ export declare const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -18,3 +18,10 @@ 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
+ /**
22
+ * Config key (system scope) for how long `escapeHatchUse` audit events are kept. Only a fallback
23
+ * for apps without compliance-profiles; with it mounted, the tenant profile's `auditLog.retention`
24
+ * applies (a tenant override can only lengthen it).
25
+ */
26
+ export const ESCAPE_HATCH_RETENTION_DAYS_KEY = "audit:config:escape-hatch-retention-days";
27
+ export const DEFAULT_ESCAPE_HATCH_RETENTION_DAYS = 90;
@@ -7,6 +7,7 @@ export declare const escapeHatchUsedSchema: z.ZodObject<{
7
7
  handler: z.ZodString;
8
8
  kind: z.ZodEnum<{
9
9
  "acknowledge-cross-tenant": "acknowledge-cross-tenant";
10
+ "cross-tenant-read": "cross-tenant-read";
10
11
  "global-write": "global-write";
11
12
  "identity-switch": "identity-switch";
12
13
  "unsafe-all-tenants": "unsafe-all-tenants";
@@ -11,6 +11,7 @@ const escapeHatchKinds = [
11
11
  "global-write",
12
12
  "identity-switch",
13
13
  "unsafe-all-tenants",
14
+ "cross-tenant-read",
14
15
  ];
15
16
  export const escapeHatchUsedSchema = z.object({
16
17
  handler: z.string(),
@@ -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,11 @@
1
+ import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type ConfigResolver, type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ export declare function isDbConnection(runner: DbRunner): runner is DbConnection;
4
+ export declare function runEscapeHatchRetention(args: {
5
+ readonly db: DbRunner;
6
+ readonly registry: Registry;
7
+ readonly configResolver: ConfigResolver | undefined;
8
+ readonly userId: string;
9
+ }): Promise<{
10
+ readonly deletedCount: number;
11
+ }>;
@@ -0,0 +1,91 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { COMPLIANCE_PROFILES, subtractRetentionSpec, } from "@cosmicdrift/kumiko-framework/compliance";
3
+ import { parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
+ import { pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
6
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
7
+ import { resolveProfileForTenant } from "../compliance-profiles/index.js";
8
+ import { createConfigAccessor } from "../config/index.js";
9
+ import { DEFAULT_ESCAPE_HATCH_RETENTION_DAYS, ESCAPE_HATCH_RETENTION_DAYS_KEY, } from "./constants.js";
10
+ import { ESCAPE_HATCH_USE_AGGREGATE_TYPE, escapeHatchUsedSchema, } from "./escape-hatch-audit-sink.js";
11
+ // pruneEvents opens its own transaction, so a runner that cannot begin one (a tx handle) is rejected instead of nested.
12
+ export function isDbConnection(runner) {
13
+ return "begin" in runner && typeof runner.begin === "function";
14
+ }
15
+ async function resolveRetentionDays(args) {
16
+ if (!args.configResolver)
17
+ return DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
18
+ const config = createConfigAccessor(args.registry, args.configResolver, SYSTEM_TENANT_ID, args.userId, args.db);
19
+ const raw = await config(ESCAPE_HATCH_RETENTION_DAYS_KEY);
20
+ return typeof raw === "number" && raw >= 1 ? raw : DEFAULT_ESCAPE_HATCH_RETENTION_DAYS;
21
+ }
22
+ // Soft dependency: audit must stay mountable without compliance-profiles, so we probe its entity.
23
+ const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
24
+ const PRUNE_CHUNK_SIZE = 1000;
25
+ // Overrides may only lengthen retention: the earlier cutoff (same `now`) is the longer period.
26
+ function earlierInstant(a, b) {
27
+ return Temporal.Instant.compare(a, b) <= 0 ? a : b;
28
+ }
29
+ async function resolveTenantCutoff(db, tenantId, now) {
30
+ const { profile } = await resolveProfileForTenant({ db, tenantId });
31
+ const effective = subtractRetentionSpec(now, profile.auditLog.retention);
32
+ const base = subtractRetentionSpec(now, COMPLIANCE_PROFILES[profile.key].auditLog.retention);
33
+ return earlierInstant(effective, base);
34
+ }
35
+ // A cross-tenant audit (e.g. identity-switch) lives under the actor's tenant but must also
36
+ // satisfy the target tenant's retention. A malformed payload falls back to the storing tenant.
37
+ function targetTenantOf(payload) {
38
+ const parsed = escapeHatchUsedSchema.safeParse(payload);
39
+ return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
40
+ }
41
+ async function pruneByTenantProfiles(db) {
42
+ const rows = await selectMany(db, eventsTable, {
43
+ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
44
+ });
45
+ const now = getTemporal().Now.instant();
46
+ const cutoffByTenant = new Map();
47
+ const cutoffOf = async (tenantId) => {
48
+ const cached = cutoffByTenant.get(tenantId);
49
+ if (cached)
50
+ return cached;
51
+ const cutoff = await resolveTenantCutoff(db, tenantId, now);
52
+ cutoffByTenant.set(tenantId, cutoff);
53
+ return cutoff;
54
+ };
55
+ const expiredAggregateIds = [];
56
+ for (const row of rows) {
57
+ const targetTenantId = targetTenantOf(row.payload);
58
+ const storeCutoff = await cutoffOf(row.tenantId);
59
+ const cutoff = targetTenantId
60
+ ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
61
+ : storeCutoff;
62
+ if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
63
+ expiredAggregateIds.push(row.aggregateId);
64
+ }
65
+ }
66
+ let deletedCount = 0;
67
+ for (let i = 0; i < expiredAggregateIds.length; i += PRUNE_CHUNK_SIZE) {
68
+ const result = await pruneEvents(db, {
69
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
70
+ aggregateIds: expiredAggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
71
+ olderThan: now,
72
+ });
73
+ deletedCount += result.deletedCount;
74
+ }
75
+ return deletedCount;
76
+ }
77
+ export async function runEscapeHatchRetention(args) {
78
+ const { db } = args;
79
+ if (!isDbConnection(db)) {
80
+ throw new Error("audit escape-hatch retention: a transaction-free DbConnection is required");
81
+ }
82
+ if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
83
+ const olderThanDays = await resolveRetentionDays({ ...args, db });
84
+ const { deletedCount } = await pruneEvents(db, {
85
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
86
+ olderThanDays,
87
+ });
88
+ return { deletedCount };
89
+ }
90
+ return { deletedCount: await pruneByTenantProfiles(db) };
91
+ }
@@ -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 });
@@ -6,6 +6,10 @@ export type RenderTokenContentArgs = {
6
6
  readonly locale?: AuthMailLocale;
7
7
  /** Optional app name for subject + intro. Default from auth.mail.appNameDefault. */
8
8
  readonly appName?: string;
9
+ /** ISO instant the token was issued; the validity duration is measured from it. Default: now. */
10
+ readonly issuedAt?: string;
11
+ /** IANA zone the expiry time is shown in. Default "UTC"; an invalid zone falls back to "UTC". */
12
+ readonly timeZone?: string;
9
13
  };
10
14
  export type RenderInviteEmailArgs = RenderTokenContentArgs & {
11
15
  readonly role: string;
@@ -15,31 +15,31 @@ export const AUTH_MAIL_EN = {
15
15
  "auth.mail.reset.greeting": "Hi,",
16
16
  "auth.mail.reset.intro": "you requested a password reset for {app}. Click the link below to set a new password:",
17
17
  "auth.mail.reset.button": "Reset password",
18
- "auth.mail.reset.expiry": "The link expires on {when}.",
18
+ "auth.mail.reset.expiry": "The link is valid for {duration} (until {when}).",
19
19
  "auth.mail.reset.ignore": "If you didn't request a reset, you can safely ignore this email — your password won't change.",
20
20
  "auth.mail.verify.subject": "{app} — Verify your email",
21
21
  "auth.mail.verify.greeting": "Welcome,",
22
22
  "auth.mail.verify.intro": "please verify your email address for {app} to activate your account:",
23
23
  "auth.mail.verify.button": "Verify email",
24
- "auth.mail.verify.expiry": "The link expires on {when}.",
24
+ "auth.mail.verify.expiry": "The link is valid for {duration} (until {when}).",
25
25
  "auth.mail.verify.ignore": "If you didn't create this account, you can ignore this email.",
26
26
  "auth.mail.activation.subject": "{app} — Activate your account",
27
27
  "auth.mail.activation.greeting": "Welcome,",
28
28
  "auth.mail.activation.intro": "click the link below to activate your {app} account. The next step is choosing your password:",
29
29
  "auth.mail.activation.button": "Activate account",
30
- "auth.mail.activation.expiry": "The link expires on {when}.",
30
+ "auth.mail.activation.expiry": "The link is valid for {duration} (until {when}).",
31
31
  "auth.mail.activation.ignore": "If you didn't sign up, you can ignore this email — no account is created until you open the link.",
32
32
  "auth.mail.invite.subject": "{app} — Workspace invitation",
33
33
  "auth.mail.invite.greeting": "Hi,",
34
34
  "auth.mail.invite.intro": "you've been invited to a {app} workspace as {role}. Click the link below to accept:",
35
35
  "auth.mail.invite.button": "Accept invitation",
36
- "auth.mail.invite.expiry": "The link expires on {when}.",
36
+ "auth.mail.invite.expiry": "The link is valid for {duration} (until {when}).",
37
37
  "auth.mail.invite.ignore": "If you weren't expecting this invitation, you can ignore this email.",
38
38
  "auth.mail.unlock.subject": "{app} — Unlock your account",
39
39
  "auth.mail.unlock.greeting": "Hi,",
40
40
  "auth.mail.unlock.intro": "your {app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:",
41
41
  "auth.mail.unlock.button": "Unlock account",
42
- "auth.mail.unlock.expiry": "The link expires on {when}.",
42
+ "auth.mail.unlock.expiry": "The link is valid for {duration} (until {when}).",
43
43
  "auth.mail.unlock.ignore": "If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
44
44
  };
45
45
  registerMailTranslations("en", AUTH_MAIL_EN);
@@ -73,7 +73,7 @@ export function renderResetPasswordEmail(args) {
73
73
  intro: t(locale, "auth.mail.reset.intro", { app }),
74
74
  buttonLabel: t(locale, "auth.mail.reset.button"),
75
75
  buttonUrl: args.url,
76
- expiry: t(locale, "auth.mail.reset.expiry", { when: formatExpiry(args.expiresAt) }),
76
+ expiry: t(locale, "auth.mail.reset.expiry", expiryParams(args, locale)),
77
77
  ignore: t(locale, "auth.mail.reset.ignore"),
78
78
  });
79
79
  }
@@ -87,7 +87,7 @@ export function renderUnlockAccountEmail(args) {
87
87
  intro: t(locale, "auth.mail.unlock.intro", { app }),
88
88
  buttonLabel: t(locale, "auth.mail.unlock.button"),
89
89
  buttonUrl: args.url,
90
- expiry: t(locale, "auth.mail.unlock.expiry", { when: formatExpiry(args.expiresAt) }),
90
+ expiry: t(locale, "auth.mail.unlock.expiry", expiryParams(args, locale)),
91
91
  ignore: t(locale, "auth.mail.unlock.ignore"),
92
92
  });
93
93
  }
@@ -101,7 +101,7 @@ export function renderVerifyEmail(args) {
101
101
  intro: t(locale, "auth.mail.verify.intro", { app }),
102
102
  buttonLabel: t(locale, "auth.mail.verify.button"),
103
103
  buttonUrl: args.url,
104
- expiry: t(locale, "auth.mail.verify.expiry", { when: formatExpiry(args.expiresAt) }),
104
+ expiry: t(locale, "auth.mail.verify.expiry", expiryParams(args, locale)),
105
105
  ignore: t(locale, "auth.mail.verify.ignore"),
106
106
  });
107
107
  }
@@ -115,7 +115,7 @@ export function renderActivationEmail(args) {
115
115
  intro: t(locale, "auth.mail.activation.intro", { app }),
116
116
  buttonLabel: t(locale, "auth.mail.activation.button"),
117
117
  buttonUrl: args.url,
118
- expiry: t(locale, "auth.mail.activation.expiry", { when: formatExpiry(args.expiresAt) }),
118
+ expiry: t(locale, "auth.mail.activation.expiry", expiryParams(args, locale)),
119
119
  ignore: t(locale, "auth.mail.activation.ignore"),
120
120
  });
121
121
  }
@@ -129,19 +129,72 @@ export function renderInviteEmail(args) {
129
129
  intro: t(locale, "auth.mail.invite.intro", { app, role: args.role }),
130
130
  buttonLabel: t(locale, "auth.mail.invite.button"),
131
131
  buttonUrl: args.url,
132
- expiry: t(locale, "auth.mail.invite.expiry", { when: formatExpiry(args.expiresAt) }),
132
+ expiry: t(locale, "auth.mail.invite.expiry", expiryParams(args, locale)),
133
133
  ignore: t(locale, "auth.mail.invite.ignore"),
134
134
  });
135
135
  }
136
- function formatExpiry(iso) {
136
+ const MS_PER_MINUTE = 60_000;
137
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE;
138
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
139
+ // Rounds to whole minutes first so a few ms between issuing and signing still
140
+ // read as "1 day"; a larger unit is only used when it divides the span exactly.
141
+ function durationParts(ms) {
142
+ const minutes = Math.max(1, Math.round(ms / MS_PER_MINUTE));
143
+ const minutesPerHour = MS_PER_HOUR / MS_PER_MINUTE;
144
+ const minutesPerDay = MS_PER_DAY / MS_PER_MINUTE;
145
+ if (minutes % minutesPerDay === 0)
146
+ return { amount: minutes / minutesPerDay, unit: "day" };
147
+ if (minutes % minutesPerHour === 0)
148
+ return { amount: minutes / minutesPerHour, unit: "hour" };
149
+ return { amount: minutes, unit: "minute" };
150
+ }
151
+ function formatDuration(ms, locale) {
152
+ const { amount, unit } = durationParts(ms);
153
+ const options = { style: "unit", unit, unitDisplay: "long" };
137
154
  try {
138
- const z = Temporal.Instant.from(iso).toZonedDateTimeISO("UTC");
139
- return `${z.year}-${pad2(z.month)}-${pad2(z.day)} ${pad2(z.hour)}:${pad2(z.minute)} UTC`;
155
+ return new Intl.NumberFormat(locale, options).format(amount);
140
156
  }
141
157
  catch {
142
- return iso;
158
+ return new Intl.NumberFormat("en", options).format(amount);
143
159
  }
144
160
  }
145
- function pad2(n) {
146
- return String(n).padStart(2, "0");
161
+ // Intl rejects timeZoneName together with dateStyle/timeStyle, so the zone
162
+ // abbreviation is read from a second formatter and appended.
163
+ function formatWhen(expiresAt, locale, timeZone) {
164
+ const build = (tag, zone) => {
165
+ const at = expiresAt.epochMilliseconds;
166
+ const dateTime = new Intl.DateTimeFormat(tag, {
167
+ dateStyle: "medium",
168
+ timeStyle: "short",
169
+ timeZone: zone,
170
+ }).format(at);
171
+ const zoneName = new Intl.DateTimeFormat(tag, { timeZone: zone, timeZoneName: "short" })
172
+ .formatToParts(at)
173
+ .find((part) => part.type === "timeZoneName")?.value;
174
+ return zoneName ? `${dateTime} ${zoneName}` : dateTime;
175
+ };
176
+ try {
177
+ return build(locale, timeZone);
178
+ }
179
+ catch {
180
+ try {
181
+ return build(locale, "UTC");
182
+ }
183
+ catch {
184
+ return build("en", "UTC");
185
+ }
186
+ }
187
+ }
188
+ function expiryParams(args, locale) {
189
+ try {
190
+ const expiresAt = Temporal.Instant.from(args.expiresAt);
191
+ const issuedAt = args.issuedAt ? Temporal.Instant.from(args.issuedAt) : Temporal.Now.instant();
192
+ return {
193
+ duration: formatDuration(expiresAt.epochMilliseconds - issuedAt.epochMilliseconds, locale),
194
+ when: formatWhen(expiresAt, locale, args.timeZone ?? "UTC"),
195
+ };
196
+ }
197
+ catch {
198
+ return { duration: args.expiresAt, when: args.expiresAt };
199
+ }
147
200
  }
@@ -67,7 +67,8 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
67
67
  }
68
68
  const email = request.email.toLowerCase();
69
69
  const tenantId = inviter.tenantId;
70
- const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
70
+ const issuedAt = Temporal.Now.instant();
71
+ const expiresAt = issuedAt.add({ seconds: ttlSeconds });
71
72
  // The unique index allows one row per (tenantId, email). Whatever its
72
73
  // status, a re-invite resets it to pending with a fresh token.
73
74
  const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
@@ -126,6 +127,8 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
126
127
  appUrl: opts.appUrl,
127
128
  token,
128
129
  expiresAt: expiresAt.toString(),
130
+ issuedAt: issuedAt.toString(),
131
+ timeZone: ctx.tz.user,
129
132
  ...(opts.appName !== undefined && { appName: opts.appName }),
130
133
  locale,
131
134
  });
@@ -104,7 +104,8 @@ export function createSignupRequestHandler(opts) {
104
104
  // uses randomBytes from node:crypto, the same source as CSRF/
105
105
  // session tokens.
106
106
  const token = generateToken();
107
- const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
107
+ const issuedAt = Temporal.Now.instant();
108
+ const expiresAt = issuedAt.add({ seconds: ttlSeconds });
108
109
  const expiresAtIso = expiresAt.toString();
109
110
  await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
110
111
  // A handover grant in this request re-verifies and replaces the prior
@@ -158,6 +159,8 @@ export function createSignupRequestHandler(opts) {
158
159
  appUrl: opts.appUrl,
159
160
  token,
160
161
  expiresAt: expiresAtIso,
162
+ issuedAt: issuedAt.toString(),
163
+ timeZone: ctx.tz.user,
161
164
  ...(opts.appName !== undefined && { appName: opts.appName }),
162
165
  locale,
163
166
  });
@@ -55,6 +55,7 @@ export function createTokenRequestHandler(spec, opts) {
55
55
  return { isSuccess: true, data };
56
56
  }
57
57
  const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
58
+ const issuedAt = expiresAt.subtract({ minutes: ttl }).toString();
58
59
  const locale = resolveHandlerMailLocale(ctx, opts.locale);
59
60
  await dispatchMagicLinkMail(ctx.notify, {
60
61
  handlerName: spec.handlerName,
@@ -67,6 +68,8 @@ export function createTokenRequestHandler(spec, opts) {
67
68
  appUrl: opts.appUrl,
68
69
  token,
69
70
  expiresAt: expiresAt.toString(),
71
+ issuedAt,
72
+ timeZone: ctx.tz.user,
70
73
  ...(opts.appName !== undefined && { appName: opts.appName }),
71
74
  locale,
72
75
  });
@@ -12,6 +12,8 @@ export type MagicLinkMailParams = {
12
12
  readonly expiresAt: string;
13
13
  readonly appName?: string;
14
14
  readonly locale?: AuthMailLocale;
15
+ readonly issuedAt?: string;
16
+ readonly timeZone?: string;
15
17
  };
16
18
  export declare function resolveHandlerMailLocale(ctx: {
17
19
  readonly locale?: string;
@@ -40,6 +40,8 @@ export async function dispatchMagicLinkMail(notify, spec, params) {
40
40
  expiresAt: params.expiresAt,
41
41
  ...(params.locale !== undefined && { locale: params.locale }),
42
42
  ...(params.appName !== undefined && { appName: params.appName }),
43
+ ...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
44
+ ...(params.timeZone !== undefined && { timeZone: params.timeZone }),
43
45
  });
44
46
  await notify(spec.notificationType, {
45
47
  route: { email: params.email },
@@ -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
  }