@cosmicdrift/kumiko-bundled-features 0.242.0 → 0.243.1
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 +9 -9
- package/src/auth-email-password/changes.json +13 -13
- package/src/auth-foundation/changes.json +6 -6
- package/src/cap-overview/changes.json +6 -6
- package/src/crypto-shredding/changes.json +8 -8
- package/src/personal-access-tokens/changes.json +6 -6
- package/src/user-data-rights/changes.json +6 -6
- package/src/user-profile/__tests__/profile-screen.test.tsx +78 -4
- package/src/user-profile/changes.json +6 -0
- package/src/user-profile/web/profile-screen.tsx +97 -95
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.243.1",
|
|
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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.243.1",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.243.1",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.243.1",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.243.1",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.243.1",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.243.1",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.243.1",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.243.1"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
"title": "Self-signup provisions TenantAdmin on the new tenant",
|
|
6
6
|
"detail": "INITIAL_SIGNUP_ROLES is TenantAdmin (was Admin) so the first user on a new tenant can invite peers and manage memberships without elevation dead-ends."
|
|
7
7
|
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.167.1",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "accountLockout is reachable through runProdApp/runDevApp (fw#1627).",
|
|
12
|
+
"detail": "accountLockout was an option on createAuthEmailPasswordFeature, but both wrappers mount auth-email-password themselves and their auth options only knew accountUnlock — an app on either entrypoint had no way to turn brute-force protection on. RunProdAppAuthOptions and RunDevAppAuthOptions now carry accountLockout and composeFeatures passes it through.",
|
|
13
|
+
"migration": "Opt-in, nothing breaks without it. Two things to know before relying on it: without ctx.redis the lockout is silently skipped in login.write.ts — no error, no warning, no protection — so treat Redis as a deploy invariant, and prove the lockout with a behavioural test instead of assuming the mount is enough."
|
|
14
|
+
},
|
|
8
15
|
{
|
|
9
16
|
"version": "0.165.1",
|
|
10
17
|
"type": "breaking",
|
|
@@ -12,13 +19,6 @@
|
|
|
12
19
|
"detail": "The four positional args (LoginComponent, loginProps, MfaVerifyComponent, MfaSetupComponent) did not scale past two optional MFA params. Shipped in the stranded 2.0.0 major and carried into the 0.165.1 line.",
|
|
13
20
|
"migration": "Rewrite call sites as makeAuthGate({ loginScreen, loginScreenProps, mfaVerifyScreen, mfaSetupScreen }). Type-check catches every occurrence."
|
|
14
21
|
},
|
|
15
|
-
{
|
|
16
|
-
"version": "0.167.1",
|
|
17
|
-
"type": "improvement",
|
|
18
|
-
"title": "accountLockout is reachable through runProdApp/runDevApp (fw#1627).",
|
|
19
|
-
"detail": "accountLockout was an option on createAuthEmailPasswordFeature, but both wrappers mount auth-email-password themselves and their auth options only knew accountUnlock \u2014 an app on either entrypoint had no way to turn brute-force protection on. RunProdAppAuthOptions and RunDevAppAuthOptions now carry accountLockout and composeFeatures passes it through.",
|
|
20
|
-
"migration": "Opt-in, nothing breaks without it. Two things to know before relying on it: without ctx.redis the lockout is silently skipped in login.write.ts \u2014 no error, no warning, no protection \u2014 so treat Redis as a deploy invariant, and prove the lockout with a behavioural test instead of assuming the mount is enough."
|
|
21
|
-
},
|
|
22
22
|
{
|
|
23
23
|
"version": "0.162.0",
|
|
24
24
|
"type": "improvement",
|
|
@@ -28,19 +28,19 @@
|
|
|
28
28
|
{
|
|
29
29
|
"version": "0.154.1",
|
|
30
30
|
"type": "fix",
|
|
31
|
-
"title": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled
|
|
32
|
-
"detail": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled-features/auth-email-password/web`. Every other auth screen (ForgotPasswordScreen, SignupScreen, ResetPasswordScreen,
|
|
31
|
+
"title": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled…",
|
|
32
|
+
"detail": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled-features/auth-email-password/web`. Every other auth screen (ForgotPasswordScreen, SignupScreen, ResetPasswordScreen, …) was already exported from the barrel with its props type; `LoginScreen` was missed. `makeAuthGate`'s second parameter is typed `LoginScreenProps`, so consumers passing a typed `loginScreenProps` override (e.g. `@cosmicdriftgamestudio/kumiko-designer`) couldn't import the type at all. - @cosmicdrift/kumiko-framework@0.154.1 - @cosmicdrift/kumiko-dispatcher-live@0.154.1 - @cosmicdrift/kumiko-headless@0.154.1 - @cosmicdrift/kumiko-renderer@0.154.1 - @cosmicdrift/kumiko-renderer-web@0.154.1"
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"version": "0.151.0",
|
|
36
36
|
"type": "improvement",
|
|
37
|
-
"title": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly
|
|
38
|
-
"detail": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly
|
|
37
|
+
"title": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly — u…",
|
|
38
|
+
"detail": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly — use the new `createLoginRoute({ loginScreenProps, mfaVerifyScreen, unaffected (they already build on the same logic internally now). Why: a raw `<LoginScreen />` render has no MFA-challenge handling unless the caller remembers to hand-wire `onMfaChallenge` + swap in a verify screen itself — exactly how kumiko-framework#266's login-time MFA step went missing in a real app's standalone apex/marketing login route (it renders outside `emailPasswordClient`'s own gate, which already handled this correctly). `createLoginRoute` is the one place this logic lives now, for both the gated and standalone cases — there's no lower-level piece left to misuse. The `apex-surface-auth` recipe is updated to match."
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"version": "0.131.0",
|
|
42
42
|
"type": "improvement",
|
|
43
|
-
"title": "App-Mounting 2.0
|
|
44
|
-
"detail": "App-Mounting 2.0
|
|
43
|
+
"title": "App-Mounting 2.0 Säule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, Secti…",
|
|
44
|
+
"detail": "App-Mounting 2.0 Säule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, SectionCard, StatusBadge, ProgressBar, CollapsibleSection, DetailList, ModeSwitch, StatusBarChart, TimeseriesChart, EmptyState/LoadingState/ErrorState, QueryTable) + Status-Farb-Tokens (--color-status-\\*). Neue Hooks useMutation + useDisclosure. Neues Core-Primitive Link (default/button/muted), Button-Variant \"link\", Text-Variant \"muted\"; auth-email-password nutzt sie (authButtonClass/authMutedLinkClass entfernt)."
|
|
45
45
|
}
|
|
46
46
|
]
|
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
"title": "validateSessionStoreMultiplicity accepts zero sessionStore providers (fw#1547).",
|
|
6
6
|
"detail": "A machine-API-only deployment (PAT-bearer auth, no browser sessions) can mount auth-foundation without also mounting sessions."
|
|
7
7
|
},
|
|
8
|
-
{
|
|
9
|
-
"version": "0.161.0",
|
|
10
|
-
"type": "improvement",
|
|
11
|
-
"title": "tenantResolver/tenantExistence extension points, sessionStore without auth.sessions (fw#1372-1375).",
|
|
12
|
-
"detail": "Auth-foundation migration: tenant resolution and tenant-existence become extension points, sessionStore is wired without going through auth.sessions, AnonymousAccessConfig is slimmed down. See the auth-foundation-providers recipe."
|
|
13
|
-
},
|
|
14
8
|
{
|
|
15
9
|
"version": "0.165.0",
|
|
16
10
|
"type": "fix",
|
|
17
11
|
"title": "Allow zero tokenVerifier when sessionStore present",
|
|
18
12
|
"detail": "Auth foundation now accepts zero tokenVerifier when a sessionStore is configured — prevents crash in session-store-only setups."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.161.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "tenantResolver/tenantExistence extension points, sessionStore without auth.sessions (fw#1372-1375).",
|
|
18
|
+
"detail": "Auth-foundation migration: tenant resolution and tenant-existence become extension points, sessionStore is wired without going through auth.sessions, AnonymousAccessConfig is slimmed down. See the auth-foundation-providers recipe."
|
|
19
19
|
}
|
|
20
20
|
]
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[
|
|
2
|
-
{
|
|
3
|
-
"version": "0.224.0",
|
|
4
|
-
"type": "improvement",
|
|
5
|
-
"title": "Add cap-overview feature: tier + cap usage visibility per tenant.",
|
|
6
|
-
"detail": "New read-only bundled feature. SystemAdmin gets a paged, searchable tenant list with tier/billing columns and per-cap usage bars, plus a per-tenant dashboard reachable from the list. TenantAdmin gets a 'My usage' dashboard showing their own tenant's cap usage as cards. Reads tier-engine, billing-foundation and tenant projections plus caller-supplied CapSpec usage callbacks; every handler enforces explicit tenant scoping (no ambient tenant filter, cross-tenant reads require SystemAdmin + acknowledgeCrossTenant)."
|
|
7
|
-
},
|
|
8
2
|
{
|
|
9
3
|
"version": "0.233.0",
|
|
10
4
|
"type": "improvement",
|
|
11
5
|
"title": "CapSpec.limit can now return null for an unlimited usage meter.",
|
|
12
6
|
"detail": "A CapSpec's `limit(tier)` may now return `null` to mean unlimited: the cap is still counted (used shows the raw count) but never capped, has no percent, and renders no progress bar or badge. This is a distinct meaning from `limit <= 0`, which unchanged still means 'not part of this tier' and renders as 0/0%."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.224.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Add cap-overview feature: tier + cap usage visibility per tenant.",
|
|
12
|
+
"detail": "New read-only bundled feature. SystemAdmin gets a paged, searchable tenant list with tier/billing columns and per-cap usage bars, plus a per-tenant dashboard reachable from the list. TenantAdmin gets a 'My usage' dashboard showing their own tenant's cap usage as cards. Reads tier-engine, billing-foundation and tenant projections plus caller-supplied CapSpec usage callbacks; every handler enforces explicit tenant scoping (no ambient tenant filter, cross-tenant reads require SystemAdmin + acknowledgeCrossTenant)."
|
|
13
13
|
}
|
|
14
14
|
]
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.167.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Test-only reset helpers moved from /crypto to /testing (fw#1631).",
|
|
6
|
+
"detail": "resetPiiSubjectKmsForTests and resetBlindIndexKeyForTests are no longer exported by the production barrels. The functions did not change — only the export path. Why it matters beyond tidiness: resetPiiSubjectKmsForTests clears the injected KMS, after which encryptForStorage sees no adapter and writes subject-annotated fields in plaintext, with no error and no log. Reachable from a production barrel, that is one stray import away from silent plaintext PII.",
|
|
7
|
+
"migration": "Change the import in your test files: `import { resetPiiSubjectKmsForTests } from \"@cosmicdrift/kumiko-framework/testing\"` instead of `.../crypto`. Type-check catches every occurrence; nothing else changes. Apps mounting crypto-shredding typically hit this in every test that configures an InMemoryKmsAdapter.",
|
|
8
|
+
"codemod": "scripts/codemod/crypto-shredding-testing-move.ts"
|
|
9
|
+
},
|
|
2
10
|
{
|
|
3
11
|
"version": "0.166.0",
|
|
4
12
|
"type": "improvement",
|
|
@@ -11,14 +19,6 @@
|
|
|
11
19
|
"title": "KmsWiringEnv accepts process.env directly (fw#1618).",
|
|
12
20
|
"detail": "All members being optional made TypeScript's weak-type detection reject ProcessEnv for having no properties in common, which forced every consumer into a cast or a six-key mapping — the boilerplate fw#1617 set out to remove."
|
|
13
21
|
},
|
|
14
|
-
{
|
|
15
|
-
"version": "0.167.0",
|
|
16
|
-
"type": "breaking",
|
|
17
|
-
"title": "Test-only reset helpers moved from /crypto to /testing (fw#1631).",
|
|
18
|
-
"detail": "resetPiiSubjectKmsForTests and resetBlindIndexKeyForTests are no longer exported by the production barrels. The functions did not change — only the export path. Why it matters beyond tidiness: resetPiiSubjectKmsForTests clears the injected KMS, after which encryptForStorage sees no adapter and writes subject-annotated fields in plaintext, with no error and no log. Reachable from a production barrel, that is one stray import away from silent plaintext PII.",
|
|
19
|
-
"migration": "Change the import in your test files: `import { resetPiiSubjectKmsForTests } from \"@cosmicdrift/kumiko-framework/testing\"` instead of `.../crypto`. Type-check catches every occurrence; nothing else changes. Apps mounting crypto-shredding typically hit this in every test that configures an InMemoryKmsAdapter.",
|
|
20
|
-
"codemod": "scripts/codemod/crypto-shredding-testing-move.ts"
|
|
21
|
-
},
|
|
22
22
|
{
|
|
23
23
|
"version": "0.165.3",
|
|
24
24
|
"type": "improvement",
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
[
|
|
2
|
-
{
|
|
3
|
-
"version": "0.117.0",
|
|
4
|
-
"type": "improvement",
|
|
5
|
-
"title": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated vi…",
|
|
6
|
-
"detail": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated via the tier-engine (mirrors ledger/tags). Pass `{ toggleable: { default: false } }` for fail-closed gating — PAT is then off until a tier lists `\"personal-access-tokens\"` in its features. Omitting the option keeps PAT always-on (no behaviour change for existing consumers)."
|
|
7
|
-
},
|
|
8
2
|
{
|
|
9
3
|
"version": "0.202.0",
|
|
10
4
|
"type": "breaking",
|
|
11
5
|
"title": "personal-access-tokens: `write:create` now requires `currentPassword` (+ `mfaCode` if MFA enrolled) (e91d4cb).",
|
|
12
6
|
"detail": "`personal-access-tokens:write:create` now requires `currentPassword` (verified against the caller's password hash) before minting a token, and rejects when the caller has MFA enrolled and `mfaCode` is missing or wrong — a session cookie alone is no longer enough to stand up a durable API credential. `expiresInDays` now defaults to 90 days instead of never-expiring when omitted (the existing 3650-day cap is unchanged, so a genuinely long-lived token is still possible if requested explicitly). Changing a user's password, or enabling/disabling MFA, now revokes all of that user's live PAT tokens — mirrors the existing session auto-revoke-on-password-change behavior. `run-prod-app`/`run-dev-app` wire the new MFA↔PAT revoke callback automatically when both `auth-mfa` and `personal-access-tokens` are mounted; no app-level change needed for that part.",
|
|
13
7
|
"migration": "Apps that already mint PATs (their own client code, scripts, or tests) need to add `currentPassword` to the `create` request payload — this is a breaking change to the `create` request shape despite the minor bump (bundled-features doesn't follow strict semver across its handler schemas yet). If the caller has MFA enrolled, also include a valid `mfaCode`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.117.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated vi…",
|
|
13
|
+
"detail": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated via the tier-engine (mirrors ledger/tags). Pass `{ toggleable: { default: false } }` for fail-closed gating — PAT is then off until a tier lists `\"personal-access-tokens\"` in its features. Omitting the option keeps PAT always-on (no behaviour change for existing consumers)."
|
|
14
14
|
}
|
|
15
15
|
]
|
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
"detail": "`privacy-center` is now a declarative `projectionDetail` screen instead of a custom React component: the Restriction and Deletion sections render through the generic renderer (`EditFieldsSection` + `actions: RowAction[]`), preserving the original confirmation dialogs (`RowActionWriteHandler.confirm`) and visibility rules (`visible: {field, eq/ne}`) 1:1. The Export section (Art. 20) stays a custom `EditExtensionSection` — it needs polling + a signed-URL download — registered via the new `ClientFeatureDefinition.extensionSectionComponents` under `EXPORT_SECTION_EXTENSION_NAME`. `PrivacyCenterScreen` is a dead export (the renderer selects the screen by `screen.type`, not the client component registry). The single `status` field now renders through the `enumOption` format instead of the raw enum string; `gracePeriodEnd` is hidden when no deletion is pending instead of showing an empty date. Known UI regression, accepted for this pass: the confirm-dialog title is now always the action's `label` (the renderer hardcodes this) — the original's distinct `dialogTitle` copy and its dynamic composed banner sentence (e.g. \"Your account will be deleted on {date}\") are gone, replaced by the translated `status` label plus a separately labeled `gracePeriodEnd` date.",
|
|
7
7
|
"migration": "A declarative screen is registered once and can no longer be toggled per-app on the client: `userDataRightsClient(options)`'s `privacyCenter: { showDeletion }` option is removed. Move the flag server-side instead — replace `createUserDataRightsFeature({})` with `createUserDataRightsFeature({ privacyCenterShowDeletion: false })` (default `true`) — it conditionally omits the Deletion section and its `request-deletion`/`cancel-deletion` actions from the screen definition. Then drop the now-unused `privacyCenter` option from the matching `userDataRightsClient({ privacyCenter: { showDeletion: false } })` call. Known affected consumer: `money-horse` (`src/app/client-features.tsx:74`)."
|
|
8
8
|
},
|
|
9
|
-
{
|
|
10
|
-
"version": "0.165.1",
|
|
11
|
-
"type": "fix",
|
|
12
|
-
"title": "restrict-account runs the same cross-tenant membership check as lift-restriction (fw#1543).",
|
|
13
|
-
"detail": "Any Admin/TenantAdmin — not just SystemAdmin — could restrict a user's account and force-revoke their sessions regardless of tenant, as long as they held an admin role in some tenant. Also: the GET /user-export/by-token fallback now forwards x-forwarded-for to the internal query call, so the per-IP 30/min limit stops collapsing into one global bucket."
|
|
14
|
-
},
|
|
15
9
|
{
|
|
16
10
|
"version": "0.165.3",
|
|
17
11
|
"type": "improvement",
|
|
18
12
|
"title": "Forget cleanup purges derived Meili docs for searchable subject-PII (fw#1610).",
|
|
19
13
|
"detail": "run-forget-cleanup (and the forget cron path) call purgeSearchDocumentsForSubject after crypto-shred so searchable userOwned fields do not linger in Meili as plaintext after Art. 17."
|
|
20
14
|
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.165.1",
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"title": "restrict-account runs the same cross-tenant membership check as lift-restriction (fw#1543).",
|
|
19
|
+
"detail": "Any Admin/TenantAdmin — not just SystemAdmin — could restrict a user's account and force-revoke their sessions regardless of tenant, as long as they held an admin role in some tenant. Also: the GET /user-export/by-token fallback now forwards x-forwarded-for to the internal query call, so the per-IP 30/min limit stops collapsing into one global bucket."
|
|
20
|
+
},
|
|
21
21
|
{
|
|
22
22
|
"version": "0.165.0",
|
|
23
23
|
"type": "breaking",
|
|
@@ -105,15 +105,62 @@ async function waitForMount(view: ReturnType<typeof render>, testId: string): Pr
|
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Reproduces the real host: RenderEdit renders exactly one <form
|
|
109
|
+
// testId="render-edit-form"> around every singleton projectionDetail screen
|
|
110
|
+
// (render-edit.tsx), and both sections mount as children of that form via
|
|
111
|
+
// ExtensionSectionMount. A section that renders its own <form> nests invalid
|
|
112
|
+
// DOM inside it — the security bug this guards against: the submit button
|
|
113
|
+
// falls back to a native GET navigation, putting the password(s) in the URL
|
|
114
|
+
// query instead of dispatching a write.
|
|
115
|
+
function renderInsideHostForm(node: ReactNode) {
|
|
116
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
117
|
+
const view = render(<form data-testid="render-edit-form">{node}</form>, {
|
|
118
|
+
wrapper: withProviders(writes),
|
|
119
|
+
});
|
|
120
|
+
return { view, writes };
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
describe("ChangeEmailSection", () => {
|
|
109
|
-
test("renders
|
|
124
|
+
test("renders no <form> of its own (would nest inside the host RenderEdit <form>), shows the current email, no raw i18n keys", async () => {
|
|
110
125
|
const { view } = renderChangeEmailSection("marc@example.com");
|
|
111
126
|
await waitForMount(view, "change-email-root");
|
|
112
|
-
expect(view.
|
|
127
|
+
expect(view.container.querySelector("form")).toBeNull();
|
|
113
128
|
expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
|
|
114
129
|
expect(view.container.textContent).not.toContain("profile.");
|
|
115
130
|
});
|
|
116
131
|
|
|
132
|
+
// Security regression guard: mounted inside the real host <form> (as
|
|
133
|
+
// RenderEdit does in production), a click on the submit button must still
|
|
134
|
+
// reach the write dispatcher — not fall back to the host form's native
|
|
135
|
+
// GET submission, which would put currentPassword/newEmail in the URL.
|
|
136
|
+
test("mounted inside the host RenderEdit <form>, a click on the submit button dispatches change-email (no nested <form>, no native submit fallback)", async () => {
|
|
137
|
+
const { view, writes } = renderInsideHostForm(
|
|
138
|
+
<ChangeEmailSection
|
|
139
|
+
entityName="user"
|
|
140
|
+
entityId={null}
|
|
141
|
+
values={{ email: "old@example.com" }}
|
|
142
|
+
patch={() => {}}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
await waitForMount(view, "profile-email");
|
|
146
|
+
expect(view.container.querySelectorAll("form")).toHaveLength(1);
|
|
147
|
+
|
|
148
|
+
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
149
|
+
const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
|
|
150
|
+
if (!emailInput || !pwInput) throw new Error("email form inputs not found");
|
|
151
|
+
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
152
|
+
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
153
|
+
fireEvent.click(view.getByTestId("profile-email-submit"));
|
|
154
|
+
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
if (writes.length === 0) throw new Error("no write dispatched yet");
|
|
157
|
+
});
|
|
158
|
+
expect(writes[0]).toEqual({
|
|
159
|
+
type: "user-profile:write:change-email",
|
|
160
|
+
payload: { currentPassword: "current-pw", newEmail: "new@example.com" },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
117
164
|
test("submits change-email with currentPassword + newEmail, patches the host's email on success", async () => {
|
|
118
165
|
const { view, writes, patchCalls } = renderChangeEmailSection("old@example.com");
|
|
119
166
|
await waitForMount(view, "profile-email");
|
|
@@ -212,13 +259,40 @@ describe("ChangeEmailSection", () => {
|
|
|
212
259
|
});
|
|
213
260
|
|
|
214
261
|
describe("ChangePasswordSection", () => {
|
|
215
|
-
test("renders
|
|
262
|
+
test("renders no <form> of its own (would nest inside the host RenderEdit <form>), no raw i18n keys", async () => {
|
|
216
263
|
const { view } = renderChangePasswordSection();
|
|
217
264
|
await waitForMount(view, "change-password-root");
|
|
218
|
-
expect(view.
|
|
265
|
+
expect(view.container.querySelector("form")).toBeNull();
|
|
219
266
|
expect(view.container.textContent).not.toContain("profile.");
|
|
220
267
|
});
|
|
221
268
|
|
|
269
|
+
// Security regression guard: mounted inside the real host <form> (as
|
|
270
|
+
// RenderEdit does in production), a click on the submit button must still
|
|
271
|
+
// reach the write dispatcher — not fall back to the host form's native
|
|
272
|
+
// GET submission, which would put old+new passwords in the URL.
|
|
273
|
+
test("mounted inside the host RenderEdit <form>, a click on the submit button dispatches change-password (no nested <form>, no native submit fallback)", async () => {
|
|
274
|
+
const { view, writes } = renderInsideHostForm(<ChangePasswordSection />);
|
|
275
|
+
await waitForMount(view, "profile-password");
|
|
276
|
+
expect(view.container.querySelectorAll("form")).toHaveLength(1);
|
|
277
|
+
|
|
278
|
+
const oldPw = view.container.querySelector<HTMLInputElement>("#profile-old-password");
|
|
279
|
+
const newPw = view.container.querySelector<HTMLInputElement>("#profile-new-password");
|
|
280
|
+
const confirmPw = view.container.querySelector<HTMLInputElement>("#profile-confirm-password");
|
|
281
|
+
if (!oldPw || !newPw || !confirmPw) throw new Error("password form inputs not found");
|
|
282
|
+
fireEvent.change(oldPw, { target: { value: "current-pw" } });
|
|
283
|
+
fireEvent.change(newPw, { target: { value: "new-pw-1" } });
|
|
284
|
+
fireEvent.change(confirmPw, { target: { value: "new-pw-1" } });
|
|
285
|
+
fireEvent.click(view.getByTestId("profile-password-submit"));
|
|
286
|
+
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
if (writes.length === 0) throw new Error("no write dispatched yet");
|
|
289
|
+
});
|
|
290
|
+
expect(writes[0]).toEqual({
|
|
291
|
+
type: "auth-email-password:write:change-password",
|
|
292
|
+
payload: { oldPassword: "current-pw", newPassword: "new-pw-1" },
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
222
296
|
test("mismatched confirm password blocks the submit (no write dispatched)", async () => {
|
|
223
297
|
const { view, writes } = renderChangePasswordSection();
|
|
224
298
|
await waitForMount(view, "profile-password");
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.243.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Security fix: ChangePasswordSection/ChangeEmailSection no longer nest a <form> inside the host RenderEdit form.",
|
|
6
|
+
"detail": "Since `profile` became a declarative `projectionDetail` screen (fw#2312), RenderEdit wraps the whole singleton screen in its own host `<form>`. `ChangePasswordSection` and `ChangeEmailSection` were each rendering their own `<Form>` inside that host, creating invalid nested `<form>` DOM. The submit button silently fell back to a native GET submission of the outer form instead of dispatching a write, putting the entered password(s) in the URL query string (browser history, `Referer` header, proxy/server access logs — `ChangePasswordSection` leaked both the old and the new password). Both sections now follow the same no-nested-form pattern `write-form-section.tsx` uses: a plain container with a `type=\"button\"` that calls the write dispatcher directly. testIds are unchanged; Enter-to-submit inside these two sections is an accepted, deliberate trade-off (same one `write-form-section.tsx` already makes)."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.241.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
usePrimitives,
|
|
14
14
|
useTranslation,
|
|
15
15
|
} from "@cosmicdrift/kumiko-renderer";
|
|
16
|
-
import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
|
|
17
16
|
import { type ReactNode, useState } from "react";
|
|
18
17
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
19
18
|
import { requestEmailVerification } from "../../auth-email-password/web";
|
|
@@ -46,12 +45,15 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
|
|
|
46
45
|
|
|
47
46
|
// Mounted by the renderer's ExtensionSectionMount (already wrapped in
|
|
48
47
|
// <Section title={...} testId="section-extension-...">) — takes no props of
|
|
49
|
-
// its own.
|
|
50
|
-
//
|
|
51
|
-
//
|
|
48
|
+
// its own. Deliberately NOT a <Form>: RenderEdit already renders a host
|
|
49
|
+
// <form> around the whole singleton screen (render-edit.tsx), so a second
|
|
50
|
+
// nested <form> here is invalid DOM and silently degrades the submit button
|
|
51
|
+
// into a native GET navigation instead of a write dispatch (same failure
|
|
52
|
+
// mode write-form-section.tsx documents and avoids the same way — a plain
|
|
53
|
+
// button click, not a second <form>).
|
|
52
54
|
export function ChangePasswordSection(): ReactNode {
|
|
53
55
|
const t = useTranslation();
|
|
54
|
-
const {
|
|
56
|
+
const { Field, Input, Button } = usePrimitives();
|
|
55
57
|
const dispatcher = useDispatcher();
|
|
56
58
|
const [oldPassword, setOldPassword] = useState("");
|
|
57
59
|
const [newPassword, setNewPassword] = useState("");
|
|
@@ -82,55 +84,55 @@ export function ChangePasswordSection(): ReactNode {
|
|
|
82
84
|
|
|
83
85
|
const submitting = status.kind === "submitting";
|
|
84
86
|
return (
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
</
|
|
133
|
-
</
|
|
87
|
+
<div data-testid="profile-password" className="flex flex-col gap-4">
|
|
88
|
+
<Field id="profile-old-password" label={t("profile.password.old")} required>
|
|
89
|
+
<Input
|
|
90
|
+
kind="password"
|
|
91
|
+
id="profile-old-password"
|
|
92
|
+
name="profile-old-password"
|
|
93
|
+
value={oldPassword}
|
|
94
|
+
onChange={setOldPassword}
|
|
95
|
+
disabled={submitting}
|
|
96
|
+
required
|
|
97
|
+
autoComplete="current-password"
|
|
98
|
+
/>
|
|
99
|
+
</Field>
|
|
100
|
+
<Field id="profile-new-password" label={t("profile.password.new")} required>
|
|
101
|
+
<Input
|
|
102
|
+
kind="password"
|
|
103
|
+
id="profile-new-password"
|
|
104
|
+
name="profile-new-password"
|
|
105
|
+
value={newPassword}
|
|
106
|
+
onChange={setNewPassword}
|
|
107
|
+
disabled={submitting}
|
|
108
|
+
required
|
|
109
|
+
autoComplete="new-password"
|
|
110
|
+
/>
|
|
111
|
+
</Field>
|
|
112
|
+
<Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
|
|
113
|
+
<Input
|
|
114
|
+
kind="password"
|
|
115
|
+
id="profile-confirm-password"
|
|
116
|
+
name="profile-confirm-password"
|
|
117
|
+
value={confirm}
|
|
118
|
+
onChange={setConfirm}
|
|
119
|
+
disabled={submitting}
|
|
120
|
+
required
|
|
121
|
+
autoComplete="new-password"
|
|
122
|
+
/>
|
|
123
|
+
</Field>
|
|
124
|
+
<StatusBanner status={status} />
|
|
125
|
+
<div className="flex items-center justify-end gap-2">
|
|
126
|
+
<Button
|
|
127
|
+
type="button"
|
|
128
|
+
disabled={submitting}
|
|
129
|
+
onClick={() => onSubmit()}
|
|
130
|
+
testId="profile-password-submit"
|
|
131
|
+
>
|
|
132
|
+
{t("profile.password.submit")}
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
134
136
|
);
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -142,7 +144,7 @@ export function ChangePasswordSection(): ReactNode {
|
|
|
142
144
|
// relying on a full-screen refetch to un-stale the "current email" line.
|
|
143
145
|
export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): ReactNode {
|
|
144
146
|
const t = useTranslation();
|
|
145
|
-
const {
|
|
147
|
+
const { Field, Input, Button, Text } = usePrimitives();
|
|
146
148
|
const dispatcher = useDispatcher();
|
|
147
149
|
const [newEmail, setNewEmail] = useState("");
|
|
148
150
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
@@ -188,45 +190,45 @@ export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): Re
|
|
|
188
190
|
|
|
189
191
|
const submitting = status.kind === "submitting";
|
|
190
192
|
return (
|
|
191
|
-
<
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
{
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
</
|
|
230
|
-
</
|
|
193
|
+
<div data-testid="profile-email" className="flex flex-col gap-4">
|
|
194
|
+
<Text variant="muted" testId="profile-email-current">
|
|
195
|
+
{t("profile.email.current")}: {currentEmail}
|
|
196
|
+
</Text>
|
|
197
|
+
<Field id="profile-new-email" label={t("profile.email.new")} required>
|
|
198
|
+
<Input
|
|
199
|
+
kind="email"
|
|
200
|
+
id="profile-new-email"
|
|
201
|
+
name="profile-new-email"
|
|
202
|
+
value={newEmail}
|
|
203
|
+
onChange={setNewEmail}
|
|
204
|
+
disabled={submitting}
|
|
205
|
+
required
|
|
206
|
+
autoComplete="email"
|
|
207
|
+
/>
|
|
208
|
+
</Field>
|
|
209
|
+
<Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
|
|
210
|
+
<Input
|
|
211
|
+
kind="password"
|
|
212
|
+
id="profile-email-password"
|
|
213
|
+
name="profile-email-password"
|
|
214
|
+
value={currentPassword}
|
|
215
|
+
onChange={setCurrentPassword}
|
|
216
|
+
disabled={submitting}
|
|
217
|
+
required
|
|
218
|
+
autoComplete="current-password"
|
|
219
|
+
/>
|
|
220
|
+
</Field>
|
|
221
|
+
<StatusBanner status={status} />
|
|
222
|
+
<div className="flex items-center justify-end gap-2">
|
|
223
|
+
<Button
|
|
224
|
+
type="button"
|
|
225
|
+
disabled={submitting}
|
|
226
|
+
onClick={() => onSubmit()}
|
|
227
|
+
testId="profile-email-submit"
|
|
228
|
+
>
|
|
229
|
+
{t("profile.email.submit")}
|
|
230
|
+
</Button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
231
233
|
);
|
|
232
234
|
}
|