@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.
- package/dist/audit/constants.d.ts +3 -0
- package/dist/audit/constants.js +3 -0
- package/dist/audit/feature.js +34 -7
- package/dist/audit/run-escape-hatch-retention.d.ts +10 -0
- package/dist/audit/run-escape-hatch-retention.js +27 -0
- package/dist/auth-email-password/bootstrap.d.ts +80 -0
- package/dist/auth-email-password/bootstrap.js +170 -0
- package/dist/auth-email-password/constants.d.ts +2 -0
- package/dist/auth-email-password/constants.js +5 -0
- package/dist/auth-email-password/errors.d.ts +1 -0
- package/dist/auth-email-password/errors.js +6 -0
- package/dist/auth-email-password/feature.js +2 -1
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
- package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
- package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +1 -0
- package/dist/auth-email-password/invite-token-store.d.ts +3 -0
- package/dist/auth-email-password/invite-token-store.js +5 -0
- package/dist/auth-email-password/magic-link-mail.js +1 -0
- package/dist/auth-email-password/web/auth-form-primitives.d.ts +7 -1
- package/dist/auth-email-password/web/auth-form-primitives.js +5 -5
- package/dist/auth-email-password/web/confirm-account-unlock-screen.js +3 -3
- package/dist/auth-email-password/web/forgot-password-screen.js +2 -2
- package/dist/auth-email-password/web/invite-accept-screen.js +9 -9
- package/dist/auth-email-password/web/login-screen.js +8 -8
- package/dist/auth-email-password/web/request-account-unlock-screen.js +2 -2
- package/dist/auth-email-password/web/reset-password-screen.js +3 -3
- package/dist/auth-email-password/web/session-bootstrap-error.js +3 -3
- package/dist/auth-email-password/web/signup-complete-screen.js +3 -3
- package/dist/auth-email-password/web/signup-screen.js +2 -2
- package/dist/auth-email-password/web/verify-email-screen.js +3 -3
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +8 -3
- package/dist/auth-mfa/feature.d.ts +2 -0
- package/dist/auth-mfa/feature.js +2 -1
- package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +5 -5
- package/dist/auth-mfa/web/mfa-verify-screen.js +3 -3
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
- package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
- package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
- package/dist/channel-discord/discord-channel.d.ts +4 -0
- package/dist/channel-discord/discord-channel.js +4 -0
- package/dist/channel-discord/feature.js +7 -2
- package/dist/channel-discord/index.d.ts +1 -1
- package/dist/channel-discord/index.js +1 -1
- package/dist/channel-email/email-channel.js +8 -1
- package/dist/channel-email/pii-guard.js +6 -5
- package/dist/channel-email/smtp-transport.js +22 -1
- package/dist/channel-email/types.d.ts +1 -0
- package/dist/channel-slack/feature.js +4 -2
- package/dist/channel-slack/index.d.ts +1 -1
- package/dist/channel-slack/index.js +1 -1
- package/dist/channel-slack/slack-channel.d.ts +4 -0
- package/dist/channel-slack/slack-channel.js +4 -0
- package/dist/channel-teams/feature.js +4 -2
- package/dist/channel-teams/index.d.ts +1 -1
- package/dist/channel-teams/index.js +1 -1
- package/dist/channel-teams/teams-channel.d.ts +4 -0
- package/dist/channel-teams/teams-channel.js +4 -0
- package/dist/channel-telegram/feature.js +4 -2
- package/dist/channel-telegram/index.d.ts +1 -1
- package/dist/channel-telegram/index.js +1 -1
- package/dist/channel-telegram/telegram-channel.d.ts +8 -0
- package/dist/channel-telegram/telegram-channel.js +28 -12
- package/dist/delivery/chat-webhook-channel.d.ts +5 -1
- package/dist/delivery/chat-webhook-channel.js +10 -5
- package/dist/delivery/chat-webhook-sender.d.ts +6 -2
- package/dist/delivery/chat-webhook-sender.js +13 -8
- package/dist/delivery/delivery-service.d.ts +4 -3
- package/dist/delivery/delivery-service.js +53 -33
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +2 -2
- package/dist/delivery/jobs.js +3 -1
- package/dist/delivery/types.d.ts +5 -3
- package/dist/legal-pages/feature.d.ts +3 -15
- package/dist/legal-pages/feature.js +20 -50
- package/dist/legal-pages/lib/boot-check.d.ts +10 -0
- package/dist/legal-pages/lib/boot-check.js +33 -0
- package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
- package/dist/legal-pages/lib/validate-routes.js +17 -0
- package/dist/page-render/index.d.ts +2 -1
- package/dist/page-render/index.js +1 -0
- package/dist/page-render/layout.d.ts +7 -0
- package/dist/page-render/layout.js +5 -0
- package/dist/page-render/trailing-slash.d.ts +2 -0
- package/dist/page-render/trailing-slash.js +15 -0
- package/dist/renderer-simple/feature.js +1 -0
- package/dist/renderer-simple/index.d.ts +1 -1
- package/dist/renderer-simple/index.js +1 -1
- package/dist/renderer-simple/simple-renderer.d.ts +12 -5
- package/dist/renderer-simple/simple-renderer.js +66 -12
- package/dist/secrets/announce-secret-change.d.ts +8 -0
- package/dist/secrets/announce-secret-change.js +18 -0
- package/dist/secrets/feature.js +1 -1
- package/dist/secrets/handlers/delete.write.js +2 -0
- package/dist/secrets/handlers/set.write.js +6 -1
- package/dist/secrets/i18n.d.ts +4 -0
- package/dist/secrets/i18n.js +4 -0
- package/dist/secrets/secrets-context.d.ts +2 -0
- package/dist/secrets/secrets-context.js +3 -0
- package/dist/secrets/write-gate.d.ts +5 -1
- package/dist/secrets/write-gate.js +24 -1
- package/dist/tenant/constants.d.ts +1 -0
- package/dist/tenant/constants.js +1 -0
- package/dist/tenant/handlers/add-member.write.js +14 -3
- package/dist/tenant/handlers/invitations.query.d.ts +8 -0
- package/dist/tenant/handlers/invitations.query.js +15 -1
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +2 -0
- package/dist/tenant/invitation-table.js +10 -1
- package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
- package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
- package/dist/tier-engine/feature.js +83 -14
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
- package/dist/user/invited-global-roles.d.ts +14 -0
- package/dist/user/invited-global-roles.js +35 -0
- package/dist/user-data-rights/feature.js +3 -0
- package/dist/waitlist/constants.d.ts +33 -0
- package/dist/waitlist/constants.js +35 -0
- package/dist/waitlist/entity.d.ts +433 -0
- package/dist/waitlist/entity.js +64 -0
- package/dist/waitlist/feature.d.ts +3 -0
- package/dist/waitlist/feature.js +38 -0
- package/dist/waitlist/handlers/invite.write.d.ts +9 -0
- package/dist/waitlist/handlers/invite.write.js +119 -0
- package/dist/waitlist/handlers/list.query.d.ts +1 -0
- package/dist/waitlist/handlers/list.query.js +10 -0
- package/dist/waitlist/handlers/reject.write.d.ts +7 -0
- package/dist/waitlist/handlers/reject.write.js +80 -0
- package/dist/waitlist/handlers/submit.write.d.ts +13 -0
- package/dist/waitlist/handlers/submit.write.js +112 -0
- package/dist/waitlist/i18n.d.ts +6 -0
- package/dist/waitlist/i18n.js +31 -0
- package/dist/waitlist/index.d.ts +5 -0
- package/dist/waitlist/index.js +4 -0
- package/dist/waitlist/lib.d.ts +10 -0
- package/dist/waitlist/lib.js +24 -0
- package/dist/waitlist/mail.d.ts +15 -0
- package/dist/waitlist/mail.js +73 -0
- package/dist/waitlist/options.d.ts +30 -0
- package/dist/waitlist/options.js +8 -0
- package/dist/waitlist/payloads.d.ts +14 -0
- package/dist/waitlist/payloads.js +47 -0
- package/dist/waitlist/screens.d.ts +2 -0
- package/dist/waitlist/screens.js +42 -0
- package/dist/waitlist/user-data-hooks.d.ts +3 -0
- package/dist/waitlist/user-data-hooks.js +71 -0
- package/package.json +17 -9
- package/src/auth-email-password/changes.json +30 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/channel-email/changes.json +6 -0
- package/src/channel-telegram/changes.json +9 -0
- package/src/delivery/changes.json +7 -0
- package/src/files/changes.json +13 -1
- package/src/renderer-simple/changes.json +6 -0
- package/src/secrets/changes.json +12 -0
- package/src/tenant/changes.json +6 -0
- 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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
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
|
+
}
|
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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(
|
|
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 (
|
|
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 (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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(
|
|
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 (
|
|
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(
|
|
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:
|
|
28
|
-
|
|
29
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
}
|