@cosmicdrift/kumiko-bundled-features 0.340.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 (174) 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 +80 -0
  7. package/dist/auth-email-password/bootstrap.js +170 -0
  8. package/dist/auth-email-password/constants.d.ts +2 -0
  9. package/dist/auth-email-password/constants.js +5 -0
  10. package/dist/auth-email-password/errors.d.ts +1 -0
  11. package/dist/auth-email-password/errors.js +6 -0
  12. package/dist/auth-email-password/feature.js +2 -1
  13. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  14. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  15. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  16. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  17. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  18. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  19. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +1 -0
  22. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  23. package/dist/auth-email-password/invite-token-store.js +5 -0
  24. package/dist/auth-email-password/magic-link-mail.js +1 -0
  25. package/dist/auth-email-password/web/auth-form-primitives.d.ts +7 -1
  26. package/dist/auth-email-password/web/auth-form-primitives.js +5 -5
  27. package/dist/auth-email-password/web/confirm-account-unlock-screen.js +3 -3
  28. package/dist/auth-email-password/web/forgot-password-screen.js +2 -2
  29. package/dist/auth-email-password/web/invite-accept-screen.js +9 -9
  30. package/dist/auth-email-password/web/login-screen.js +8 -8
  31. package/dist/auth-email-password/web/request-account-unlock-screen.js +2 -2
  32. package/dist/auth-email-password/web/reset-password-screen.js +3 -3
  33. package/dist/auth-email-password/web/session-bootstrap-error.js +3 -3
  34. package/dist/auth-email-password/web/signup-complete-screen.js +3 -3
  35. package/dist/auth-email-password/web/signup-screen.js +2 -2
  36. package/dist/auth-email-password/web/verify-email-screen.js +3 -3
  37. package/dist/auth-mfa/config.d.ts +1 -1
  38. package/dist/auth-mfa/config.js +8 -3
  39. package/dist/auth-mfa/feature.d.ts +2 -0
  40. package/dist/auth-mfa/feature.js +2 -1
  41. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  42. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  43. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +5 -5
  44. package/dist/auth-mfa/web/mfa-verify-screen.js +3 -3
  45. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  46. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  47. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  48. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  49. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  50. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  51. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  52. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  53. package/dist/channel-discord/discord-channel.d.ts +4 -0
  54. package/dist/channel-discord/discord-channel.js +4 -0
  55. package/dist/channel-discord/feature.js +7 -2
  56. package/dist/channel-discord/index.d.ts +1 -1
  57. package/dist/channel-discord/index.js +1 -1
  58. package/dist/channel-email/email-channel.js +8 -1
  59. package/dist/channel-email/pii-guard.js +6 -5
  60. package/dist/channel-email/smtp-transport.js +22 -1
  61. package/dist/channel-email/types.d.ts +1 -0
  62. package/dist/channel-slack/feature.js +4 -2
  63. package/dist/channel-slack/index.d.ts +1 -1
  64. package/dist/channel-slack/index.js +1 -1
  65. package/dist/channel-slack/slack-channel.d.ts +4 -0
  66. package/dist/channel-slack/slack-channel.js +4 -0
  67. package/dist/channel-teams/feature.js +4 -2
  68. package/dist/channel-teams/index.d.ts +1 -1
  69. package/dist/channel-teams/index.js +1 -1
  70. package/dist/channel-teams/teams-channel.d.ts +4 -0
  71. package/dist/channel-teams/teams-channel.js +4 -0
  72. package/dist/channel-telegram/feature.js +4 -2
  73. package/dist/channel-telegram/index.d.ts +1 -1
  74. package/dist/channel-telegram/index.js +1 -1
  75. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  76. package/dist/channel-telegram/telegram-channel.js +28 -12
  77. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  78. package/dist/delivery/chat-webhook-channel.js +10 -5
  79. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  80. package/dist/delivery/chat-webhook-sender.js +13 -8
  81. package/dist/delivery/delivery-service.d.ts +4 -3
  82. package/dist/delivery/delivery-service.js +53 -33
  83. package/dist/delivery/index.d.ts +2 -2
  84. package/dist/delivery/index.js +2 -2
  85. package/dist/delivery/jobs.js +3 -1
  86. package/dist/delivery/types.d.ts +5 -3
  87. package/dist/legal-pages/feature.d.ts +3 -15
  88. package/dist/legal-pages/feature.js +20 -50
  89. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  90. package/dist/legal-pages/lib/boot-check.js +33 -0
  91. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  92. package/dist/legal-pages/lib/validate-routes.js +17 -0
  93. package/dist/page-render/index.d.ts +2 -1
  94. package/dist/page-render/index.js +1 -0
  95. package/dist/page-render/layout.d.ts +7 -0
  96. package/dist/page-render/layout.js +5 -0
  97. package/dist/page-render/trailing-slash.d.ts +2 -0
  98. package/dist/page-render/trailing-slash.js +15 -0
  99. package/dist/renderer-simple/feature.js +1 -0
  100. package/dist/renderer-simple/index.d.ts +1 -1
  101. package/dist/renderer-simple/index.js +1 -1
  102. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  103. package/dist/renderer-simple/simple-renderer.js +66 -12
  104. package/dist/secrets/announce-secret-change.d.ts +8 -0
  105. package/dist/secrets/announce-secret-change.js +18 -0
  106. package/dist/secrets/feature.js +1 -1
  107. package/dist/secrets/handlers/delete.write.js +2 -0
  108. package/dist/secrets/handlers/set.write.js +6 -1
  109. package/dist/secrets/i18n.d.ts +4 -0
  110. package/dist/secrets/i18n.js +4 -0
  111. package/dist/secrets/secrets-context.d.ts +2 -0
  112. package/dist/secrets/secrets-context.js +3 -0
  113. package/dist/secrets/write-gate.d.ts +5 -1
  114. package/dist/secrets/write-gate.js +24 -1
  115. package/dist/tenant/constants.d.ts +1 -0
  116. package/dist/tenant/constants.js +1 -0
  117. package/dist/tenant/handlers/add-member.write.js +14 -3
  118. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  119. package/dist/tenant/handlers/invitations.query.js +15 -1
  120. package/dist/tenant/index.d.ts +1 -0
  121. package/dist/tenant/index.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +2 -0
  123. package/dist/tenant/invitation-table.js +10 -1
  124. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  125. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  126. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  127. package/dist/tier-engine/feature.js +83 -14
  128. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  129. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  130. package/dist/user/invited-global-roles.d.ts +14 -0
  131. package/dist/user/invited-global-roles.js +35 -0
  132. package/dist/user-data-rights/feature.js +3 -0
  133. package/dist/waitlist/constants.d.ts +33 -0
  134. package/dist/waitlist/constants.js +35 -0
  135. package/dist/waitlist/entity.d.ts +433 -0
  136. package/dist/waitlist/entity.js +64 -0
  137. package/dist/waitlist/feature.d.ts +3 -0
  138. package/dist/waitlist/feature.js +38 -0
  139. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  140. package/dist/waitlist/handlers/invite.write.js +119 -0
  141. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  142. package/dist/waitlist/handlers/list.query.js +10 -0
  143. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  144. package/dist/waitlist/handlers/reject.write.js +80 -0
  145. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  146. package/dist/waitlist/handlers/submit.write.js +112 -0
  147. package/dist/waitlist/i18n.d.ts +6 -0
  148. package/dist/waitlist/i18n.js +31 -0
  149. package/dist/waitlist/index.d.ts +5 -0
  150. package/dist/waitlist/index.js +4 -0
  151. package/dist/waitlist/lib.d.ts +10 -0
  152. package/dist/waitlist/lib.js +24 -0
  153. package/dist/waitlist/mail.d.ts +15 -0
  154. package/dist/waitlist/mail.js +73 -0
  155. package/dist/waitlist/options.d.ts +30 -0
  156. package/dist/waitlist/options.js +8 -0
  157. package/dist/waitlist/payloads.d.ts +14 -0
  158. package/dist/waitlist/payloads.js +47 -0
  159. package/dist/waitlist/screens.d.ts +2 -0
  160. package/dist/waitlist/screens.js +42 -0
  161. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  162. package/dist/waitlist/user-data-hooks.js +71 -0
  163. package/package.json +17 -9
  164. package/src/auth-email-password/changes.json +30 -0
  165. package/src/auth-mfa/changes.json +6 -0
  166. package/src/billing-foundation/changes.json +6 -0
  167. package/src/channel-email/changes.json +6 -0
  168. package/src/channel-telegram/changes.json +9 -0
  169. package/src/delivery/changes.json +7 -0
  170. package/src/files/changes.json +13 -1
  171. package/src/renderer-simple/changes.json +6 -0
  172. package/src/secrets/changes.json +12 -0
  173. package/src/tenant/changes.json +6 -0
  174. package/src/waitlist/changes.json +23 -0
