@cosmicdrift/kumiko-samples 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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/audit/constants.ts +5 -0
- package/packages/bundled-features/src/audit/feature.ts +47 -6
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +325 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +30 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
- package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
- package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
- package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
- package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
- package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +9 -1
- package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
- package/packages/bundled-features/src/channel-email/types.ts +4 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +23 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +42 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +63 -7
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
|
@@ -23,8 +23,15 @@ import {
|
|
|
23
23
|
access,
|
|
24
24
|
assignableAppRolesOf,
|
|
25
25
|
defineWriteHandler,
|
|
26
|
+
type HandlerContext,
|
|
27
|
+
type SessionUser,
|
|
28
|
+
type WriteResult,
|
|
26
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
InternalError,
|
|
32
|
+
type WriteFailure,
|
|
33
|
+
writeFailure,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
28
35
|
import { Temporal } from "temporal-polyfill";
|
|
29
36
|
import * as z from "zod";
|
|
30
37
|
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
|
|
@@ -40,14 +47,19 @@ import {
|
|
|
40
47
|
reservedMembershipRoleError,
|
|
41
48
|
unassignableMembershipRoleError,
|
|
42
49
|
} from "../../tenant/membership-roles.js";
|
|
50
|
+
// kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
|
|
51
|
+
import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
|
|
43
52
|
import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
|
|
44
53
|
import type { AuthMailLocale } from "../email-templates.js";
|
|
45
54
|
import { renderInviteEmail } from "../email-templates.js";
|
|
55
|
+
import { inviteAlreadyAccepted } from "../errors.js";
|
|
46
56
|
import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store.js";
|
|
47
57
|
import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
|
|
48
58
|
|
|
49
59
|
const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
|
|
50
60
|
|
|
61
|
+
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
62
|
+
|
|
51
63
|
const InviteCreateSchema = z.object({
|
|
52
64
|
email: z.email(),
|
|
53
65
|
role: z.string().min(1).max(50),
|
|
@@ -59,6 +71,7 @@ export type InviteCreateData = {
|
|
|
59
71
|
readonly tenantId: string;
|
|
60
72
|
readonly email: string;
|
|
61
73
|
readonly role: string;
|
|
74
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
62
75
|
readonly expiresAt: string;
|
|
63
76
|
};
|
|
64
77
|
|
|
@@ -97,15 +110,160 @@ function findRankedRole(roles: readonly string[]): string | undefined {
|
|
|
97
110
|
return roles.find((role) => findForbiddenRoleAssignment(["system"], [role], []) === undefined);
|
|
98
111
|
}
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
type InviteIssueRequest = {
|
|
114
|
+
readonly email: string;
|
|
115
|
+
readonly role: string;
|
|
116
|
+
readonly globalRoles: readonly GlobalRole[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function checkAssignableInviteRole(
|
|
120
|
+
opts: InviteCreateOptions,
|
|
121
|
+
inviterRoles: readonly string[],
|
|
122
|
+
role: string,
|
|
123
|
+
ctx: HandlerContext,
|
|
124
|
+
): WriteFailure | undefined {
|
|
125
|
+
const forbiddenRole = findForbiddenMembershipRole([role]);
|
|
126
|
+
if (forbiddenRole !== undefined) return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
127
|
+
|
|
128
|
+
const elevationForbidden = findForbiddenRoleAssignment(
|
|
129
|
+
inviterRoles,
|
|
130
|
+
[role],
|
|
131
|
+
[],
|
|
132
|
+
assignableAppRolesOf(ctx.registry),
|
|
133
|
+
);
|
|
134
|
+
if (
|
|
135
|
+
elevationForbidden !== undefined &&
|
|
136
|
+
!opts.additionalAssignableRoles?.includes(elevationForbidden)
|
|
137
|
+
) {
|
|
138
|
+
return writeFailure(unassignableMembershipRoleError(elevationForbidden));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (opts.canAssignRole && !opts.canAssignRole(inviterRoles, role)) {
|
|
142
|
+
return writeFailure(unassignableMembershipRoleError(role));
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function issueInvitation(
|
|
148
|
+
opts: InviteCreateOptions,
|
|
149
|
+
ttlSeconds: number,
|
|
150
|
+
request: InviteIssueRequest,
|
|
151
|
+
inviter: SessionUser,
|
|
152
|
+
ctx: HandlerContext,
|
|
153
|
+
refuseAccepted = false,
|
|
154
|
+
): Promise<WriteResult<InviteCreateData>> {
|
|
155
|
+
if (!ctx.redis) {
|
|
156
|
+
return writeFailure(
|
|
157
|
+
new InternalError({ message: "invite-create requires ctx.redis for token store" }),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const email = request.email.toLowerCase();
|
|
162
|
+
const tenantId = inviter.tenantId;
|
|
163
|
+
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
164
|
+
|
|
165
|
+
// The unique index allows one row per (tenantId, email). Whatever its
|
|
166
|
+
// status, a re-invite resets it to pending with a fresh token.
|
|
167
|
+
const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
|
|
168
|
+
|
|
169
|
+
let invitationId: string;
|
|
170
|
+
if (existing) {
|
|
171
|
+
// Provisioning callers (waitlist) must not flip an accepted invite back to
|
|
172
|
+
// pending: that would let them cancel a live membership's invitation.
|
|
173
|
+
// The tenant-admin path keeps resetting because re-inviting a member is
|
|
174
|
+
// how roles get re-assigned.
|
|
175
|
+
if (refuseAccepted && existing["status"] === INVITATION_STATUS.accepted) {
|
|
176
|
+
return inviteAlreadyAccepted();
|
|
177
|
+
}
|
|
178
|
+
invitationId = existing["id"] as string; // @cast-boundary db-row
|
|
179
|
+
const existingVersion = existing["version"] as number; // @cast-boundary db-row
|
|
180
|
+
// At most one live invite token per invitation: invalidate
|
|
181
|
+
// whatever's there before minting the new one.
|
|
182
|
+
await invalidateExistingInviteToken(ctx.redis, invitationId);
|
|
183
|
+
|
|
184
|
+
const updateResult = await executor.update(
|
|
185
|
+
{
|
|
186
|
+
id: invitationId,
|
|
187
|
+
version: existingVersion,
|
|
188
|
+
changes: {
|
|
189
|
+
role: request.role,
|
|
190
|
+
// Always written, also as []: a tenant-admin resend must drop
|
|
191
|
+
// global roles a previous system invite put on this row.
|
|
192
|
+
globalRoles: [...request.globalRoles],
|
|
193
|
+
status: INVITATION_STATUS.pending,
|
|
194
|
+
invitedBy: inviter.id,
|
|
195
|
+
expiresAt,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
inviter,
|
|
199
|
+
ctx.db,
|
|
200
|
+
);
|
|
201
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
202
|
+
} else {
|
|
203
|
+
const createResult = await executor.create(
|
|
204
|
+
{
|
|
205
|
+
email,
|
|
206
|
+
role: request.role,
|
|
207
|
+
globalRoles: [...request.globalRoles],
|
|
208
|
+
status: INVITATION_STATUS.pending,
|
|
209
|
+
invitedBy: inviter.id,
|
|
210
|
+
expiresAt,
|
|
211
|
+
},
|
|
212
|
+
inviter,
|
|
213
|
+
ctx.db,
|
|
214
|
+
);
|
|
215
|
+
if (!createResult.isSuccess) return createResult;
|
|
216
|
+
invitationId = (createResult.data as { id: string }).id; // @cast-boundary engine-payload
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const token = generateToken();
|
|
220
|
+
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
221
|
+
|
|
222
|
+
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
223
|
+
|
|
224
|
+
await dispatchMagicLinkMail(
|
|
225
|
+
ctx.notify,
|
|
226
|
+
{
|
|
227
|
+
handlerName: "invite-create",
|
|
228
|
+
notificationType: INVITE_NOTIFICATION_TYPE,
|
|
229
|
+
renderContent: (renderArgs) => renderInviteEmail({ ...renderArgs, role: request.role }),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
email,
|
|
233
|
+
appUrl: opts.appUrl,
|
|
234
|
+
token,
|
|
235
|
+
expiresAt: expiresAt.toString(),
|
|
236
|
+
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
237
|
+
locale,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
isSuccess: true,
|
|
243
|
+
data: {
|
|
244
|
+
kind: "invite-created",
|
|
245
|
+
invitationId,
|
|
246
|
+
tenantId,
|
|
247
|
+
email,
|
|
248
|
+
role: request.role,
|
|
249
|
+
globalRoles: request.globalRoles,
|
|
250
|
+
expiresAt: expiresAt.toString(),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function assertAppDefinedAdditionalRoles(opts: InviteCreateOptions): void {
|
|
101
256
|
const rankedRole = findRankedRole(opts.additionalAssignableRoles ?? []);
|
|
102
257
|
if (rankedRole !== undefined) {
|
|
103
258
|
throw new Error(
|
|
104
259
|
`[auth-email-password] invite.additionalAssignableRoles must list app-defined roles only; "${rankedRole}" is a framework-ranked role`,
|
|
105
260
|
);
|
|
106
261
|
}
|
|
107
|
-
|
|
108
|
-
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
265
|
+
assertAppDefinedAdditionalRoles(opts);
|
|
266
|
+
const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
|
|
109
267
|
|
|
110
268
|
return defineWriteHandler<"invite-create", typeof InviteCreateSchema, InviteCreateData>({
|
|
111
269
|
name: "invite-create",
|
|
@@ -114,119 +272,58 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
114
272
|
description:
|
|
115
273
|
"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.",
|
|
116
274
|
handler: async (event, ctx) => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
new InternalError({ message: "invite-create requires ctx.redis for token store" }),
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const forbiddenRole = findForbiddenMembershipRole([event.payload.role]);
|
|
124
|
-
if (forbiddenRole !== undefined) {
|
|
125
|
-
return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const elevationForbidden = findForbiddenRoleAssignment(
|
|
275
|
+
const roleRejection = checkAssignableInviteRole(
|
|
276
|
+
opts,
|
|
129
277
|
event.user.roles,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
assignableAppRolesOf(ctx.registry),
|
|
278
|
+
event.payload.role,
|
|
279
|
+
ctx,
|
|
133
280
|
);
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
|
|
142
|
-
return writeFailure(unassignableMembershipRoleError(event.payload.role));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const email = event.payload.email.toLowerCase();
|
|
146
|
-
const tenantId = event.user.tenantId;
|
|
147
|
-
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
148
|
-
|
|
149
|
-
// Existing row für (tenantId, email) — unique-index garantiert
|
|
150
|
-
// max. eine Row. Status egal (cancelled/accepted/expired/pending);
|
|
151
|
-
// wir setzen sie auf pending zurück und vergeben einen frischen
|
|
152
|
-
// Token wenn der bisherige nicht mehr lebt.
|
|
153
|
-
const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
|
|
154
|
-
|
|
155
|
-
let invitationId: string;
|
|
156
|
-
let token: string;
|
|
157
|
-
if (existing) {
|
|
158
|
-
invitationId = existing["id"] as string; // @cast-boundary db-row
|
|
159
|
-
const existingVersion = existing["version"] as number; // @cast-boundary db-row
|
|
160
|
-
// At most one live invite token per invitation: invalidate
|
|
161
|
-
// whatever's there before minting the new one.
|
|
162
|
-
await invalidateExistingInviteToken(ctx.redis, invitationId);
|
|
163
|
-
token = generateToken();
|
|
164
|
-
|
|
165
|
-
const updateResult = await executor.update(
|
|
166
|
-
{
|
|
167
|
-
id: invitationId,
|
|
168
|
-
version: existingVersion,
|
|
169
|
-
changes: {
|
|
170
|
-
role: event.payload.role,
|
|
171
|
-
status: INVITATION_STATUS.pending,
|
|
172
|
-
invitedBy: event.user.id,
|
|
173
|
-
expiresAt,
|
|
174
|
-
},
|
|
175
|
-
},
|
|
176
|
-
event.user,
|
|
177
|
-
ctx.db,
|
|
178
|
-
);
|
|
179
|
-
if (!updateResult.isSuccess) return updateResult;
|
|
180
|
-
} else {
|
|
181
|
-
const createResult = await executor.create(
|
|
182
|
-
{
|
|
183
|
-
email,
|
|
184
|
-
role: event.payload.role,
|
|
185
|
-
status: INVITATION_STATUS.pending,
|
|
186
|
-
invitedBy: event.user.id,
|
|
187
|
-
expiresAt,
|
|
188
|
-
},
|
|
189
|
-
event.user,
|
|
190
|
-
ctx.db,
|
|
191
|
-
);
|
|
192
|
-
if (!createResult.isSuccess) return createResult;
|
|
193
|
-
invitationId = (createResult.data as { id: string }).id; // @cast-boundary engine-payload
|
|
194
|
-
token = generateToken();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
|
|
198
|
-
|
|
199
|
-
const locale = resolveHandlerMailLocale(ctx, opts.locale);
|
|
200
|
-
|
|
201
|
-
await dispatchMagicLinkMail(
|
|
202
|
-
ctx.notify,
|
|
203
|
-
{
|
|
204
|
-
handlerName: "invite-create",
|
|
205
|
-
notificationType: INVITE_NOTIFICATION_TYPE,
|
|
206
|
-
renderContent: (renderArgs) =>
|
|
207
|
-
renderInviteEmail({ ...renderArgs, role: event.payload.role }),
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
email,
|
|
211
|
-
appUrl: opts.appUrl,
|
|
212
|
-
token,
|
|
213
|
-
expiresAt: expiresAt.toString(),
|
|
214
|
-
...(opts.appName !== undefined && { appName: opts.appName }),
|
|
215
|
-
locale,
|
|
216
|
-
},
|
|
281
|
+
if (roleRejection) return roleRejection;
|
|
282
|
+
return issueInvitation(
|
|
283
|
+
opts,
|
|
284
|
+
ttlSeconds,
|
|
285
|
+
{ email: event.payload.email, role: event.payload.role, globalRoles: [] },
|
|
286
|
+
event.user,
|
|
287
|
+
ctx,
|
|
217
288
|
);
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
218
292
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
293
|
+
const SystemInviteCreateSchema = z.object({
|
|
294
|
+
email: z.email(),
|
|
295
|
+
role: z.string().min(1).max(50),
|
|
296
|
+
globalRoles: z.array(z.enum(GLOBAL_ROLE_OPTIONS)).max(GLOBAL_ROLE_OPTIONS.length).default([]),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// System-only sibling of invite-create: the only path that can put global
|
|
300
|
+
// roles (SystemAdmin) on an invitation. No session mint path yields the
|
|
301
|
+
// "system" role (users.roles only accepts GLOBAL_ROLE_OPTIONS), so only
|
|
302
|
+
// in-process callers dispatching as createSystemUser (runBootstrap) get here.
|
|
303
|
+
export function createSystemInviteCreateHandler(opts: InviteCreateOptions) {
|
|
304
|
+
assertAppDefinedAdditionalRoles(opts);
|
|
305
|
+
const ttlSeconds = (opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES) * 60;
|
|
306
|
+
|
|
307
|
+
return defineWriteHandler<
|
|
308
|
+
"system-invite-create",
|
|
309
|
+
typeof SystemInviteCreateSchema,
|
|
310
|
+
InviteCreateData
|
|
311
|
+
>({
|
|
312
|
+
name: "system-invite-create",
|
|
313
|
+
schema: SystemInviteCreateSchema,
|
|
314
|
+
access: { roles: access.system },
|
|
315
|
+
agent: { expose: false },
|
|
316
|
+
description:
|
|
317
|
+
"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.",
|
|
318
|
+
handler: async (event, ctx) => {
|
|
319
|
+
const roleRejection = checkAssignableInviteRole(
|
|
320
|
+
opts,
|
|
321
|
+
event.user.roles,
|
|
322
|
+
event.payload.role,
|
|
323
|
+
ctx,
|
|
324
|
+
);
|
|
325
|
+
if (roleRejection) return roleRejection;
|
|
326
|
+
return issueInvitation(opts, ttlSeconds, event.payload, event.user, ctx, true);
|
|
230
327
|
},
|
|
231
328
|
});
|
|
232
329
|
}
|
package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts
CHANGED
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
} from "../../tenant/membership-roles.js";
|
|
47
47
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add
|
|
48
48
|
import { seedTenantMembership } from "../../tenant/seeding.js";
|
|
49
|
+
// kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
|
|
50
|
+
import { parseInvitedGlobalRoles } from "../../user/invited-global-roles.js";
|
|
49
51
|
// kumiko-lint-ignore cross-feature-import existence-check
|
|
50
52
|
import { userTable } from "../../user/schema/user.js";
|
|
51
53
|
import { invalidInviteToken } from "../errors.js";
|
|
@@ -113,6 +115,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
113
115
|
readonly tenantId: TenantId;
|
|
114
116
|
readonly email: string;
|
|
115
117
|
readonly role: string;
|
|
118
|
+
readonly globalRoles: unknown;
|
|
116
119
|
readonly version: number;
|
|
117
120
|
};
|
|
118
121
|
|
|
@@ -133,6 +136,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
133
136
|
"auth:invite-signup-complete",
|
|
134
137
|
);
|
|
135
138
|
const invitationRole = invitation.role;
|
|
139
|
+
const invitationGlobalRoles = parseInvitedGlobalRoles(invitation.globalRoles);
|
|
136
140
|
const invitationVersion = invitation.version;
|
|
137
141
|
|
|
138
142
|
// User-Not-Exists-Check: wenn die Email schon registriert ist,
|
|
@@ -157,6 +161,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
157
161
|
password: event.payload.password,
|
|
158
162
|
displayName: invitationEmail.split("@")[0] ?? invitationEmail,
|
|
159
163
|
emailVerified: true,
|
|
164
|
+
...(invitationGlobalRoles.length > 0 && { roles: invitationGlobalRoles }),
|
|
160
165
|
});
|
|
161
166
|
|
|
162
167
|
// Membership-Add via seed-helper (gleiches Pattern wie
|
|
@@ -192,7 +197,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
192
197
|
tenantId: invitationTenantId,
|
|
193
198
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
194
199
|
// a reserved role on the invitation itself must never reach the session.
|
|
195
|
-
roles: buildSessionRoles(
|
|
200
|
+
roles: buildSessionRoles(invitationGlobalRoles, [invitationRole]),
|
|
196
201
|
};
|
|
197
202
|
|
|
198
203
|
committed = true;
|
|
@@ -93,6 +93,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
93
93
|
schema: SignupRequestSchema,
|
|
94
94
|
access: { roles: ["anonymous"] },
|
|
95
95
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
96
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
|
|
96
97
|
description:
|
|
97
98
|
"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.",
|
|
98
99
|
escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
|
|
@@ -100,6 +100,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
100
100
|
schema: RequestTokenSchema,
|
|
101
101
|
access: { roles: ["anonymous"] },
|
|
102
102
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
103
|
+
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
|
|
103
104
|
escapeHatch: {
|
|
104
105
|
reason:
|
|
105
106
|
"Anonymous requester has no session — looks up the target user by email via " +
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
// its own changeset + deliberate deprecation window, not a silent drop.
|
|
8
8
|
export { hashPassword, verifyPassword } from "../shared/password-hashing.js";
|
|
9
9
|
export { type AuthPath, type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths.js";
|
|
10
|
+
export type {
|
|
11
|
+
BootstrapConfigValue,
|
|
12
|
+
BootstrapDeps,
|
|
13
|
+
BootstrapInvite,
|
|
14
|
+
BootstrapInviteOutcome,
|
|
15
|
+
BootstrapPlan,
|
|
16
|
+
BootstrapReport,
|
|
17
|
+
BootstrapSeedDeps,
|
|
18
|
+
BootstrapSystemAdmin,
|
|
19
|
+
BootstrapTenant,
|
|
20
|
+
BootstrapTenantOutcome,
|
|
21
|
+
} from "./bootstrap.js";
|
|
22
|
+
export { BootstrapPlanError, BootstrapWriteError, bootstrapTenants } from "./bootstrap.js";
|
|
10
23
|
export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants.js";
|
|
11
24
|
// Renderers for the auth mails. All four magic-link flows (reset, verify,
|
|
12
25
|
// signup-activation, invite) emit structured AuthMailContent through delivery
|
|
@@ -76,3 +76,9 @@ export async function deleteInviteToken(
|
|
|
76
76
|
/** Burn release for failed-accept paths (DB error etc.) so a legitimate
|
|
77
77
|
* retry isn't blocked by a stale burn marker. */
|
|
78
78
|
export const unburnInviteToken = store.unburn;
|
|
79
|
+
|
|
80
|
+
/** Whether the invitation still has an unexpired, unconsumed token — Redis TTL
|
|
81
|
+
* is the single truth for invite expiry (see the module comment). */
|
|
82
|
+
export async function hasLiveInviteToken(redis: Redis, invitationId: string): Promise<boolean> {
|
|
83
|
+
return (await store.getTokenHashForSubject(redis, invitationId)) !== null;
|
|
84
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// die früheren authButtonClass/authMutedLinkClass sind dorthin gewandert.
|
|
16
16
|
|
|
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, type ReactNode, useContext, useEffect, useState } from "react";
|
|
20
20
|
|
|
21
21
|
// Wrappt die zentrierte Auth-Card in ihre Umgebung. Default = Fullscreen-
|
|
@@ -47,25 +47,41 @@ export function useAuthShell(): AuthShellRenderer | null {
|
|
|
47
47
|
export type AuthCardProps = {
|
|
48
48
|
readonly title?: string;
|
|
49
49
|
readonly subtitle?: ReactNode;
|
|
50
|
+
/** Merged onto the Card (default `w-full max-w-sm`). */
|
|
51
|
+
readonly className?: string;
|
|
52
|
+
readonly headerClassName?: string;
|
|
53
|
+
readonly titleClassName?: string;
|
|
54
|
+
/** Merged onto the body wrapper (default `p-6 pt-0 flex flex-col gap-4`). */
|
|
55
|
+
readonly bodyClassName?: string;
|
|
50
56
|
readonly children: ReactNode;
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
// kumiko-lint-ignore no-custom-primitives Wraps usePrimitives().Card, pure domain name for the auth-screen chrome, not a primitive reimplementation
|
|
54
|
-
export function AuthCard({
|
|
60
|
+
export function AuthCard({
|
|
61
|
+
title,
|
|
62
|
+
subtitle,
|
|
63
|
+
className,
|
|
64
|
+
headerClassName,
|
|
65
|
+
titleClassName,
|
|
66
|
+
bodyClassName,
|
|
67
|
+
children,
|
|
68
|
+
}: AuthCardProps): ReactNode {
|
|
55
69
|
const { Card } = usePrimitives();
|
|
56
70
|
const shell = useAuthShell() ?? defaultAuthShell;
|
|
57
71
|
// h1 (Seiten-Hauptüberschrift) via Header-Slot erhalten — die Card-Default-
|
|
58
72
|
// Header wäre h3. padded:false = Form sitzt randlos wie bisher (bare form).
|
|
59
73
|
const card = (
|
|
60
74
|
<Card
|
|
61
|
-
className="w-full max-w-sm"
|
|
75
|
+
className={cn("w-full max-w-sm", className)}
|
|
62
76
|
options={{ padded: false }}
|
|
63
77
|
slots={{
|
|
64
78
|
header:
|
|
65
79
|
title !== undefined || subtitle !== undefined ? (
|
|
66
|
-
<div className="flex flex-col space-y-1.5 p-6 pb-4">
|
|
80
|
+
<div className={cn("flex flex-col space-y-1.5 p-6 pb-4", headerClassName)}>
|
|
67
81
|
{title !== undefined && (
|
|
68
|
-
<h1 className="text-xl font-semibold tracking-tight"
|
|
82
|
+
<h1 className={cn("text-xl font-semibold tracking-tight", titleClassName)}>
|
|
83
|
+
{title}
|
|
84
|
+
</h1>
|
|
69
85
|
)}
|
|
70
86
|
{subtitle !== undefined && (
|
|
71
87
|
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
|
@@ -74,7 +90,9 @@ export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNod
|
|
|
74
90
|
) : undefined,
|
|
75
91
|
}}
|
|
76
92
|
>
|
|
77
|
-
<BareFormProvider>
|
|
93
|
+
<BareFormProvider>
|
|
94
|
+
<div className={cn("p-6 pt-0 flex flex-col gap-4", bodyClassName)}>{children}</div>
|
|
95
|
+
</BareFormProvider>
|
|
78
96
|
</Card>
|
|
79
97
|
);
|
|
80
98
|
return shell(card);
|
package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx
CHANGED
|
@@ -47,12 +47,10 @@ export function ConfirmAccountUnlockScreen({
|
|
|
47
47
|
if (status === "missing-token") {
|
|
48
48
|
return (
|
|
49
49
|
<AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</Link>
|
|
55
|
-
</div>
|
|
50
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.missingToken")}</p>
|
|
51
|
+
<Link href={loginHref} variant="muted">
|
|
52
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
53
|
+
</Link>
|
|
56
54
|
</AuthCard>
|
|
57
55
|
);
|
|
58
56
|
}
|
|
@@ -72,12 +70,10 @@ export function ConfirmAccountUnlockScreen({
|
|
|
72
70
|
if (status === "success") {
|
|
73
71
|
return (
|
|
74
72
|
<AuthCard title={title ?? t("auth.unlockAccount.successTitle")}>
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
</Link>
|
|
80
|
-
</div>
|
|
73
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.successBody")}</p>
|
|
74
|
+
<Link href={loginHref} variant="button">
|
|
75
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
76
|
+
</Link>
|
|
81
77
|
</AuthCard>
|
|
82
78
|
);
|
|
83
79
|
}
|
|
@@ -85,12 +81,10 @@ export function ConfirmAccountUnlockScreen({
|
|
|
85
81
|
// status === "error"
|
|
86
82
|
return (
|
|
87
83
|
<AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
</Link>
|
|
93
|
-
</div>
|
|
84
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.errorBody")}</p>
|
|
85
|
+
<Link href={loginHref} variant="muted">
|
|
86
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
87
|
+
</Link>
|
|
94
88
|
</AuthCard>
|
|
95
89
|
);
|
|
96
90
|
}
|
|
@@ -72,7 +72,7 @@ export function ForgotPasswordScreen({
|
|
|
72
72
|
return (
|
|
73
73
|
<AuthCard title={effectiveTitle} subtitle={subtitle}>
|
|
74
74
|
{done ? (
|
|
75
|
-
|
|
75
|
+
<>
|
|
76
76
|
<Banner variant="info">
|
|
77
77
|
<p className="font-medium text-foreground">{t("auth.forgotPassword.successTitle")}</p>
|
|
78
78
|
<p className="mt-1">{t("auth.forgotPassword.successBody")}</p>
|
|
@@ -80,9 +80,9 @@ export function ForgotPasswordScreen({
|
|
|
80
80
|
<Link href={loginHref} variant="muted">
|
|
81
81
|
{t("auth.forgotPassword.backToLogin")}
|
|
82
82
|
</Link>
|
|
83
|
-
|
|
83
|
+
</>
|
|
84
84
|
) : (
|
|
85
|
-
|
|
85
|
+
<>
|
|
86
86
|
<p className="text-sm text-muted-foreground">{t("auth.forgotPassword.intro")}</p>
|
|
87
87
|
<Form onSubmit={onSubmit}>
|
|
88
88
|
<Field id="forgot-email" label={t("auth.forgotPassword.email")} required>
|
|
@@ -104,7 +104,7 @@ export function ForgotPasswordScreen({
|
|
|
104
104
|
<Link href={loginHref} variant="muted" className="self-center">
|
|
105
105
|
{t("auth.forgotPassword.backToLogin")}
|
|
106
106
|
</Link>
|
|
107
|
-
|
|
107
|
+
</>
|
|
108
108
|
)}
|
|
109
109
|
</AuthCard>
|
|
110
110
|
);
|