@cosmicdrift/kumiko-samples 0.404.1 → 0.405.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +10 -10
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
- package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
- package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
- package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
- package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
- package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
- package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
- package/packages/bundled-features/src/folders/feature.ts +2 -8
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
- package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
- package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
- package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
- package/packages/bundled-features/src/ledger/feature.ts +2 -7
- package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
- package/packages/bundled-features/src/notes-history/feature.ts +7 -8
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
- package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
- package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
- package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
- package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
- package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
- package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
- package/packages/bundled-features/src/shared/signed-token.ts +4 -14
- package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
- package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
- package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
- package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
- package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
- package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
- package/packages/bundled-features/src/tags/feature.ts +2 -8
- package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
- package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
- package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
- package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.405.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.405.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -167,15 +167,15 @@
|
|
|
167
167
|
"@cosmicdrift/kumiko-types": "workspace:*",
|
|
168
168
|
"@mollie/api-client": "^4.6.0",
|
|
169
169
|
"@node-rs/argon2": "^2.2.2",
|
|
170
|
-
"@tiptap/core": "^3.32.
|
|
171
|
-
"@tiptap/extension-link": "^3.32.
|
|
172
|
-
"@tiptap/extension-list": "^3.32.
|
|
173
|
-
"@tiptap/extension-table": "^3.32.
|
|
174
|
-
"@tiptap/html": "^3.32.
|
|
175
|
-
"@tiptap/extensions": "^3.32.
|
|
176
|
-
"@tiptap/pm": "^3.32.
|
|
177
|
-
"@tiptap/react": "^3.32.
|
|
178
|
-
"@tiptap/starter-kit": "^3.32.
|
|
170
|
+
"@tiptap/core": "^3.32.2",
|
|
171
|
+
"@tiptap/extension-link": "^3.32.2",
|
|
172
|
+
"@tiptap/extension-list": "^3.32.2",
|
|
173
|
+
"@tiptap/extension-table": "^3.32.2",
|
|
174
|
+
"@tiptap/html": "^3.32.2",
|
|
175
|
+
"@tiptap/extensions": "^3.32.2",
|
|
176
|
+
"@tiptap/pm": "^3.32.2",
|
|
177
|
+
"@tiptap/react": "^3.32.2",
|
|
178
|
+
"@tiptap/starter-kit": "^3.32.2",
|
|
179
179
|
"@types/mailparser": "^3.9.0",
|
|
180
180
|
"@types/qrcode": "^1.5.6",
|
|
181
181
|
"clsx": "^2.1.1",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.405.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Login-path redirect hook and topbar without UserMenu",
|
|
6
|
+
"detail": "auth-email-password/web exports useRedirectAuthedFromLogin({ loginPath, target }), which replaces the URL with target while a signed-in user sits on the login path, also after Browser-Back, and isAuthedOnLoginPath. DefaultTopbarActions takes userMenu (default true) to hide the UserMenu and themeToggleTestId for the ThemeToggle button."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.394.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type NotificationLocalizedTemplate,
|
|
3
2
|
QnTypes,
|
|
4
3
|
qn,
|
|
5
4
|
type TemplateVariables,
|
|
6
5
|
templateVariable,
|
|
7
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import {
|
|
9
|
-
hasMailTranslationKey,
|
|
10
|
-
listMailLocales,
|
|
11
|
-
mailT,
|
|
12
|
-
resolveMailLocale,
|
|
13
|
-
} from "@cosmicdrift/kumiko-framework/i18n";
|
|
7
|
+
import { mailT } from "@cosmicdrift/kumiko-framework/i18n";
|
|
14
8
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
9
|
+
import { createMailLocaleTemplates } from "../shared/index.ts";
|
|
15
10
|
import { AUTH_EMAIL_PASSWORD_FEATURE } from "./web/constants.ts";
|
|
16
11
|
|
|
17
12
|
export const AUTH_MAIL_KINDS = {
|
|
@@ -62,18 +57,14 @@ const ALL_AUTH_MAIL_KINDS: readonly AuthMailKind[] = [
|
|
|
62
57
|
"invite",
|
|
63
58
|
];
|
|
64
59
|
|
|
65
|
-
const
|
|
66
|
-
ALL_AUTH_MAIL_KINDS
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
export
|
|
73
|
-
const locale = resolveMailLocale(requestedLocale);
|
|
74
|
-
const key = SUBJECT_KEY_BY_TYPE.get(notificationType);
|
|
75
|
-
return key !== undefined && hasMailTranslationKey(locale, key) ? locale : "en";
|
|
76
|
-
}
|
|
60
|
+
const mailLocaleTemplates = createMailLocaleTemplates({
|
|
61
|
+
kinds: ALL_AUTH_MAIL_KINDS,
|
|
62
|
+
notificationTypes: AuthMailNotificationTypes,
|
|
63
|
+
subjectKey,
|
|
64
|
+
bodyFor: (kind, locale) => mailBody(locale, AUTH_MAIL_KINDS[kind].translationKey),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const authMailTemplateLocale = mailLocaleTemplates.templateLocale;
|
|
77
68
|
|
|
78
69
|
export const AUTH_MAIL_VARIABLES: TemplateVariables = {
|
|
79
70
|
appName: templateVariable.text({ required: true }),
|
|
@@ -128,31 +119,4 @@ function mailBody(locale: string, translationKey: string): string {
|
|
|
128
119
|
].join("");
|
|
129
120
|
}
|
|
130
121
|
|
|
131
|
-
|
|
132
|
-
// registered its mail translations. English always ships; another locale only
|
|
133
|
-
// gets a template when it has its own subject, so a missing translation never
|
|
134
|
-
// silently becomes an English mail in that locale's slot.
|
|
135
|
-
export function authMailLocalizedTemplate(kind: AuthMailKind): () => NotificationLocalizedTemplate {
|
|
136
|
-
const { translationKey } = AUTH_MAIL_KINDS[kind];
|
|
137
|
-
const kindSubjectKey = subjectKey(kind);
|
|
138
|
-
return () => {
|
|
139
|
-
const locales = [
|
|
140
|
-
"en",
|
|
141
|
-
...listMailLocales().filter(
|
|
142
|
-
(locale) => locale !== "en" && hasMailTranslationKey(locale, kindSubjectKey),
|
|
143
|
-
),
|
|
144
|
-
];
|
|
145
|
-
return Object.fromEntries(
|
|
146
|
-
locales.map((locale) => [
|
|
147
|
-
locale,
|
|
148
|
-
{
|
|
149
|
-
body: mailBody(locale, translationKey),
|
|
150
|
-
meta: {
|
|
151
|
-
subject: mailT(locale, kindSubjectKey, { app: "{{appName}}" }),
|
|
152
|
-
category: "transactional" as const,
|
|
153
|
-
},
|
|
154
|
-
},
|
|
155
|
-
]),
|
|
156
|
-
);
|
|
157
|
-
};
|
|
158
|
-
}
|
|
122
|
+
export const authMailLocalizedTemplate = mailLocaleTemplates.localizedTemplate;
|
|
@@ -24,6 +24,11 @@ export type DefaultTopbarActionsProps = {
|
|
|
24
24
|
readonly lightIcon?: ReactNode;
|
|
25
25
|
/** Dark-Mode-Icon. Default: Unicode ☾. */
|
|
26
26
|
readonly darkIcon?: ReactNode;
|
|
27
|
+
/** Render the UserMenu. Default true; false for apps whose account/logout
|
|
28
|
+
* lives elsewhere (for example a sidebar profile). */
|
|
29
|
+
readonly userMenu?: boolean;
|
|
30
|
+
/** data-testid forwarded to the ThemeToggle button. */
|
|
31
|
+
readonly themeToggleTestId?: string;
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
export function DefaultTopbarActions({
|
|
@@ -31,6 +36,8 @@ export function DefaultTopbarActions({
|
|
|
31
36
|
extras,
|
|
32
37
|
lightIcon,
|
|
33
38
|
darkIcon,
|
|
39
|
+
userMenu = true,
|
|
40
|
+
themeToggleTestId,
|
|
34
41
|
}: DefaultTopbarActionsProps = {}): ReactNode {
|
|
35
42
|
return (
|
|
36
43
|
<div className="flex items-center gap-2">
|
|
@@ -39,8 +46,9 @@ export function DefaultTopbarActions({
|
|
|
39
46
|
<ThemeToggle
|
|
40
47
|
{...(lightIcon !== undefined && { lightIcon })}
|
|
41
48
|
{...(darkIcon !== undefined && { darkIcon })}
|
|
49
|
+
{...(themeToggleTestId !== undefined && { testId: themeToggleTestId })}
|
|
42
50
|
/>
|
|
43
|
-
<UserMenu />
|
|
51
|
+
{userMenu && <UserMenu />}
|
|
44
52
|
</div>
|
|
45
53
|
);
|
|
46
54
|
}
|
|
@@ -87,6 +87,11 @@ export type { TenantMenuItemsProps } from "./tenant-menu-items.tsx";
|
|
|
87
87
|
export { TenantMenuItems } from "./tenant-menu-items.tsx";
|
|
88
88
|
export type { TenantSwitcherProps } from "./tenant-switcher.tsx";
|
|
89
89
|
export { TenantSwitcher } from "./tenant-switcher.tsx";
|
|
90
|
+
export type { UseRedirectAuthedFromLoginOptions } from "./use-redirect-authed-from-login.ts";
|
|
91
|
+
export {
|
|
92
|
+
isAuthedOnLoginPath,
|
|
93
|
+
useRedirectAuthedFromLogin,
|
|
94
|
+
} from "./use-redirect-authed-from-login.ts";
|
|
90
95
|
export type { ShellUser } from "./use-shell-user.ts";
|
|
91
96
|
export { useShellUser } from "./use-shell-user.ts";
|
|
92
97
|
export type { UserMenuProps } from "./user-menu.tsx";
|
|
@@ -109,15 +109,15 @@ export function InviteAcceptScreen({
|
|
|
109
109
|
);
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
-
const
|
|
112
|
+
const postInviteAccept = async (path: string, body: Record<string, string>): Promise<void> => {
|
|
113
113
|
setSubmitting(true);
|
|
114
114
|
setError(null);
|
|
115
115
|
// kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
|
|
116
|
-
const res = await fetch(
|
|
116
|
+
const res = await fetch(path, {
|
|
117
117
|
method: "POST",
|
|
118
118
|
credentials: "same-origin",
|
|
119
119
|
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
120
|
-
body: JSON.stringify(
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
121
|
});
|
|
122
122
|
setSubmitting(false);
|
|
123
123
|
if (res.ok) {
|
|
@@ -129,45 +129,14 @@ export function InviteAcceptScreen({
|
|
|
129
129
|
setError(t("auth.errors.invalidInviteToken"));
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
setError(null);
|
|
135
|
-
// kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
|
|
136
|
-
const res = await fetch("/api/auth/invite-accept-with-login", {
|
|
137
|
-
method: "POST",
|
|
138
|
-
credentials: "same-origin",
|
|
139
|
-
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
140
|
-
body: JSON.stringify({ token, email, password }),
|
|
141
|
-
});
|
|
142
|
-
setSubmitting(false);
|
|
143
|
-
if (res.ok) {
|
|
144
|
-
// @cast-boundary engine-payload — auth route JSON
|
|
145
|
-
const data = (await res.json()) as InviteAcceptResponse;
|
|
146
|
-
finishAccept(data);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
setError(t("auth.errors.invalidInviteToken"));
|
|
150
|
-
};
|
|
132
|
+
const acceptLoggedIn = (): Promise<void> =>
|
|
133
|
+
postInviteAccept("/api/auth/invite-accept", { token });
|
|
151
134
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
method: "POST",
|
|
158
|
-
credentials: "same-origin",
|
|
159
|
-
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
160
|
-
body: JSON.stringify({ token, password }),
|
|
161
|
-
});
|
|
162
|
-
setSubmitting(false);
|
|
163
|
-
if (res.ok) {
|
|
164
|
-
// @cast-boundary engine-payload — auth route JSON
|
|
165
|
-
const data = (await res.json()) as InviteAcceptResponse;
|
|
166
|
-
finishAccept(data);
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
setError(t("auth.errors.invalidInviteToken"));
|
|
170
|
-
};
|
|
135
|
+
const acceptAnonExisting = (): Promise<void> =>
|
|
136
|
+
postInviteAccept("/api/auth/invite-accept-with-login", { token, email, password });
|
|
137
|
+
|
|
138
|
+
const acceptAnonNew = (): Promise<void> =>
|
|
139
|
+
postInviteAccept("/api/auth/invite-signup-complete", { token, password });
|
|
171
140
|
|
|
172
141
|
const onSubmit = (e?: FormEvent): void => {
|
|
173
142
|
e?.preventDefault();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TenantSummary } from "./auth-client.ts";
|
|
2
|
+
|
|
3
|
+
// Label priority: app-provided resolver > server-provided name/key from
|
|
4
|
+
// /auth/tenants > UUID prefix. The prefix is only a last resort — it is
|
|
5
|
+
// indistinguishable for seeded tenants (00000000-…).
|
|
6
|
+
export function tenantLabel(
|
|
7
|
+
tenants: readonly TenantSummary[],
|
|
8
|
+
tenantId: string,
|
|
9
|
+
tenantName: ((tenantId: string) => string) | undefined,
|
|
10
|
+
): string {
|
|
11
|
+
if (tenantName !== undefined) return tenantName(tenantId);
|
|
12
|
+
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
13
|
+
// || instead of ??: an empty name/key string must not be used as a label.
|
|
14
|
+
return membership?.name || membership?.key || tenantId.slice(0, 8);
|
|
15
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "@cosmicdrift/kumiko-renderer/web";
|
|
10
10
|
import type { ReactNode } from "react";
|
|
11
11
|
import { useSession } from "./session.tsx";
|
|
12
|
+
import { tenantLabel } from "./tenant-label.ts";
|
|
12
13
|
|
|
13
14
|
export type TenantMenuItemsProps = {
|
|
14
15
|
readonly tenantName?: (tenantId: string) => string;
|
|
@@ -19,12 +20,6 @@ export function TenantMenuItems({ tenantName }: TenantMenuItemsProps): ReactNode
|
|
|
19
20
|
const { user, tenants, activeTenantId, switchTenant } = useSession();
|
|
20
21
|
if (user === null || tenants.length <= 1) return null;
|
|
21
22
|
|
|
22
|
-
const nameOf = (tenantId: string): string => {
|
|
23
|
-
if (tenantName !== undefined) return tenantName(tenantId);
|
|
24
|
-
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
25
|
-
return membership?.name || membership?.key || tenantId.slice(0, 8);
|
|
26
|
-
};
|
|
27
|
-
|
|
28
23
|
return (
|
|
29
24
|
<>
|
|
30
25
|
<DropdownMenuLabel>{t("auth.tenant.switcher.label")}</DropdownMenuLabel>
|
|
@@ -37,7 +32,7 @@ export function TenantMenuItems({ tenantName }: TenantMenuItemsProps): ReactNode
|
|
|
37
32
|
if (membership.tenantId !== activeTenantId) void switchTenant(membership.tenantId);
|
|
38
33
|
}}
|
|
39
34
|
>
|
|
40
|
-
<span className="truncate">{
|
|
35
|
+
<span className="truncate">{tenantLabel(tenants, membership.tenantId, tenantName)}</span>
|
|
41
36
|
</DropdownMenuCheckboxItem>
|
|
42
37
|
))}
|
|
43
38
|
<DropdownMenuSeparator />
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { Building2, ChevronDown } from "lucide-react";
|
|
25
25
|
import { type ReactNode, useCallback, useState } from "react";
|
|
26
26
|
import { useSession } from "./session.tsx";
|
|
27
|
+
import { tenantLabel } from "./tenant-label.ts";
|
|
27
28
|
|
|
28
29
|
export type TenantSwitcherProps = {
|
|
29
30
|
/** Optional: provides a human-readable name for each tenant ID.
|
|
@@ -55,22 +56,14 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
|
|
|
55
56
|
[activeTenantId, switchTenant],
|
|
56
57
|
);
|
|
57
58
|
|
|
58
|
-
// Label priority: app-provided resolver (prop) > server-provided
|
|
59
|
-
// name/key from /auth/tenants > UUID prefix. The latter is only a
|
|
60
|
-
// last resort — it is indistinguishable for seeded tenants (00000000-…).
|
|
61
|
-
const nameOf = (tenantId: string): string => {
|
|
62
|
-
if (tenantName !== undefined) return tenantName(tenantId);
|
|
63
|
-
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
64
|
-
// || instead of ??: an empty name/key string must not be used as a label.
|
|
65
|
-
return membership?.name || membership?.key || tenantId.slice(0, 8);
|
|
66
|
-
};
|
|
67
|
-
|
|
68
59
|
// Rendering gate: no user → nothing; only one tenant → nothing as well
|
|
69
60
|
// (single-tenant apps do not need a switcher).
|
|
70
61
|
if (user === null || tenants.length <= 1) return null;
|
|
71
62
|
|
|
72
63
|
const activeLabel =
|
|
73
|
-
activeTenantId !== null
|
|
64
|
+
activeTenantId !== null
|
|
65
|
+
? tenantLabel(tenants, activeTenantId, tenantName)
|
|
66
|
+
: t("auth.tenant.switcher.none");
|
|
74
67
|
|
|
75
68
|
return (
|
|
76
69
|
<DropdownMenu>
|
|
@@ -105,7 +98,9 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
|
|
|
105
98
|
}}
|
|
106
99
|
>
|
|
107
100
|
<div className="flex flex-col items-start min-w-0">
|
|
108
|
-
<span className="truncate font-medium">
|
|
101
|
+
<span className="truncate font-medium">
|
|
102
|
+
{tenantLabel(tenants, membership.tenantId, tenantName)}
|
|
103
|
+
</span>
|
|
109
104
|
{membership.roles.length > 0 && (
|
|
110
105
|
<span className="text-xs text-muted-foreground truncate">
|
|
111
106
|
{membership.roles.join(", ")}
|
package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type NavTarget, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useSession } from "./session.tsx";
|
|
4
|
+
|
|
5
|
+
export function isAuthedOnLoginPath(authed: boolean, pathname: string, loginPath: string): boolean {
|
|
6
|
+
return authed && pathname === loginPath;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type UseRedirectAuthedFromLoginOptions = {
|
|
10
|
+
readonly loginPath: string;
|
|
11
|
+
readonly target: NavTarget;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// The SPA has no screen at the login path: after a login the URL stays there, and marketing
|
|
15
|
+
// links land there directly. replace, not navigate: a push would make Browser-Back from the
|
|
16
|
+
// target land on the login path again, which redirects forward again (history ping-pong).
|
|
17
|
+
export function useRedirectAuthedFromLogin({
|
|
18
|
+
loginPath,
|
|
19
|
+
target,
|
|
20
|
+
}: UseRedirectAuthedFromLoginOptions): void {
|
|
21
|
+
const session = useSession();
|
|
22
|
+
const nav = useNav();
|
|
23
|
+
const authed = session.user !== null;
|
|
24
|
+
// Callers pass an inline object; a ref keeps it out of the effect deps.
|
|
25
|
+
const targetRef = useRef(target);
|
|
26
|
+
targetRef.current = target;
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// skip: no browser location to inspect (native or server render)
|
|
29
|
+
if (typeof window === "undefined") return;
|
|
30
|
+
const redirectIfStranded = () => {
|
|
31
|
+
if (isAuthedOnLoginPath(authed, window.location.pathname, loginPath))
|
|
32
|
+
nav.replace(targetRef.current);
|
|
33
|
+
};
|
|
34
|
+
redirectIfStranded();
|
|
35
|
+
// Browser-Back to the login path changes neither authed nor nav, so the effect alone would not re-fire.
|
|
36
|
+
window.addEventListener("popstate", redirectIfStranded);
|
|
37
|
+
return () => window.removeEventListener("popstate", redirectIfStranded);
|
|
38
|
+
}, [authed, nav, loginPath]);
|
|
39
|
+
}
|
|
@@ -58,33 +58,20 @@ export function createAuthFoundationFeature() {
|
|
|
58
58
|
recommended: false,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// No side-effects at register-time — registry stores the usage,
|
|
64
|
-
// resolveTokenVerifier looks it up at request-time.
|
|
65
|
-
},
|
|
66
|
-
});
|
|
61
|
+
// Registry stores the usage, resolveTokenVerifier looks it up at request-time.
|
|
62
|
+
r.extendsRegistrar(EXT_TOKEN_VERIFIER, {});
|
|
67
63
|
r.bootCheck(({ features }) => validateTokenVerifierMultiplicity(features));
|
|
68
64
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// No side-effects at register-time — resolveSessionStore at boot/request.
|
|
72
|
-
},
|
|
73
|
-
});
|
|
65
|
+
// resolveSessionStore looks the usage up at boot/request-time.
|
|
66
|
+
r.extendsRegistrar(EXT_SESSION_STORE, {});
|
|
74
67
|
r.bootCheck(({ features }) => validateSessionStoreMultiplicity(features));
|
|
75
68
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// Optional — zero providers is fine (single-tenant / header-cookie).
|
|
79
|
-
},
|
|
80
|
-
});
|
|
69
|
+
// Optional — zero providers is fine (single-tenant / header-cookie).
|
|
70
|
+
r.extendsRegistrar(EXT_TENANT_RESOLVER, {});
|
|
81
71
|
r.bootCheck(({ features }) => validateTenantResolverMultiplicity(features));
|
|
82
72
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// Optional — zero providers skips the existence check.
|
|
86
|
-
},
|
|
87
|
-
});
|
|
73
|
+
// Optional — zero providers skips the existence check.
|
|
74
|
+
r.extendsRegistrar(EXT_TENANT_EXISTENCE, {});
|
|
88
75
|
r.bootCheck(({ features }) => validateTenantExistenceMultiplicity(features));
|
|
89
76
|
},
|
|
90
77
|
{ dedupeOptions: {} },
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.405.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "MFA setup token uses the shared HMAC token codec",
|
|
6
|
+
"detail": "signMfaSetupToken and verifyMfaSetupToken run on createHmacTokenCodec with the domain mfa-setup. Tokens issued before the change still verify, and new tokens are byte-identical for the same input. The challenge and preauth-setup tokens share one payload guard, and signed-token and the inbound-mail OAuth state use the same HMAC signature helper."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.391.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
createTenantDb,
|
|
35
35
|
type DbConnection,
|
|
36
36
|
selectMany,
|
|
37
|
-
type TenantDb,
|
|
38
37
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
39
38
|
import type { JobContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
40
39
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -42,6 +41,7 @@ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
42
41
|
import {
|
|
43
42
|
type ChunkedMigrationStopReason,
|
|
44
43
|
classifyStoredEnvelope,
|
|
44
|
+
createTenantDbCache,
|
|
45
45
|
runChunkedMigration,
|
|
46
46
|
} from "../../shared/index.ts";
|
|
47
47
|
import { userMfaEntity, userMfaTable } from "../schema/user-mfa.ts";
|
|
@@ -105,20 +105,14 @@ export async function mfaReencryptJob(
|
|
|
105
105
|
? Date.now() + payload.maxDurationMs
|
|
106
106
|
: Number.POSITIVE_INFINITY;
|
|
107
107
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
reason: "MFA key re-encryption job rewrites enrollments of every tenant",
|
|
117
|
-
});
|
|
118
|
-
tdbCache.set(tenantId, existing);
|
|
119
|
-
}
|
|
120
|
-
return existing;
|
|
121
|
-
}
|
|
108
|
+
const tdbFor = createTenantDbCache((tenantId) =>
|
|
109
|
+
createTenantDb({
|
|
110
|
+
db,
|
|
111
|
+
tenantId,
|
|
112
|
+
mode: "system",
|
|
113
|
+
reason: "MFA key re-encryption job rewrites enrollments of every tenant",
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
122
116
|
|
|
123
117
|
type UserMfaRow = {
|
|
124
118
|
id: string;
|
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
3
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
-
|
|
5
|
-
function hmacSign(input: string, secret: string): string {
|
|
6
|
-
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
7
|
-
}
|
|
4
|
+
import { hmacSignatureMatches, signHmacSha256Base64Url } from "../shared/index.ts";
|
|
8
5
|
|
|
9
6
|
export type HmacTokenVerifyResult<TPayload> =
|
|
10
7
|
| { readonly ok: true; readonly payload: TPayload; readonly expiresAtMs: number }
|
|
11
8
|
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
12
9
|
|
|
10
|
+
export type UserTenantPayload = { readonly userId: string; readonly tenantId: TenantId };
|
|
11
|
+
|
|
12
|
+
export function isUserTenantPayload(value: unknown): value is UserTenantPayload {
|
|
13
|
+
if (!isPlainObject(value)) return false;
|
|
14
|
+
return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
// `domain` is part of the signed input: it is the only thing that stops a token
|
|
14
18
|
// minted for one MFA step from verifying as another (e.g. a preauth-setup token
|
|
15
19
|
// replayed as a challenge token), since both share the same secret and payload shape.
|
|
16
|
-
export function createHmacTokenCodec<
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
export function createHmacTokenCodec<TPayload extends { readonly userId: string }>(
|
|
21
|
+
domain: string,
|
|
22
|
+
isPayload: (value: unknown) => value is TPayload,
|
|
23
|
+
) {
|
|
19
24
|
type EncodedBody = TPayload & { readonly expiresAtMs: number };
|
|
20
25
|
|
|
21
26
|
function splitEncodedBody(
|
|
@@ -38,7 +43,7 @@ export function createHmacTokenCodec<
|
|
|
38
43
|
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
39
44
|
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
40
45
|
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
41
|
-
const signature =
|
|
46
|
+
const signature = signHmacSha256Base64Url(`${domain}:${bodyB64}`, secret);
|
|
42
47
|
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
43
48
|
},
|
|
44
49
|
|
|
@@ -52,11 +57,9 @@ export function createHmacTokenCodec<
|
|
|
52
57
|
const [bodyB64, providedSig] = parts;
|
|
53
58
|
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
54
59
|
|
|
55
|
-
const expected =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
59
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
60
|
+
const expected = signHmacSha256Base64Url(`${domain}:${bodyB64}`, secret);
|
|
61
|
+
if (!hmacSignatureMatches(expected, providedSig))
|
|
62
|
+
return { ok: false, reason: "bad_signature" };
|
|
60
63
|
|
|
61
64
|
let parsed: unknown;
|
|
62
65
|
try {
|
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createHmacTokenCodec,
|
|
5
|
+
type HmacTokenVerifyResult,
|
|
6
|
+
isUserTenantPayload,
|
|
7
|
+
} from "./hmac-token-codec.ts";
|
|
5
8
|
|
|
6
9
|
export type MfaChallengePayload = {
|
|
7
10
|
readonly userId: string;
|
|
8
11
|
readonly tenantId: TenantId;
|
|
9
12
|
};
|
|
10
13
|
|
|
11
|
-
function isPayload(value: unknown): value is MfaChallengePayload {
|
|
12
|
-
if (!isPlainObject(value)) return false;
|
|
13
|
-
return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
|
|
14
|
-
}
|
|
15
|
-
|
|
16
14
|
// Domain-separated from mfa-preauth-setup (see hmac-token-codec). Single-use is
|
|
17
15
|
// enforced by the consumer via burnToken (purpose "mfa-challenge"); this codec
|
|
18
16
|
// only signs and verifies. Brute-forcing the code behind a valid token is
|
|
19
17
|
// limited separately in mfa-verify-attempts.ts.
|
|
20
|
-
const codec = createHmacTokenCodec("mfa-challenge",
|
|
18
|
+
const codec = createHmacTokenCodec("mfa-challenge", isUserTenantPayload);
|
|
21
19
|
|
|
22
20
|
export function signMfaChallengeToken(
|
|
23
21
|
payload: MfaChallengePayload,
|
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createHmacTokenCodec,
|
|
5
|
+
type HmacTokenVerifyResult,
|
|
6
|
+
isUserTenantPayload,
|
|
7
|
+
} from "./hmac-token-codec.ts";
|
|
5
8
|
|
|
6
9
|
export type MfaPreauthSetupPayload = {
|
|
7
10
|
readonly userId: string;
|
|
8
11
|
readonly tenantId: TenantId;
|
|
9
12
|
};
|
|
10
13
|
|
|
11
|
-
function isPayload(value: unknown): value is MfaPreauthSetupPayload {
|
|
12
|
-
if (!isPlainObject(value)) return false;
|
|
13
|
-
return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
|
|
14
|
-
}
|
|
15
|
-
|
|
16
14
|
// Domain "mfa-preauth-setup" must never equal the challenge domain: a setup
|
|
17
15
|
// token has to fail verification as a challenge token and vice versa. The
|
|
18
16
|
// challenge token secret is reused deliberately; the domain carries the separation.
|
|
19
|
-
const codec = createHmacTokenCodec("mfa-preauth-setup",
|
|
17
|
+
const codec = createHmacTokenCodec("mfa-preauth-setup", isUserTenantPayload);
|
|
20
18
|
|
|
21
19
|
export function signMfaPreauthSetupToken(
|
|
22
20
|
payload: MfaPreauthSetupPayload,
|