@cosmicdrift/kumiko-bundled-features 0.102.1 → 0.104.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 +6 -6
- package/src/auth-email-password/__tests__/email-templates.test.ts +44 -47
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +41 -21
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +62 -32
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +39 -20
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +39 -8
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +53 -36
- package/src/auth-email-password/auth-paths.ts +24 -0
- package/src/auth-email-password/email-templates.ts +59 -118
- package/src/auth-email-password/feature.ts +19 -1
- package/src/auth-email-password/handlers/invite-create.write.ts +33 -5
- package/src/auth-email-password/handlers/request-email-verification.write.ts +5 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +5 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +38 -8
- package/src/auth-email-password/handlers/token-request-handler.ts +31 -0
- package/src/auth-email-password/index.ts +9 -20
- package/src/auth-email-password/magic-link-mail.ts +62 -0
- package/src/auth-email-password/__tests__/auth-mailer.test.ts +0 -138
- package/src/auth-email-password/auth-mailer.ts +0 -153
|
@@ -14,9 +14,15 @@ import {
|
|
|
14
14
|
unsafeCreateEntityTable,
|
|
15
15
|
unsafePushTables,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
+
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
17
18
|
import { createConfigFeature } from "../../config";
|
|
18
19
|
import { createConfigResolver } from "../../config/resolver";
|
|
19
20
|
import { configValuesTable } from "../../config/table";
|
|
21
|
+
import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
|
|
22
|
+
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
23
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
24
|
+
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
25
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
20
26
|
import { createTenantFeature } from "../../tenant";
|
|
21
27
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
22
28
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
@@ -26,6 +32,9 @@ import { AuthHandlers } from "../constants";
|
|
|
26
32
|
import { createAuthEmailPasswordFeature } from "../feature";
|
|
27
33
|
|
|
28
34
|
let stack: TestStack;
|
|
35
|
+
// delivery is a hard dep of the reset/verify flows now; mount it so boot
|
|
36
|
+
// passes. These tests hit unknown emails → no-op → no mail is actually sent.
|
|
37
|
+
const emailTransport = createInMemoryTransport();
|
|
29
38
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
30
39
|
const resetSecret = randomBytes(32).toString("base64");
|
|
31
40
|
const verifySecret = randomBytes(32).toString("base64");
|
|
@@ -39,26 +48,44 @@ beforeAll(async () => {
|
|
|
39
48
|
createConfigFeature(),
|
|
40
49
|
createUserFeature(),
|
|
41
50
|
createTenantFeature(),
|
|
51
|
+
createTemplateResolverFeature(),
|
|
52
|
+
createRendererFoundationFeature(),
|
|
53
|
+
createDeliveryFeature(),
|
|
54
|
+
createRendererSimpleFeature(),
|
|
55
|
+
createChannelEmailFeature({
|
|
56
|
+
transport: emailTransport,
|
|
57
|
+
renderer: simpleRenderer,
|
|
58
|
+
resolveEmail: async () => "unused@test.local",
|
|
59
|
+
}),
|
|
42
60
|
createAuthEmailPasswordFeature({
|
|
43
|
-
passwordReset: {
|
|
44
|
-
|
|
61
|
+
passwordReset: {
|
|
62
|
+
hmacSecret: resetSecret,
|
|
63
|
+
tokenTtlMinutes: 15,
|
|
64
|
+
appUrl: "https://app.example.com/reset",
|
|
65
|
+
},
|
|
66
|
+
emailVerification: {
|
|
67
|
+
hmacSecret: verifySecret,
|
|
68
|
+
tokenTtlMinutes: 60,
|
|
69
|
+
mode: "strict",
|
|
70
|
+
appUrl: "https://app.example.com/verify",
|
|
71
|
+
},
|
|
45
72
|
}),
|
|
46
73
|
],
|
|
47
|
-
extraContext:
|
|
74
|
+
extraContext: (deps) => ({
|
|
75
|
+
...createDeliveryTestContext(deps),
|
|
76
|
+
configResolver: resolver,
|
|
77
|
+
configEncryption: encryption,
|
|
78
|
+
}),
|
|
48
79
|
authConfig: {
|
|
49
80
|
membershipQuery: "tenant:query:memberships",
|
|
50
81
|
loginHandler: AuthHandlers.login,
|
|
51
82
|
passwordReset: {
|
|
52
83
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
53
84
|
confirmHandler: AuthHandlers.resetPassword,
|
|
54
|
-
appResetUrl: "https://app.example.com/reset",
|
|
55
|
-
sendResetEmail: async () => {},
|
|
56
85
|
},
|
|
57
86
|
emailVerification: {
|
|
58
87
|
requestHandler: AuthHandlers.requestEmailVerification,
|
|
59
88
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
60
|
-
appVerifyUrl: "https://app.example.com/verify",
|
|
61
|
-
sendVerificationEmail: async () => {},
|
|
62
89
|
},
|
|
63
90
|
},
|
|
64
91
|
// Tight limit so the test trips it with a small number of requests,
|
|
@@ -70,7 +97,11 @@ beforeAll(async () => {
|
|
|
70
97
|
|
|
71
98
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
72
99
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
73
|
-
await unsafePushTables(stack.db, {
|
|
100
|
+
await unsafePushTables(stack.db, {
|
|
101
|
+
configValuesTable,
|
|
102
|
+
tenantMembershipsTable,
|
|
103
|
+
notificationPreferencesTable,
|
|
104
|
+
});
|
|
74
105
|
});
|
|
75
106
|
|
|
76
107
|
afterAll(async () => {
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// reale User-Pfad sind.
|
|
5
5
|
//
|
|
6
6
|
// Pinst:
|
|
7
|
-
// 1. POST signup-request mit valid email → 200, Mail
|
|
8
|
-
//
|
|
7
|
+
// 1. POST signup-request mit valid email → 200, Activation-Mail via
|
|
8
|
+
// delivery (channel-email in-memory transport) mit Token-URL.
|
|
9
9
|
// 2. Resend-Idempotenz: zweiter Request für selbe email → gleicher
|
|
10
10
|
// Token in Mail (existing token in Redis wird re-genutzt).
|
|
11
11
|
// 3. POST signup-confirm mit captured Token + Password → 200, Cookies
|
|
@@ -32,9 +32,15 @@ import {
|
|
|
32
32
|
unsafeCreateEntityTable,
|
|
33
33
|
unsafePushTables,
|
|
34
34
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
35
|
+
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
35
36
|
import { createConfigFeature } from "../../config";
|
|
36
37
|
import { createConfigResolver } from "../../config/resolver";
|
|
37
38
|
import { configValuesTable } from "../../config/table";
|
|
39
|
+
import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
|
|
40
|
+
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
41
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
42
|
+
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
43
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
38
44
|
import { createTenantFeature } from "../../tenant";
|
|
39
45
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
40
46
|
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
@@ -44,11 +50,11 @@ import { AuthErrors, AuthHandlers } from "../constants";
|
|
|
44
50
|
import { createAuthEmailPasswordFeature } from "../feature";
|
|
45
51
|
|
|
46
52
|
const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
|
|
54
|
+
// Activation mails now go through delivery (ctx.notify → channel-email). The
|
|
55
|
+
// in-memory transport captures what would be sent; route:{email} delivers
|
|
56
|
+
// directly (no jobRunner in the test stack → inline send).
|
|
57
|
+
const emailTransport = createInMemoryTransport();
|
|
52
58
|
|
|
53
59
|
let stack: TestStack;
|
|
54
60
|
|
|
@@ -58,21 +64,31 @@ beforeAll(async () => {
|
|
|
58
64
|
createConfigFeature(),
|
|
59
65
|
createUserFeature(),
|
|
60
66
|
createTenantFeature(),
|
|
67
|
+
createTemplateResolverFeature(),
|
|
68
|
+
createRendererFoundationFeature(),
|
|
69
|
+
createDeliveryFeature(),
|
|
70
|
+
createRendererSimpleFeature(),
|
|
71
|
+
createChannelEmailFeature({
|
|
72
|
+
transport: emailTransport,
|
|
73
|
+
renderer: simpleRenderer,
|
|
74
|
+
// route:{email} delivers directly — resolveEmail (userId→address) is
|
|
75
|
+
// never hit by the signup flow, but the channel requires it.
|
|
76
|
+
resolveEmail: async () => "unused@test.local",
|
|
77
|
+
}),
|
|
61
78
|
createAuthEmailPasswordFeature({
|
|
62
|
-
signup: { tokenTtlMinutes: 60 },
|
|
79
|
+
signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
|
|
63
80
|
}),
|
|
64
81
|
],
|
|
65
|
-
extraContext:
|
|
82
|
+
extraContext: (deps) => ({
|
|
83
|
+
...createDeliveryTestContext(deps),
|
|
84
|
+
configResolver: createConfigResolver(),
|
|
85
|
+
}),
|
|
66
86
|
authConfig: {
|
|
67
87
|
membershipQuery: "tenant:query:memberships",
|
|
68
88
|
loginHandler: AuthHandlers.login,
|
|
69
89
|
signup: {
|
|
70
90
|
requestHandler: AuthHandlers.signupRequest,
|
|
71
91
|
confirmHandler: AuthHandlers.signupConfirm,
|
|
72
|
-
appActivationUrl: APP_ACTIVATION_URL,
|
|
73
|
-
sendActivationEmail: async (args) => {
|
|
74
|
-
capturedActivationEmails.push(args);
|
|
75
|
-
},
|
|
76
92
|
},
|
|
77
93
|
},
|
|
78
94
|
});
|
|
@@ -82,7 +98,11 @@ beforeAll(async () => {
|
|
|
82
98
|
// tenant.schema.indexes). unsafeCreateEntityTable baut das via
|
|
83
99
|
// buildEntityTable nach — pinst den TOCTOU-Schutz für signup-confirm.
|
|
84
100
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
85
|
-
await unsafePushTables(stack.db, {
|
|
101
|
+
await unsafePushTables(stack.db, {
|
|
102
|
+
configValuesTable,
|
|
103
|
+
tenantMembershipsTable,
|
|
104
|
+
notificationPreferencesTable,
|
|
105
|
+
});
|
|
86
106
|
});
|
|
87
107
|
|
|
88
108
|
afterAll(async () => {
|
|
@@ -93,7 +113,7 @@ beforeEach(async () => {
|
|
|
93
113
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
94
114
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
95
115
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
96
|
-
|
|
116
|
+
emailTransport.sent.length = 0;
|
|
97
117
|
// Redis-cleanup damit Resend-Tests keine state-leaks haben.
|
|
98
118
|
const allKeys = await stack.redis.redis.keys("signup:*");
|
|
99
119
|
if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
|
|
@@ -111,52 +131,49 @@ async function postLogin(email: string, password: string): Promise<Response> {
|
|
|
111
131
|
return stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
112
132
|
}
|
|
113
133
|
|
|
114
|
-
function
|
|
115
|
-
const match =
|
|
116
|
-
if (!match?.[1]) throw new Error(`No token in
|
|
134
|
+
function extractTokenFromMail(html: string): string {
|
|
135
|
+
const match = html.match(/[?&]token=([^&"'<\s]+)/);
|
|
136
|
+
if (!match?.[1]) throw new Error(`No token in mail html: ${html.slice(0, 200)}`);
|
|
117
137
|
return decodeURIComponent(match[1]);
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
describe("POST /api/auth/signup-request", () => {
|
|
121
|
-
test("known email → 200, mail
|
|
141
|
+
test("known email → 200, delivery sends activation mail with token URL", async () => {
|
|
122
142
|
const res = await postSignupRequest("alice@example.com");
|
|
123
143
|
expect(res.status).toBe(200);
|
|
124
144
|
expect(await res.json()).toEqual({ isSuccess: true });
|
|
125
|
-
expect(
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
128
|
-
expect(
|
|
129
|
-
expect(
|
|
130
|
-
expect(typeof captured.expiresAt).toBe("string");
|
|
145
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
146
|
+
const sent = emailTransport.sent[0];
|
|
147
|
+
if (!sent) throw new Error("no mail sent");
|
|
148
|
+
expect(sent.to).toBe("alice@example.com");
|
|
149
|
+
expect(sent.html).toContain(`${APP_ACTIVATION_URL}?token=`);
|
|
131
150
|
});
|
|
132
151
|
|
|
133
152
|
test("Resend: zweiter Request für selbe email → gleicher token in Mail", async () => {
|
|
134
153
|
await postSignupRequest("resend@example.com");
|
|
135
154
|
await postSignupRequest("resend@example.com");
|
|
136
155
|
|
|
137
|
-
expect(
|
|
138
|
-
const [first, second] =
|
|
139
|
-
if (!first || !second) throw new Error("missing
|
|
140
|
-
expect(
|
|
141
|
-
extractTokenFromUrl(first.activationUrl),
|
|
142
|
-
);
|
|
156
|
+
expect(emailTransport.sent).toHaveLength(2);
|
|
157
|
+
const [first, second] = emailTransport.sent;
|
|
158
|
+
if (!first || !second) throw new Error("missing mails");
|
|
159
|
+
expect(extractTokenFromMail(second.html)).toBe(extractTokenFromMail(first.html));
|
|
143
160
|
});
|
|
144
161
|
|
|
145
162
|
test("malformed body → 200 (silent success, anti-enumeration)", async () => {
|
|
146
163
|
const res = await stack.http.raw("POST", "/api/auth/signup-request", { wrong: "shape" });
|
|
147
164
|
expect(res.status).toBe(200);
|
|
148
|
-
expect(
|
|
165
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
149
166
|
});
|
|
150
167
|
});
|
|
151
168
|
|
|
152
169
|
describe("POST /api/auth/signup-confirm", () => {
|
|
153
170
|
async function requestSignup(email: string): Promise<string> {
|
|
154
|
-
|
|
171
|
+
emailTransport.sent.length = 0;
|
|
155
172
|
const res = await postSignupRequest(email);
|
|
156
173
|
expect(res.status).toBe(200);
|
|
157
|
-
const
|
|
158
|
-
if (!
|
|
159
|
-
return
|
|
174
|
+
const sent = emailTransport.sent[0];
|
|
175
|
+
if (!sent) throw new Error("signup-request fixture didn't send mail");
|
|
176
|
+
return extractTokenFromMail(sent.html);
|
|
160
177
|
}
|
|
161
178
|
|
|
162
179
|
test("voller Roundtrip: confirm legt user + tenant + Admin-Membership an, Cookies + Login funktioniert", async () => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Convention paths for the four auth pages (relative to the app base URL).
|
|
2
|
+
// resolveAuthMail builds each flow's magic-link appUrl from baseUrl + these;
|
|
3
|
+
// the handlers append `?token=…` and mail via delivery (ctx.notify).
|
|
4
|
+
|
|
5
|
+
/** Pfad-Konstanten der 4 Auth-Seiten (relativ zur App-baseUrl). */
|
|
6
|
+
export type AuthPaths = {
|
|
7
|
+
readonly resetPassword: string;
|
|
8
|
+
readonly verifyEmail: string;
|
|
9
|
+
readonly signupComplete: string;
|
|
10
|
+
readonly inviteAccept: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Konventions-Pfade — alle Kumiko-Apps nutzen dieselben. Apps überschreiben
|
|
14
|
+
* nur die Ausnahme via `makeAuthPaths({ ... })`. */
|
|
15
|
+
export const DEFAULT_AUTH_PATHS: AuthPaths = {
|
|
16
|
+
resetPassword: "/reset-password",
|
|
17
|
+
verifyEmail: "/verify-email",
|
|
18
|
+
signupComplete: "/signup/complete",
|
|
19
|
+
inviteAccept: "/invite/accept",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function makeAuthPaths(overrides: Partial<AuthPaths> = {}): AuthPaths {
|
|
23
|
+
return { ...DEFAULT_AUTH_PATHS, ...overrides };
|
|
24
|
+
}
|
|
@@ -1,63 +1,42 @@
|
|
|
1
|
-
// Default
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Default renderers for the transactional auth mails. All four magic-link flows
|
|
2
|
+
// (password-reset, email-verification, signup-activation, invite) emit
|
|
3
|
+
// structured AuthMailContent that the handler hands to delivery (ctx.notify) —
|
|
4
|
+
// renderer-simple turns it into HTML. Apps wanting their own branding swap the
|
|
5
|
+
// renderer; these templates are deliberately plain so the renderer (and the
|
|
6
|
+
// operator reading the mailer log) can rely on them.
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
// sendResetEmail: ({ email, resetUrl, expiresAt }) =>
|
|
9
|
-
// mailSender.send({
|
|
10
|
-
// to: email,
|
|
11
|
-
// ...renderResetPasswordEmail({ resetUrl, expiresAt, locale: "de" }),
|
|
12
|
-
// }),
|
|
13
|
-
// }
|
|
14
|
-
//
|
|
15
|
-
// Apps die ihr eigenes Branding wollen, schreiben einen eigenen Renderer
|
|
16
|
-
// und mischen ihn in. Die Templates hier sind bewusst plain HTML mit
|
|
17
|
-
// inline-styling — kein CSS-Framework, kein bild-asset. Mail-Clients
|
|
18
|
-
// rendern das verlässlich, und der Operator kann das HTML im Mailer-Log
|
|
19
|
-
// problemlos lesen.
|
|
20
|
-
//
|
|
21
|
-
// Locale: de + en. Apps mit anderen Sprachen rendern selbst.
|
|
8
|
+
// Locale: de + en. Apps with other languages render themselves.
|
|
22
9
|
|
|
23
|
-
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
24
10
|
import { Temporal } from "temporal-polyfill";
|
|
25
11
|
|
|
26
12
|
export type AuthMailLocale = "de" | "en";
|
|
27
13
|
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
// Unified args for the structured token-mail renderers (reset + verify).
|
|
15
|
+
// `url` is the fully-built magic-link — the handler already appended ?token=.
|
|
16
|
+
export type RenderTokenContentArgs = {
|
|
17
|
+
readonly url: string;
|
|
30
18
|
readonly expiresAt: string;
|
|
31
19
|
readonly locale?: AuthMailLocale;
|
|
32
20
|
/** Optional: App-Name fürs Subject + Header. Default "Account". */
|
|
33
21
|
readonly appName?: string;
|
|
34
22
|
};
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
readonly locale?: AuthMailLocale;
|
|
40
|
-
readonly appName?: string;
|
|
41
|
-
};
|
|
24
|
+
// Invite adds the role to the unified token-content args; `url` is the fully-
|
|
25
|
+
// built magic-link (the handler already appended ?token=).
|
|
26
|
+
export type RenderInviteEmailArgs = RenderTokenContentArgs & { readonly role: string };
|
|
42
27
|
|
|
43
|
-
export type
|
|
44
|
-
readonly
|
|
45
|
-
readonly
|
|
46
|
-
readonly locale?: AuthMailLocale;
|
|
47
|
-
readonly appName?: string;
|
|
48
|
-
};
|
|
28
|
+
export type AuthMailSection =
|
|
29
|
+
| { readonly text: string }
|
|
30
|
+
| { readonly button: { readonly label: string; readonly url: string } };
|
|
49
31
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
readonly locale?: AuthMailLocale;
|
|
55
|
-
readonly appName?: string;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export type RenderedEmail = {
|
|
32
|
+
// Structured content the magic-link handlers pass as the delivery `data`
|
|
33
|
+
// payload: renderer-simple turns header/sections/footer into HTML, the
|
|
34
|
+
// email-channel reads `subject`. No pre-rendered HTML — the renderer owns layout.
|
|
35
|
+
export type AuthMailContent = {
|
|
59
36
|
readonly subject: string;
|
|
60
|
-
readonly
|
|
37
|
+
readonly header: string;
|
|
38
|
+
readonly sections: readonly AuthMailSection[];
|
|
39
|
+
readonly footer: string;
|
|
61
40
|
};
|
|
62
41
|
|
|
63
42
|
const STRINGS = {
|
|
@@ -94,7 +73,6 @@ const STRINGS = {
|
|
|
94
73
|
inviteExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
|
|
95
74
|
inviteIgnore:
|
|
96
75
|
"Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.",
|
|
97
|
-
fallbackUrl: "Falls der Button nicht funktioniert, kopiere diesen Link in den Browser:",
|
|
98
76
|
},
|
|
99
77
|
en: {
|
|
100
78
|
resetSubject: (app: string) => `${app} — Reset your password`,
|
|
@@ -127,134 +105,97 @@ const STRINGS = {
|
|
|
127
105
|
inviteButton: "Accept invitation",
|
|
128
106
|
inviteExpiry: (when: string) => `The link expires on ${when}.`,
|
|
129
107
|
inviteIgnore: "If you weren't expecting this invitation, you can ignore this email.",
|
|
130
|
-
fallbackUrl: "If the button doesn't work, copy this link into your browser:",
|
|
131
108
|
},
|
|
132
109
|
} as const;
|
|
133
110
|
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
111
|
+
// Structured content for the delivery path. header = action title (CTA label),
|
|
112
|
+
// sections = greeting/intro/button/expiry, footer = the "ignore if not you"
|
|
113
|
+
// reassurance. The old plain-text fallback-URL link drops out — renderer-simple
|
|
114
|
+
// has no link-text section and the button carries the URL.
|
|
115
|
+
function tokenMailContent(spec: {
|
|
139
116
|
readonly subject: string;
|
|
117
|
+
readonly header: string;
|
|
140
118
|
readonly greeting: string;
|
|
141
119
|
readonly intro: string;
|
|
142
120
|
readonly buttonLabel: string;
|
|
143
121
|
readonly buttonUrl: string;
|
|
144
122
|
readonly expiry: string;
|
|
145
123
|
readonly ignore: string;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
</td></tr>`;
|
|
159
|
-
return { subject: spec.subject, html: renderShell({ title: spec.subject, bodyHtml }) };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Plain inline-styled HTML — funktioniert in Gmail/Outlook/Apple-Mail
|
|
163
|
-
// ohne dass wir Tailwind oder eine HTML-mail-Lib reinziehen müssen.
|
|
164
|
-
// guard:dup-ok — Email-HTML (table-layout, inline CSS) ≠ Web-HTML (legal-pages/markdown.ts)
|
|
165
|
-
function renderShell(args: { title: string; bodyHtml: string }): string {
|
|
166
|
-
return `<!DOCTYPE html>
|
|
167
|
-
<html lang="en">
|
|
168
|
-
<head>
|
|
169
|
-
<meta charset="utf-8" />
|
|
170
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
171
|
-
<title>${escapeHtml(args.title)}</title>
|
|
172
|
-
</head>
|
|
173
|
-
<body style="margin: 0; padding: 0; background: #f7f7f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a;">
|
|
174
|
-
<table width="100%" cellpadding="0" cellspacing="0" style="padding: 24px 0;">
|
|
175
|
-
<tr>
|
|
176
|
-
<td align="center">
|
|
177
|
-
<table width="560" cellpadding="0" cellspacing="0" style="max-width: 560px; background: #ffffff; border-radius: 8px; padding: 32px;">
|
|
178
|
-
${args.bodyHtml}
|
|
179
|
-
</table>
|
|
180
|
-
</td>
|
|
181
|
-
</tr>
|
|
182
|
-
</table>
|
|
183
|
-
</body>
|
|
184
|
-
</html>`;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
|
|
188
|
-
function renderButton(args: { url: string; label: string }): string {
|
|
189
|
-
return `<a href="${escapeHtmlAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
|
|
193
|
-
function renderFallbackUrl(args: { url: string; label: string }): string {
|
|
194
|
-
return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeHtmlAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
|
|
124
|
+
}): AuthMailContent {
|
|
125
|
+
return {
|
|
126
|
+
subject: spec.subject,
|
|
127
|
+
header: spec.header,
|
|
128
|
+
sections: [
|
|
129
|
+
{ text: spec.greeting },
|
|
130
|
+
{ text: spec.intro },
|
|
131
|
+
{ button: { label: spec.buttonLabel, url: spec.buttonUrl } },
|
|
132
|
+
{ text: spec.expiry },
|
|
133
|
+
],
|
|
134
|
+
footer: spec.ignore,
|
|
135
|
+
};
|
|
195
136
|
}
|
|
196
137
|
|
|
197
|
-
export function renderResetPasswordEmail(args:
|
|
138
|
+
export function renderResetPasswordEmail(args: RenderTokenContentArgs): AuthMailContent {
|
|
198
139
|
const locale = args.locale ?? "en";
|
|
199
140
|
const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
|
|
200
141
|
const t = STRINGS[locale];
|
|
201
|
-
return
|
|
142
|
+
return tokenMailContent({
|
|
202
143
|
subject: t.resetSubject(appName),
|
|
144
|
+
header: t.resetButton,
|
|
203
145
|
greeting: t.resetGreeting,
|
|
204
146
|
intro: t.resetIntro(appName),
|
|
205
147
|
buttonLabel: t.resetButton,
|
|
206
|
-
buttonUrl: args.
|
|
148
|
+
buttonUrl: args.url,
|
|
207
149
|
expiry: t.resetExpiry(formatExpiry(args.expiresAt)),
|
|
208
150
|
ignore: t.resetIgnore,
|
|
209
|
-
fallbackUrlLabel: t.fallbackUrl,
|
|
210
151
|
});
|
|
211
152
|
}
|
|
212
153
|
|
|
213
|
-
export function renderVerifyEmail(args:
|
|
154
|
+
export function renderVerifyEmail(args: RenderTokenContentArgs): AuthMailContent {
|
|
214
155
|
const locale = args.locale ?? "en";
|
|
215
156
|
const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
|
|
216
157
|
const t = STRINGS[locale];
|
|
217
|
-
return
|
|
158
|
+
return tokenMailContent({
|
|
218
159
|
subject: t.verifySubject(appName),
|
|
160
|
+
header: t.verifyButton,
|
|
219
161
|
greeting: t.verifyGreeting,
|
|
220
162
|
intro: t.verifyIntro(appName),
|
|
221
163
|
buttonLabel: t.verifyButton,
|
|
222
|
-
buttonUrl: args.
|
|
164
|
+
buttonUrl: args.url,
|
|
223
165
|
expiry: t.verifyExpiry(formatExpiry(args.expiresAt)),
|
|
224
166
|
ignore: t.verifyIgnore,
|
|
225
|
-
fallbackUrlLabel: t.fallbackUrl,
|
|
226
167
|
});
|
|
227
168
|
}
|
|
228
169
|
|
|
229
|
-
export function renderActivationEmail(args:
|
|
170
|
+
export function renderActivationEmail(args: RenderTokenContentArgs): AuthMailContent {
|
|
230
171
|
const locale = args.locale ?? "en";
|
|
231
172
|
const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
|
|
232
173
|
const t = STRINGS[locale];
|
|
233
|
-
return
|
|
174
|
+
return tokenMailContent({
|
|
234
175
|
subject: t.activationSubject(appName),
|
|
176
|
+
header: t.activationButton,
|
|
235
177
|
greeting: t.activationGreeting,
|
|
236
178
|
intro: t.activationIntro(appName),
|
|
237
179
|
buttonLabel: t.activationButton,
|
|
238
|
-
buttonUrl: args.
|
|
180
|
+
buttonUrl: args.url,
|
|
239
181
|
expiry: t.activationExpiry(formatExpiry(args.expiresAt)),
|
|
240
182
|
ignore: t.activationIgnore,
|
|
241
|
-
fallbackUrlLabel: t.fallbackUrl,
|
|
242
183
|
});
|
|
243
184
|
}
|
|
244
185
|
|
|
245
|
-
export function renderInviteEmail(args: RenderInviteEmailArgs):
|
|
186
|
+
export function renderInviteEmail(args: RenderInviteEmailArgs): AuthMailContent {
|
|
246
187
|
const locale = args.locale ?? "en";
|
|
247
|
-
const appName = args.appName ??
|
|
188
|
+
const appName = args.appName ?? "Workspace";
|
|
248
189
|
const t = STRINGS[locale];
|
|
249
|
-
return
|
|
190
|
+
return tokenMailContent({
|
|
250
191
|
subject: t.inviteSubject(appName),
|
|
192
|
+
header: t.inviteButton,
|
|
251
193
|
greeting: t.inviteGreeting,
|
|
252
194
|
intro: t.inviteIntro(appName, args.role),
|
|
253
195
|
buttonLabel: t.inviteButton,
|
|
254
|
-
buttonUrl: args.
|
|
196
|
+
buttonUrl: args.url,
|
|
255
197
|
expiry: t.inviteExpiry(formatExpiry(args.expiresAt)),
|
|
256
198
|
ignore: t.inviteIgnore,
|
|
257
|
-
fallbackUrlLabel: t.fallbackUrl,
|
|
258
199
|
});
|
|
259
200
|
}
|
|
260
201
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import type { AuthMailLocale } from "./email-templates";
|
|
3
4
|
import { changePasswordWrite } from "./handlers/change-password.write";
|
|
4
5
|
import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
|
|
5
6
|
import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write";
|
|
@@ -54,6 +55,12 @@ export const authEmailPasswordEnvSchema = z.object({
|
|
|
54
55
|
export type PasswordResetOptions = {
|
|
55
56
|
readonly hmacSecret: string;
|
|
56
57
|
readonly tokenTtlMinutes?: number;
|
|
58
|
+
// App page that receives the magic-link; the handler appends `?token=…` and
|
|
59
|
+
// sends the mail via delivery (ctx.notify). No sendResetEmail callback — the
|
|
60
|
+
// app mounts `delivery` + a mail channel instead.
|
|
61
|
+
readonly appUrl: string;
|
|
62
|
+
readonly appName?: string;
|
|
63
|
+
readonly locale?: AuthMailLocale;
|
|
57
64
|
};
|
|
58
65
|
|
|
59
66
|
// Opt-in configuration for the email-verification flow. mode="strict"
|
|
@@ -65,6 +72,11 @@ export type EmailVerificationOptions = {
|
|
|
65
72
|
readonly hmacSecret: string;
|
|
66
73
|
readonly tokenTtlMinutes?: number;
|
|
67
74
|
readonly mode?: "strict" | "off";
|
|
75
|
+
// App page that receives the magic-link; the handler appends `?token=…` and
|
|
76
|
+
// sends via delivery (ctx.notify). No sendVerificationEmail callback.
|
|
77
|
+
readonly appUrl: string;
|
|
78
|
+
readonly appName?: string;
|
|
79
|
+
readonly locale?: AuthMailLocale;
|
|
68
80
|
};
|
|
69
81
|
|
|
70
82
|
// Brute-force protection on the login handler. Omit for the defaults
|
|
@@ -126,7 +138,7 @@ export function createAuthEmailPasswordFeature(
|
|
|
126
138
|
|
|
127
139
|
return defineFeature("auth-email-password", (r) => {
|
|
128
140
|
r.describe(
|
|
129
|
-
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`.
|
|
141
|
+
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`. All four magic-link flows (reset, verification, signup activation, tenant invite) dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify) or opaque-random in Redis (signup/invite). Requires the `user` and `tenant` features, and declares `JWT_SECRET` (\u2265 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
|
|
130
142
|
);
|
|
131
143
|
r.uiHints({
|
|
132
144
|
displayLabel: "Auth \u00b7 Email + Password",
|
|
@@ -146,6 +158,12 @@ export function createAuthEmailPasswordFeature(
|
|
|
146
158
|
});
|
|
147
159
|
r.requires("user");
|
|
148
160
|
r.requires("tenant");
|
|
161
|
+
// All four magic-link flows (reset/verify/signup/invite) dispatch via
|
|
162
|
+
// ctx.notify → delivery must be mounted. Fail closed at boot instead of
|
|
163
|
+
// silently dropping the mail.
|
|
164
|
+
if (opts.passwordReset || opts.emailVerification || opts.signup || opts.invite) {
|
|
165
|
+
r.requires("delivery");
|
|
166
|
+
}
|
|
149
167
|
r.envSchema(authEmailPasswordEnvSchema);
|
|
150
168
|
|
|
151
169
|
const handlers = {
|