@@ -19,15 +19,18 @@
19
19
  import { generateToken } from "@cosmicdrift/kumiko-framework/api";
20
20
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
21
21
  import { access, assignableAppRolesOf, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
22
- import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
22
+ import { InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
23
23
  import { Temporal } from "temporal-polyfill";
24
24
  import * as z from "zod";
25
25
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
26
26
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
27
27
  // kumiko-lint-ignore cross-feature-import membership-role validation owned by tenant-feature
28
28
  import { findForbiddenMembershipRole, findForbiddenRoleAssignment, reservedMembershipRoleError, unassignableMembershipRoleError, } from "../../tenant/membership-roles.js";
29
+ // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
30
+ import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
29
31
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
30
32
  import { renderInviteEmail } from "../email-templates.js";
33
+ import { inviteAlreadyAccepted } from "../errors.js";
31
34
  import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store.js";
32
35
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
33
36
  const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
@@ -44,102 +47,146 @@ const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitati
44
47
  function findRankedRole(roles) {
45
48
  return roles.find((role) => findForbiddenRoleAssignment(["system"], [role], []) === undefined);
46
49
  }
47
- export function createInviteCreateHandler(opts) {
50
+ function checkAssignableInviteRole(opts, inviterRoles, role, ctx) {
51
+ const forbiddenRole = findForbiddenMembershipRole([role]);
52
+ if (forbiddenRole !== undefined)
53
+ return writeFailure(reservedMembershipRoleError(forbiddenRole));
54
+ const elevationForbidden = findForbiddenRoleAssignment(inviterRoles, [role], [], assignableAppRolesOf(ctx.registry));
55
+ if (elevationForbidden !== undefined &&
56
+ !opts.additionalAssignableRoles?.includes(elevationForbidden)) {
57
+ return writeFailure(unassignableMembershipRoleError(elevationForbidden));
58
+ }
59
+ if (opts.canAssignRole && !opts.canAssignRole(inviterRoles, role)) {
60
+ return writeFailure(unassignableMembershipRoleError(role));
61
+ }
62
+ return undefined;
63
+ }
64
+ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAccepted = false) {
65
+ if (!ctx.redis) {
66
+ return writeFailure(new InternalError({ message: "invite-create requires ctx.redis for token store" }));
67
+ }
68
+ const email = request.email.toLowerCase();
69
+ const tenantId = inviter.tenantId;
70
+ const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
71
+ // The unique index allows one row per (tenantId, email). Whatever its
72
+ // status, a re-invite resets it to pending with a fresh token.
73
+ const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
74
+ let invitationId;
75
+ if (existing) {
76
+ // Provisioning callers (waitlist) must not flip an accepted invite back to
77
+ // pending: that would let them cancel a live membership's invitation.
78
+ // The tenant-admin path keeps resetting because re-inviting a member is
79
+ // how roles get re-assigned.
80
+ if (refuseAccepted && existing["status"] === INVITATION_STATUS.accepted) {
81
+ return inviteAlreadyAccepted();
82
+ }
83
+ invitationId = existing["id"]; // @cast-boundary db-row
84
+ const existingVersion = existing["version"]; // @cast-boundary db-row
85
+ // At most one live invite token per invitation: invalidate
86
+ // whatever's there before minting the new one.
87
+ await invalidateExistingInviteToken(ctx.redis, invitationId);
88
+ const updateResult = await executor.update({
89
+ id: invitationId,
90
+ version: existingVersion,
91
+ changes: {
92
+ role: request.role,
93
+ // Always written, also as []: a tenant-admin resend must drop
94
+ // global roles a previous system invite put on this row.
95
+ globalRoles: [...request.globalRoles],
96
+ status: INVITATION_STATUS.pending,
97
+ invitedBy: inviter.id,
98
+ expiresAt,
99
+ },
100
+ }, inviter, ctx.db);
101
+ if (!updateResult.isSuccess)
102
+ return updateResult;
103
+ }
104
+ else {
105
+ const createResult = await executor.create({
106
+ email,
107
+ role: request.role,
108
+ globalRoles: [...request.globalRoles],
109
+ status: INVITATION_STATUS.pending,
110
+ invitedBy: inviter.id,
111
+ expiresAt,
112
+ }, inviter, ctx.db);
113
+ if (!createResult.isSuccess)
114
+ return createResult;
115
+ invitationId = createResult.data.id; // @cast-boundary engine-payload
116
+ }
117
+ const token = generateToken();
118
+ await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
119
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
120
+ await dispatchMagicLinkMail(ctx.notify, {
121
+ handlerName: "invite-create",
122
+ notificationType: INVITE_NOTIFICATION_TYPE,
123
+ renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: request.role }),
124
+ }, {
125
+ email,
126
+ appUrl: opts.appUrl,
127
+ token,
128
+ expiresAt: expiresAt.toString(),
129
+ ...(opts.appName !== undefined && { appName: opts.appName }),
130
+ locale,
131
+ });
132
+ return {
133
+ isSuccess: true,
134
+ data: {
135
+ kind: "invite-created",
136
+ invitationId,
137
+ tenantId,
138
+ email,
139
+ role: request.role,
140
+ globalRoles: request.globalRoles,
141
+ expiresAt: expiresAt.toString(),
142
+ },
143
+ };
144
+ }
145
+ function assertAppDefinedAdditionalRoles(opts) {
48
146
  const rankedRole = findRankedRole(opts.additionalAssignableRoles ?? []);
49
147
  if (rankedRole !== undefined) {
50
148
  throw new Error(`[auth-email-password] invite.additionalAssignableRoles must list app-defined roles only; "${rankedRole}" is a framework-ranked role`);
51
149
  }
52
- const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
53
- const ttlSeconds = ttlMinutes * 60;
150
+ }
151
+ export function createInviteCreateHandler(opts) {
152
+ assertAppDefinedAdditionalRoles(opts);
153
+ const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
54
154
  return defineWriteHandler({
55
155
  name: "invite-create",
56
156
  schema: InviteCreateSchema,
57
157
  access: { roles: access.admin },
58
158
  description: "Invites an email address into the caller's tenant with a chosen membership role, creating or reusing the invitation and mailing the invitee a fresh accept link; re-inviting the same address invalidates the previous link.",
59
159
  handler: async (event, ctx) => {
60
- if (!ctx.redis) {
61
- return writeFailure(new InternalError({ message: "invite-create requires ctx.redis for token store" }));
62
- }
63
- const forbiddenRole = findForbiddenMembershipRole([event.payload.role]);
64
- if (forbiddenRole !== undefined) {
65
- return writeFailure(reservedMembershipRoleError(forbiddenRole));
66
- }
67
- const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [event.payload.role], [], assignableAppRolesOf(ctx.registry));
68
- if (elevationForbidden !== undefined &&
69
- !opts.additionalAssignableRoles?.includes(elevationForbidden)) {
70
- return writeFailure(unassignableMembershipRoleError(elevationForbidden));
71
- }
72
- if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
73
- return writeFailure(unassignableMembershipRoleError(event.payload.role));
74
- }
75
- const email = event.payload.email.toLowerCase();
76
- const tenantId = event.user.tenantId;
77
- const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
78
- // Existing row für (tenantId, email) — unique-index garantiert
79
- // max. eine Row. Status egal (cancelled/accepted/expired/pending);
80
- // wir setzen sie auf pending zurück und vergeben einen frischen
81
- // Token wenn der bisherige nicht mehr lebt.
82
- const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
83
- let invitationId;
84
- let token;
85
- if (existing) {
86
- invitationId = existing["id"]; // @cast-boundary db-row
87
- const existingVersion = existing["version"]; // @cast-boundary db-row
88
- // At most one live invite token per invitation: invalidate
89
- // whatever's there before minting the new one.
90
- await invalidateExistingInviteToken(ctx.redis, invitationId);
91
- token = generateToken();
92
- const updateResult = await executor.update({
93
- id: invitationId,
94
- version: existingVersion,
95
- changes: {
96
- role: event.payload.role,
97
- status: INVITATION_STATUS.pending,
98
- invitedBy: event.user.id,
99
- expiresAt,
100
- },
101
- }, event.user, ctx.db);
102
- if (!updateResult.isSuccess)
103
- return updateResult;
104
- }
105
- else {
106
- const createResult = await executor.create({
107
- email,
108
- role: event.payload.role,
109
- status: INVITATION_STATUS.pending,
110
- invitedBy: event.user.id,
111
- expiresAt,
112
- }, event.user, ctx.db);
113
- if (!createResult.isSuccess)
114
- return createResult;
115
- invitationId = createResult.data.id; // @cast-boundary engine-payload
116
- token = generateToken();
117
- }
118
- await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
119
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
120
- await dispatchMagicLinkMail(ctx.notify, {
121
- handlerName: "invite-create",
122
- notificationType: INVITE_NOTIFICATION_TYPE,
123
- renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: event.payload.role }),
124
- }, {
125
- email,
126
- appUrl: opts.appUrl,
127
- token,
128
- expiresAt: expiresAt.toString(),
129
- ...(opts.appName !== undefined && { appName: opts.appName }),
130
- locale,
131
- });
132
- return {
133
- isSuccess: true,
134
- data: {
135
- kind: "invite-created",
136
- invitationId,
137
- tenantId,
138
- email,
139
- role: event.payload.role,
140
- expiresAt: expiresAt.toString(),
141
- },
142
- };
160
+ const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
161
+ if (roleRejection)
162
+ return roleRejection;
163
+ return issueInvitation(opts, ttlSeconds, { email: event.payload.email, role: event.payload.role, globalRoles: [] }, event.user, ctx);
164
+ },
165
+ });
166
+ }
167
+ const SystemInviteCreateSchema = z.object({
168
+ email: z.email(),
169
+ role: z.string().min(1).max(50),
170
+ globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
171
+ });
172
+ // System-only sibling of invite-create: the only path that can put global
173
+ // roles (SystemAdmin) on an invitation. No session mint path yields the
174
+ // "system" role (users.roles only accepts GLOBAL_ROLE_OPTIONS), so only
175
+ // in-process callers dispatching as createSystemUser (runBootstrap) get here.
176
+ export function createSystemInviteCreateHandler(opts) {
177
+ assertAppDefinedAdditionalRoles(opts);
178
+ const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
179
+ return defineWriteHandler({
180
+ name: "system-invite-create",
181
+ schema: SystemInviteCreateSchema,
182
+ access: { roles: access.system },
183
+ agent: { expose: false },
184
+ description: "System-only invite into the dispatching tenant that may additionally carry global roles, which the invitee receives only when accepting; used by provisioning such as runBootstrap.",
185
+ handler: async (event, ctx) => {
186
+ const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
187
+ if (roleRejection)
188
+ return roleRejection;
189
+ return issueInvitation(opts, ttlSeconds, event.payload, event.user, ctx, true);
143
190
  },
144
191
  });
145
192
  }
