@cosmicdrift/kumiko-bundled-features 0.35.0 → 0.38.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 +5 -5
- package/src/auth-email-password/__tests__/auth-mailer.test.ts +138 -0
- package/src/auth-email-password/auth-mailer.ts +137 -0
- package/src/auth-email-password/email-templates.ts +7 -13
- package/src/auth-email-password/errors.ts +84 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -10
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
- package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
- package/src/auth-email-password/handlers/login.write.ts +7 -51
- package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
- package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
- package/src/auth-email-password/index.ts +9 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
- package/src/cap-counter/enforce-cap.ts +5 -0
- package/src/compliance-profiles/README.md +1 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -1
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
- package/src/custom-fields/db/queries/retention.ts +1 -0
- package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
- package/src/custom-fields/run-retention.ts +4 -22
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
- package/src/custom-fields/wire-for-entity.ts +4 -12
- package/src/custom-fields/wire-user-data-rights.ts +3 -22
- package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
- package/src/file-foundation/feature.ts +13 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
- package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
- package/src/files/README.md +1 -1
- package/src/legal-pages/markdown.ts +1 -13
- package/src/renderer-simple/simple-renderer.ts +1 -8
- package/src/subscription-stripe/feature.ts +5 -2
- package/src/template-resolver/feature.ts +1 -2
- package/src/template-resolver/handlers/list.query.ts +7 -14
- package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
- package/src/tenant/command-schemas.ts +1 -1
- package/src/tenant/feature.ts +1 -2
- package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
- package/src/user-data-rights/README.md +8 -8
- package/src/template-resolver/handlers/archive.write.ts +0 -39
- package/src/template-resolver/handlers/publish.write.ts +0 -42
- package/src/tenant/handlers/disable.write.ts +0 -18
- package/src/tenant/handlers/enable.write.ts +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -74,10 +74,10 @@
|
|
|
74
74
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
78
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
77
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.37.0",
|
|
78
|
+
"@cosmicdrift/kumiko-framework": "0.37.0",
|
|
79
|
+
"@cosmicdrift/kumiko-renderer": "0.37.0",
|
|
80
|
+
"@cosmicdrift/kumiko-renderer-web": "0.37.0",
|
|
81
81
|
"@mollie/api-client": "^4.5.0",
|
|
82
82
|
"@node-rs/argon2": "^2.0.2",
|
|
83
83
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createInMemoryTransport } from "../../channel-email";
|
|
3
|
+
import { createAuthMailerConfig } from "../auth-mailer";
|
|
4
|
+
|
|
5
|
+
function makeArgs(overrides?: Record<string, unknown>) {
|
|
6
|
+
const mailSender = createInMemoryTransport();
|
|
7
|
+
return {
|
|
8
|
+
mailSender,
|
|
9
|
+
hmacSecret: "test-hmac-secret",
|
|
10
|
+
baseUrl: "https://admin.example.com",
|
|
11
|
+
paths: {
|
|
12
|
+
resetPassword: "/reset-password",
|
|
13
|
+
verifyEmail: "/verify-email",
|
|
14
|
+
signupComplete: "/signup/complete",
|
|
15
|
+
inviteAccept: "/invite/accept",
|
|
16
|
+
},
|
|
17
|
+
appName: "TestApp",
|
|
18
|
+
locale: "en" as const,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("createAuthMailerConfig", () => {
|
|
24
|
+
test("returns all 4 setups", () => {
|
|
25
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
26
|
+
expect(config.passwordReset).toBeDefined();
|
|
27
|
+
expect(config.emailVerification).toBeDefined();
|
|
28
|
+
expect(config.signup).toBeDefined();
|
|
29
|
+
expect(config.invite).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("constructs URLs from baseUrl + paths", () => {
|
|
33
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
34
|
+
expect(config.passwordReset.appResetUrl).toBe("https://admin.example.com/reset-password");
|
|
35
|
+
expect(config.emailVerification.appVerifyUrl).toBe("https://admin.example.com/verify-email");
|
|
36
|
+
expect(config.signup.appActivationUrl).toBe("https://admin.example.com/signup/complete");
|
|
37
|
+
expect(config.invite.appAcceptUrl).toBe("https://admin.example.com/invite/accept");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("forwards hmacSecret", () => {
|
|
41
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
42
|
+
expect(config.passwordReset.hmacSecret).toBe("test-hmac-secret");
|
|
43
|
+
expect(config.emailVerification.hmacSecret).toBe("test-hmac-secret");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("sendResetEmail calls mailSender.send with rendered content", async () => {
|
|
47
|
+
const args = makeArgs();
|
|
48
|
+
const config = createAuthMailerConfig(args);
|
|
49
|
+
|
|
50
|
+
await config.passwordReset.sendResetEmail({
|
|
51
|
+
email: "user@example.com",
|
|
52
|
+
resetUrl: "https://admin.example.com/reset?token=abc",
|
|
53
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
57
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
58
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
59
|
+
expect(args.mailSender.sent[0]!.subject).toContain("Reset");
|
|
60
|
+
expect(args.mailSender.sent[0]!.html).toContain("https://admin.example.com/reset?token=abc");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("sendVerificationEmail calls mailSender.send", async () => {
|
|
64
|
+
const args = makeArgs();
|
|
65
|
+
const config = createAuthMailerConfig(args);
|
|
66
|
+
|
|
67
|
+
await config.emailVerification.sendVerificationEmail({
|
|
68
|
+
email: "user@example.com",
|
|
69
|
+
verificationUrl: "https://admin.example.com/verify?token=abc",
|
|
70
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
74
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
75
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
76
|
+
expect(args.mailSender.sent[0]!.subject).toContain("Verify");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("sendActivationEmail calls mailSender.send", async () => {
|
|
80
|
+
const args = makeArgs();
|
|
81
|
+
const config = createAuthMailerConfig(args);
|
|
82
|
+
|
|
83
|
+
await config.signup.sendActivationEmail({
|
|
84
|
+
email: "user@example.com",
|
|
85
|
+
activationUrl: "https://admin.example.com/signup/complete?token=abc",
|
|
86
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
90
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
91
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("sendInviteEmail calls mailSender.send with role", async () => {
|
|
95
|
+
const args = makeArgs();
|
|
96
|
+
const config = createAuthMailerConfig(args);
|
|
97
|
+
|
|
98
|
+
await config.invite.sendInviteEmail({
|
|
99
|
+
email: "user@example.com",
|
|
100
|
+
inviteUrl: "https://admin.example.com/invite/accept?token=abc",
|
|
101
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
102
|
+
role: "Admin",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(args.mailSender.sent).toHaveLength(1);
|
|
106
|
+
expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
|
|
107
|
+
expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
|
|
108
|
+
expect(args.mailSender.sent[0]!.html).toContain("Admin");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("uses defaults when appName is omitted", () => {
|
|
112
|
+
const config = createAuthMailerConfig(makeArgs({ appName: undefined }));
|
|
113
|
+
expect(config.passwordReset.appResetUrl).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("emailVerificationMode is absent when not provided", () => {
|
|
117
|
+
const config = createAuthMailerConfig(makeArgs());
|
|
118
|
+
expect("mode" in config.emailVerification).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("emailVerificationMode is set when provided", () => {
|
|
122
|
+
const config = createAuthMailerConfig(makeArgs({ emailVerificationMode: "strict" }));
|
|
123
|
+
expect(config.emailVerification).toHaveProperty("mode", "strict");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("locale 'de' renders German subject", async () => {
|
|
127
|
+
const args = makeArgs({ locale: "de" });
|
|
128
|
+
const config = createAuthMailerConfig(args);
|
|
129
|
+
|
|
130
|
+
await config.passwordReset.sendResetEmail({
|
|
131
|
+
email: "user@example.com",
|
|
132
|
+
resetUrl: "https://example.com/reset?token=abc",
|
|
133
|
+
expiresAt: "2026-06-09T12:00:00.000Z",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(args.mailSender.sent[0]!.subject).toContain("Passwort");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { EmailTransport } from "../channel-email";
|
|
2
|
+
import type { AuthMailLocale } from "./email-templates";
|
|
3
|
+
import {
|
|
4
|
+
renderActivationEmail,
|
|
5
|
+
renderInviteEmail,
|
|
6
|
+
renderResetPasswordEmail,
|
|
7
|
+
renderVerifyEmail,
|
|
8
|
+
} from "./email-templates";
|
|
9
|
+
import type {
|
|
10
|
+
EmailVerificationOptions,
|
|
11
|
+
InviteOptions,
|
|
12
|
+
PasswordResetOptions,
|
|
13
|
+
SignupOptions,
|
|
14
|
+
} from "./feature";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Komplette Konfiguration für die 4 Auth-Mail-Flows einer App.
|
|
18
|
+
*
|
|
19
|
+
* Strukturell kompatibel mit den `*Setup`-Typen aus
|
|
20
|
+
* `@cosmicdrift/kumiko-dev-server` (`PasswordResetSetup`,
|
|
21
|
+
* `EmailVerificationSetup`, `SignupSetup`, `InviteSetup`).
|
|
22
|
+
*/
|
|
23
|
+
export type AuthMailerConfig = {
|
|
24
|
+
readonly passwordReset: PasswordResetOptions & {
|
|
25
|
+
readonly appResetUrl: string;
|
|
26
|
+
readonly sendResetEmail: (args: {
|
|
27
|
+
email: string;
|
|
28
|
+
resetUrl: string;
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
}) => Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
readonly emailVerification: EmailVerificationOptions & {
|
|
33
|
+
readonly appVerifyUrl: string;
|
|
34
|
+
readonly sendVerificationEmail: (args: {
|
|
35
|
+
email: string;
|
|
36
|
+
verificationUrl: string;
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
}) => Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
readonly signup: SignupOptions & {
|
|
41
|
+
readonly appActivationUrl: string;
|
|
42
|
+
readonly sendActivationEmail: (args: {
|
|
43
|
+
email: string;
|
|
44
|
+
activationUrl: string;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
}) => Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
readonly invite: InviteOptions & {
|
|
49
|
+
readonly appAcceptUrl: string;
|
|
50
|
+
readonly sendInviteEmail: (args: {
|
|
51
|
+
email: string;
|
|
52
|
+
inviteUrl: string;
|
|
53
|
+
expiresAt: string;
|
|
54
|
+
role: string;
|
|
55
|
+
}) => Promise<void>;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type CreateAuthMailerConfigArgs = {
|
|
60
|
+
readonly mailSender: EmailTransport;
|
|
61
|
+
readonly hmacSecret: string;
|
|
62
|
+
/** Basis-URL der App inkl. Schema (z.B. "https://admin.example.com").
|
|
63
|
+
* Die factory hängt paths.resetPassword etc. an. */
|
|
64
|
+
readonly baseUrl: string;
|
|
65
|
+
/** Pfad-Konstanten für die Auth-Seiten — jede App hat ihre eigenen
|
|
66
|
+
* in `./auth-paths.ts`. */
|
|
67
|
+
readonly paths: {
|
|
68
|
+
readonly resetPassword: string;
|
|
69
|
+
readonly verifyEmail: string;
|
|
70
|
+
readonly signupComplete: string;
|
|
71
|
+
readonly inviteAccept: string;
|
|
72
|
+
};
|
|
73
|
+
/** App-Name für Mail-Subject + Body. Default "Account". */
|
|
74
|
+
readonly appName?: string;
|
|
75
|
+
/** Locale für die Mail-Templates. Default "de". */
|
|
76
|
+
readonly locale?: AuthMailLocale;
|
|
77
|
+
/** Email-verification mode. Default undefined (kein Gate).
|
|
78
|
+
* "strict" blockt Login solange `emailVerified=false`.
|
|
79
|
+
* "off" mountet die Routes ohne login-gating. */
|
|
80
|
+
readonly emailVerificationMode?: "strict" | "off";
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Factory für `AuthMailerConfig` — baut die 4 Auth-Mail-Setups
|
|
85
|
+
* (passwordReset / emailVerification / signup / invite) inklusive
|
|
86
|
+
* `send*Email`-Wrapper + URL-Konstruktion.
|
|
87
|
+
*
|
|
88
|
+
* Jede App ruft das einmal auf und spreadet das Resultat in die
|
|
89
|
+
* `auth`-Option von `runProdApp` / `runDevApp`.
|
|
90
|
+
*/
|
|
91
|
+
export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMailerConfig {
|
|
92
|
+
const appName = args.appName ?? "Account";
|
|
93
|
+
const locale = args.locale ?? "de";
|
|
94
|
+
return {
|
|
95
|
+
passwordReset: {
|
|
96
|
+
hmacSecret: args.hmacSecret,
|
|
97
|
+
appResetUrl: `${args.baseUrl}${args.paths.resetPassword}`,
|
|
98
|
+
sendResetEmail: async ({ email, resetUrl, expiresAt }) => {
|
|
99
|
+
await args.mailSender.send({
|
|
100
|
+
to: email,
|
|
101
|
+
...renderResetPasswordEmail({ resetUrl, expiresAt, locale, appName }),
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
emailVerification: {
|
|
106
|
+
hmacSecret: args.hmacSecret,
|
|
107
|
+
...(args.emailVerificationMode !== undefined && {
|
|
108
|
+
mode: args.emailVerificationMode,
|
|
109
|
+
}),
|
|
110
|
+
appVerifyUrl: `${args.baseUrl}${args.paths.verifyEmail}`,
|
|
111
|
+
sendVerificationEmail: async ({ email, verificationUrl, expiresAt }) => {
|
|
112
|
+
await args.mailSender.send({
|
|
113
|
+
to: email,
|
|
114
|
+
...renderVerifyEmail({ verificationUrl, expiresAt, locale, appName }),
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
signup: {
|
|
119
|
+
appActivationUrl: `${args.baseUrl}${args.paths.signupComplete}`,
|
|
120
|
+
sendActivationEmail: async ({ email, activationUrl, expiresAt }) => {
|
|
121
|
+
await args.mailSender.send({
|
|
122
|
+
to: email,
|
|
123
|
+
...renderActivationEmail({ activationUrl, expiresAt, locale, appName }),
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
invite: {
|
|
128
|
+
appAcceptUrl: `${args.baseUrl}${args.paths.inviteAccept}`,
|
|
129
|
+
sendInviteEmail: async ({ email, inviteUrl, expiresAt, role }) => {
|
|
130
|
+
await args.mailSender.send({
|
|
131
|
+
to: email,
|
|
132
|
+
...renderInviteEmail({ inviteUrl, expiresAt, role, locale, appName }),
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Locale: de + en. Apps mit anderen Sprachen rendern selbst.
|
|
22
22
|
|
|
23
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
23
24
|
import { Temporal } from "temporal-polyfill";
|
|
24
25
|
|
|
25
26
|
export type AuthMailLocale = "de" | "en";
|
|
@@ -63,6 +64,7 @@ const STRINGS = {
|
|
|
63
64
|
de: {
|
|
64
65
|
resetSubject: (app: string) => `${app} — Passwort zurücksetzen`,
|
|
65
66
|
resetGreeting: "Hallo,",
|
|
67
|
+
// guard:dup-ok — false positive: i18n-String-Template, gleiche Arrow-Struktur wie anonymous fn in collect-table-metas
|
|
66
68
|
resetIntro: (app: string) =>
|
|
67
69
|
`du hast den Reset deines Passworts für ${app} angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen:`,
|
|
68
70
|
resetButton: "Passwort zurücksetzen",
|
|
@@ -159,6 +161,7 @@ function renderTokenEmail(spec: TokenEmailSpec): RenderedEmail {
|
|
|
159
161
|
|
|
160
162
|
// Plain inline-styled HTML — funktioniert in Gmail/Outlook/Apple-Mail
|
|
161
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)
|
|
162
165
|
function renderShell(args: { title: string; bodyHtml: string }): string {
|
|
163
166
|
return `<!DOCTYPE html>
|
|
164
167
|
<html lang="en">
|
|
@@ -181,12 +184,14 @@ function renderShell(args: { title: string; bodyHtml: string }): string {
|
|
|
181
184
|
</html>`;
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
|
|
184
188
|
function renderButton(args: { url: string; label: string }): string {
|
|
185
|
-
return `<a href="${
|
|
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>`;
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
// guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
|
|
188
193
|
function renderFallbackUrl(args: { url: string; label: string }): string {
|
|
189
|
-
return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${
|
|
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>`;
|
|
190
195
|
}
|
|
191
196
|
|
|
192
197
|
export function renderResetPasswordEmail(args: RenderResetPasswordEmailArgs): RenderedEmail {
|
|
@@ -270,14 +275,3 @@ function formatExpiry(iso: string): string {
|
|
|
270
275
|
function pad2(n: number): string {
|
|
271
276
|
return String(n).padStart(2, "0");
|
|
272
277
|
}
|
|
273
|
-
|
|
274
|
-
function escapeHtml(s: string): string {
|
|
275
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
276
|
-
}
|
|
277
|
-
function escapeAttr(s: string): string {
|
|
278
|
-
return s
|
|
279
|
-
.replace(/&/g, "&")
|
|
280
|
-
.replace(/"/g, """)
|
|
281
|
-
.replace(/</g, "<")
|
|
282
|
-
.replace(/>/g, ">");
|
|
283
|
-
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
import { AuthErrors } from "./constants";
|
|
3
|
+
|
|
4
|
+
export function invalidCredentials() {
|
|
5
|
+
return writeFailure(
|
|
6
|
+
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
7
|
+
i18nKey: "auth.errors.invalidCredentials",
|
|
8
|
+
}),
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function invalidInviteToken() {
|
|
13
|
+
return writeFailure(
|
|
14
|
+
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
15
|
+
i18nKey: "auth.errors.invalidInviteToken",
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function inviteEmailMismatch() {
|
|
21
|
+
return writeFailure(
|
|
22
|
+
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
23
|
+
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function invalidResetToken() {
|
|
29
|
+
return writeFailure(
|
|
30
|
+
new UnprocessableError(AuthErrors.invalidResetToken, {
|
|
31
|
+
i18nKey: "auth.errors.invalidResetToken",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function invalidVerificationToken() {
|
|
37
|
+
return writeFailure(
|
|
38
|
+
new UnprocessableError(AuthErrors.invalidVerificationToken, {
|
|
39
|
+
i18nKey: "auth.errors.invalidVerificationToken",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function invalidSignupToken() {
|
|
45
|
+
return writeFailure(
|
|
46
|
+
new UnprocessableError(AuthErrors.invalidSignupToken, {
|
|
47
|
+
i18nKey: "auth.errors.invalidSignupToken",
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function noMembership() {
|
|
53
|
+
return writeFailure(
|
|
54
|
+
new UnprocessableError(AuthErrors.noMembership, {
|
|
55
|
+
i18nKey: "auth.errors.noMembership",
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function emailNotVerified() {
|
|
61
|
+
return writeFailure(
|
|
62
|
+
new UnprocessableError(AuthErrors.emailNotVerified, {
|
|
63
|
+
i18nKey: "auth.errors.emailNotVerified",
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// retryAfterSeconds drives the login/signup UI countdown — must stay > 0.
|
|
69
|
+
export function accountLocked(retryAfterSeconds: number) {
|
|
70
|
+
return writeFailure(
|
|
71
|
+
new UnprocessableError(AuthErrors.accountLocked, {
|
|
72
|
+
i18nKey: "auth.errors.accountLocked",
|
|
73
|
+
details: { retryAfterSeconds },
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function accountRestricted() {
|
|
79
|
+
return writeFailure(
|
|
80
|
+
new UnprocessableError(AuthErrors.accountRestricted, {
|
|
81
|
+
i18nKey: "auth.errors.accountRestricted",
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
2
|
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
|
|
6
|
-
import {
|
|
5
|
+
import { invalidCredentials } from "../errors";
|
|
7
6
|
import { hashPassword, verifyPassword } from "../password-hashing";
|
|
8
7
|
|
|
9
|
-
function invalidCredentials() {
|
|
10
|
-
return writeFailure(
|
|
11
|
-
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
12
|
-
i18nKey: "auth.errors.invalidCredentials",
|
|
13
|
-
}),
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
8
|
// Change-password — authenticated. The user supplies their current password
|
|
18
9
|
// (re-auth) and the new one. The new hash is written via ctx.writeAs(system)
|
|
19
10
|
// against the user feature's update handler; field-access on passwordHash
|
|
@@ -25,11 +25,7 @@ import {
|
|
|
25
25
|
type SessionUser,
|
|
26
26
|
type TenantId,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
-
import {
|
|
29
|
-
InternalError,
|
|
30
|
-
UnprocessableError,
|
|
31
|
-
writeFailure,
|
|
32
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
28
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
33
29
|
import { z } from "zod";
|
|
34
30
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
35
31
|
import {
|
|
@@ -41,7 +37,7 @@ import {
|
|
|
41
37
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
42
38
|
// kumiko-lint-ignore cross-feature-import login-style password-check
|
|
43
39
|
import { userTable } from "../../user/schema/user";
|
|
44
|
-
import {
|
|
40
|
+
import { invalidInviteToken, inviteEmailMismatch } from "../errors";
|
|
45
41
|
import {
|
|
46
42
|
burnInviteToken,
|
|
47
43
|
deleteInviteToken,
|
|
@@ -69,14 +65,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
69
65
|
{ entityName: "tenant-invitation" },
|
|
70
66
|
);
|
|
71
67
|
|
|
72
|
-
function invalidInviteToken() {
|
|
73
|
-
return writeFailure(
|
|
74
|
-
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
75
|
-
i18nKey: "auth.errors.invalidInviteToken",
|
|
76
|
-
}),
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
68
|
export function createInviteAcceptWithLoginHandler() {
|
|
81
69
|
return defineWriteHandler<
|
|
82
70
|
"invite-accept-with-login",
|
|
@@ -123,11 +111,7 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
123
111
|
|
|
124
112
|
// Email-Match vom User-Input (nicht aus session — User ist anon)
|
|
125
113
|
if (event.payload.email.toLowerCase() !== invitationEmail) {
|
|
126
|
-
return
|
|
127
|
-
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
128
|
-
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
114
|
+
return inviteEmailMismatch();
|
|
131
115
|
}
|
|
132
116
|
|
|
133
117
|
// Password-Check gegen userTable. Anti-enumeration: bei
|
|
@@ -22,11 +22,7 @@ import {
|
|
|
22
22
|
defineWriteHandler,
|
|
23
23
|
type TenantId,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import {
|
|
26
|
-
InternalError,
|
|
27
|
-
UnprocessableError,
|
|
28
|
-
writeFailure,
|
|
29
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
25
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
26
|
import { z } from "zod";
|
|
31
27
|
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
|
|
32
28
|
import {
|
|
@@ -34,11 +30,13 @@ import {
|
|
|
34
30
|
tenantInvitationEntity,
|
|
35
31
|
tenantInvitationsTable,
|
|
36
32
|
} from "../../tenant/invitation-table";
|
|
33
|
+
// kumiko-lint-ignore cross-feature-import direkter membership-Lookup (ungefiltert, s. alreadyMember-Kommentar)
|
|
34
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
37
35
|
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
|
|
38
36
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
39
37
|
// kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
|
|
40
38
|
import { userTable } from "../../user/schema/user";
|
|
41
|
-
import {
|
|
39
|
+
import { invalidInviteToken, inviteEmailMismatch } from "../errors";
|
|
42
40
|
import {
|
|
43
41
|
burnInviteToken,
|
|
44
42
|
deleteInviteToken,
|
|
@@ -63,14 +61,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
63
61
|
{ entityName: "tenant-invitation" },
|
|
64
62
|
);
|
|
65
63
|
|
|
66
|
-
function invalidInviteToken() {
|
|
67
|
-
return writeFailure(
|
|
68
|
-
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
69
|
-
i18nKey: "auth.errors.invalidInviteToken",
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
64
|
export function createInviteAcceptHandler() {
|
|
75
65
|
return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
|
|
76
66
|
name: "invite-accept",
|
|
@@ -122,22 +112,19 @@ export function createInviteAcceptHandler() {
|
|
|
122
112
|
});
|
|
123
113
|
const userEmail = userRow?.email;
|
|
124
114
|
if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
|
|
125
|
-
return
|
|
126
|
-
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
127
|
-
i18nKey: "auth.errors.inviteEmailMismatch",
|
|
128
|
-
}),
|
|
129
|
-
);
|
|
115
|
+
return inviteEmailMismatch();
|
|
130
116
|
}
|
|
131
117
|
|
|
132
|
-
// Already-Member-Check
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
118
|
+
// Already-Member-Check direkt gegen die memberships-Projektion —
|
|
119
|
+
// NICHT via tenant:query:memberships, die disabled Tenants filtert:
|
|
120
|
+
// ein Re-Invite in einen (vorübergehend) disabled Tenant würde dort
|
|
121
|
+
// alreadyMember=false sehen und am Unique-Constraint scheitern.
|
|
122
|
+
// Idempotenz: schon Member → no-op + 200 mit alreadyMember=true.
|
|
123
|
+
const membershipRow = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
|
|
124
|
+
userId: event.user.id,
|
|
125
|
+
tenantId: invitationTenantId,
|
|
126
|
+
});
|
|
127
|
+
const alreadyMember = membershipRow !== undefined;
|
|
141
128
|
|
|
142
129
|
const dbConn = ctx.db.raw;
|
|
143
130
|
|
|
@@ -29,11 +29,7 @@ import {
|
|
|
29
29
|
type SessionUser,
|
|
30
30
|
type TenantId,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
-
import {
|
|
33
|
-
InternalError,
|
|
34
|
-
UnprocessableError,
|
|
35
|
-
writeFailure,
|
|
36
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
32
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
37
33
|
import { z } from "zod";
|
|
38
34
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
39
35
|
import {
|
|
@@ -45,7 +41,7 @@ import {
|
|
|
45
41
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
46
42
|
// kumiko-lint-ignore cross-feature-import existence-check
|
|
47
43
|
import { userTable } from "../../user/schema/user";
|
|
48
|
-
import {
|
|
44
|
+
import { invalidInviteToken } from "../errors";
|
|
49
45
|
import {
|
|
50
46
|
burnInviteToken,
|
|
51
47
|
deleteInviteToken,
|
|
@@ -73,14 +69,6 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
73
69
|
{ entityName: "tenant-invitation" },
|
|
74
70
|
);
|
|
75
71
|
|
|
76
|
-
function invalidInviteToken() {
|
|
77
|
-
return writeFailure(
|
|
78
|
-
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
79
|
-
i18nKey: "auth.errors.invalidInviteToken",
|
|
80
|
-
}),
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
72
|
export function createInviteSignupCompleteHandler() {
|
|
85
73
|
return defineWriteHandler<
|
|
86
74
|
"invite-signup-complete",
|