@@ -28,6 +28,8 @@ import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } fr
28
28
  import { findForbiddenMembershipRole, reservedMembershipRoleError, } from "../../tenant/membership-roles.js";
29
29
  // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
30
30
  import { seedTenantMembership } from "../../tenant/seeding.js";
31
+ // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
32
+ import { parseInvitedGlobalRoles } from "../../user/invited-global-roles.js";
31
33
  // kumiko-lint-ignore cross-feature-import existence-check
32
34
  import { userTable } from "../../user/schema/user.js";
33
35
  import { invalidInviteToken } from "../errors.js";
@@ -70,6 +72,7 @@ export function createInviteSignupCompleteHandler() {
70
72
  const invitationTenantId = invitation.tenantId;
71
73
  const invitationEmail = await decryptStoredPii(invitation.email, "email", "auth:invite-signup-complete");
72
74
  const invitationRole = invitation.role;
75
+ const invitationGlobalRoles = parseInvitedGlobalRoles(invitation.globalRoles);
73
76
  const invitationVersion = invitation.version;
74
77
  // User-Not-Exists-Check: wenn die Email schon registriert ist,
75
78
  // muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
@@ -93,6 +96,7 @@ export function createInviteSignupCompleteHandler() {
93
96
  password: event.payload.password,
94
97
  displayName: invitationEmail.split("@")[0] ?? invitationEmail,
95
98
  emailVerified: true,
99
+ ...(invitationGlobalRoles.length > 0 && { roles: invitationGlobalRoles }),
96
100
  });
97
101
  // Membership-Add via seed-helper (gleiches Pattern wie
98
102
  // provisionSignupAccount — bypassed addMember access-check
@@ -121,7 +125,7 @@ export function createInviteSignupCompleteHandler() {
121
125
  tenantId: invitationTenantId,
122
126
  // buildSessionRoles calls stripForbiddenMembershipRoles internally —
123
127
  // a reserved role on the invitation itself must never reach the session.
124
- roles: buildSessionRoles([], [invitationRole]),
128
+ roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
125
129
  };
126
130
  committed = true;
127
131
  return {
@@ -54,6 +54,7 @@ export function createSignupRequestHandler(opts) {
54
54
  schema: SignupRequestSchema,
55
55
  access: { roles: ["anonymous"] },
56
56
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
57
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
57
58
  description: "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
58
59
  escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
59
60
  handler: async (event, ctx) => {
@@ -27,6 +27,7 @@ export function createTokenRequestHandler(spec, opts) {
27
27
  schema: RequestTokenSchema,
28
28
  access: { roles: ["anonymous"] },
29
29
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
30
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
30
31
  escapeHatch: {
31
32
  reason: "Anonymous requester has no session — looks up the target user by email via " +
32
33
  "ctx.queryAs(SYSTEM, user:findForAuth) to mint the out-of-band token.",
@@ -1,5 +1,7 @@
1
1
  export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
2
2
  export { type AuthPath, type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
3
+ export type { BootstrapConfigValue, BootstrapDeps, BootstrapInvite, BootstrapInviteOutcome, BootstrapPlan, BootstrapReport, BootstrapSeedDeps, BootstrapSystemAdmin, BootstrapTenant, BootstrapTenantOutcome, } from "./bootstrap.js";
4
+ export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
3
5
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
4
6
  export type { AuthMailContent, AuthMailLocale, AuthMailSection, RenderInviteEmailArgs, RenderTokenContentArgs, } from "./email-templates.js";
5
7
  export { renderActivationEmail, renderInviteEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail, } from "./email-templates.js";
@@ -6,6 +6,7 @@
6
6
  // its own changeset + deliberate deprecation window, not a silent drop.
7
7
  export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
8
8
  export { DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
9
+ export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
9
10
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
10
11
  export { renderActivationEmail, renderInviteEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail, } from "./email-templates.js";
11
12
  export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature.js";
@@ -28,3 +28,6 @@ export declare function deleteInviteToken(redis: Redis, args: {
28
28
  /** Burn release for failed-accept paths (DB error etc.) so a legitimate
29
29
  * retry isn't blocked by a stale burn marker. */
30
30
  export declare const unburnInviteToken: (redis: Redis, token: string) => Promise<void>;
31
+ /** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
32
+ * is the single truth for invite expiry (see the module comment). */
33
+ export declare function hasLiveInviteToken(redis: Redis, invitationId: string): Promise<boolean>;
@@ -58,3 +58,8 @@ export async function deleteInviteToken(redis, args) {
58
58
  /** Burn release for failed-accept paths (DB error etc.) so a legitimate
59
59
  * retry isn't blocked by a stale burn marker. */
60
60
  export const unburnInviteToken = store.unburn;
61
+ /** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
62
+ * is the single truth for invite expiry (see the module comment). */
63
+ export async function hasLiveInviteToken(redis, invitationId) {
64
+ return (await store.getTokenHashForSubject(redis, invitationId)) !== null;
65
+ }
@@ -44,6 +44,7 @@ export async function dispatchMagicLinkMail(notify, spec, params) {
44
44
  await notify(spec.notificationType, {
45
45
  route: { email: params.email },
46
46
  data: content,
47
+ locale,
47
48
  priority: "critical",
48
49
  });
49
50
  }
@@ -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
  }