Skip to content

PR 7 — Account settings: change password + notification prefs + delete account#11

Merged
sidhujag merged 4 commits intomasterfrom
account-settings-pr7
Apr 21, 2026
Merged

PR 7 — Account settings: change password + notification prefs + delete account#11
sidhujag merged 4 commits intomasterfrom
account-settings-pr7

Conversation

@sidhujag
Copy link
Copy Markdown
Member

Summary

Wires three new Account-screen cards into sysnode-info and extends authService + VaultContext with the client-side crypto orchestration the backend expects.

Backend companion: syscoin/sysnode-backend#7

What's in it

Change password

  • authService.deriveChangePasswordKeys(oldPassword, newPassword, email) separates key derivation from the API call so the card can orchestrate new-master → vault rewrap → submit without authService having to know about vault internals (same pattern as login).
  • VaultContext.rewrapForPasswordChange(newMaster) wraps the existing envelope under the newly-derived vaultKey and returns {blob,etag} ready to POST; if no vault exists, returns null so the route treats it as a pure password change.
  • ChangePasswordCard ties it together: derive new master, rewrap vault, POST {oldAuthHash,newAuthHash,vault?}, surface 409 vault_rewrap_required as a hard error rather than papering over it.

Notification preferences

  • authService.getPrefs() / updatePrefs(prefs).
  • NotificationPreferencesCard gates its form behind a hydrated flag so clicks cannot race with the initial /auth/me round-trip (previously the source of flaky double-toggles).

Delete account (GDPR)

  • authService.deleteAccount({oldAuthHash}).
  • DeleteAccountCard is a two-step destructive UX: collapsed "Delete account…" entry → expanded warning list + email-match + current-password re-proof. Email match is case-insensitive and trim-tolerant so "alice@EXAMPLE.com " confirms correctly.
  • On success triggers useAuth().handleAuthLost() (which tears down VaultContext via its isAuthenticated watcher) and history.replace('/').

Test plan

  • Full frontend suite: 519/519 pass (35 suites).
    • New coverage: DeleteAccountCard (collapsed/expanded states, email mismatch guard, case-insensitive + trim tolerance, empty password guard, invalid_credentials surfaces without destroying local state, success navigates to /), NotificationPreferencesCard (hydration gating, toggle + submit), ChangePasswordCard orchestration, VaultContext.rewrapForPasswordChange, authService.deleteAccount.
  • Manual: change password in UI → reload → vault still decrypts with new password.
  • Manual: toggle notification preference; confirm round-trip through /auth/prefs.
  • Manual: delete account; confirm redirect to /, logged out, and re-registering the same email works.

Notes for review

  • resetMocks: true (CRA default) wipes jest.fn(() => ...) implementations between tests, so the new component tests install the deriveLoginKeys stub in beforeEach rather than inline in the jest.mock factory — same pattern already used in ProposalVoteModal.test.js. Without this the component tests silently fall through to real PBKDF2 (600k SHA-512 iterations) and blow past waitFor's default 1s window. Cut affected test runtime from ~10s each to ~30ms.
  • DeleteAccountCard deliberately uses fireEvent.submit on the form (and fireEvent.change on inputs) instead of userEvent.click/userEvent.type. Under React 17 + jsdom, userEvent@13's synthetic pointer sequence can commit a click before a pending controlled-input state update has flushed, running the handler on stale state (this bit NotificationPreferencesCard first and is documented inline in both test files).

Made with Cursor

…(PR 7)

Wires the three new Account-screen cards into sysnode-info and extends
authService + VaultContext with the client-side crypto orchestration
the backend expects.

Change password
- authService: deriveChangePasswordKeys(oldPassword, newPassword, email)
  separates key derivation from the API call so the card can orchestrate
  new-master -> vault rewrap -> submit without authService having to
  know about vault internals (same pattern as login).
- VaultContext.rewrapForPasswordChange(newMaster) wraps the existing
  envelope under the newly-derived vaultKey and returns {blob,etag}
  ready to POST; if no vault exists, returns null so the route treats
  it as a pure password change.
- ChangePasswordCard ties it all together: derive new master, rewrap
  vault, POST {oldAuthHash,newAuthHash,vault?}, surface 409
  vault_rewrap_required as a hard error rather than papering over it.

Notification preferences
- authService.getPrefs() / updatePrefs(prefs).
- NotificationPreferencesCard gates its form behind a `hydrated` flag
  so clicks cannot race with the initial /auth/me round-trip
  (previously the source of flaky double-toggles).

Delete account (GDPR)
- authService.deleteAccount({oldAuthHash}).
- DeleteAccountCard is a two-step destructive UX: collapsed
  "Delete account..." entry -> expanded warning list + email-match +
  current-password re-proof. Email match is case-insensitive and
  trim-tolerant so "alice@EXAMPLE.com " confirms correctly. On success
  triggers useAuth().handleAuthLost() (which tears down VaultContext
  via its isAuthenticated watcher) and history.replace('/').

Notes for tests
- New component tests reinstall deriveLoginKeys mock impl in beforeEach:
  CRA's Jest config defaults to resetMocks: true which wipes
  jest.fn(() => ...) implementations between tests. Without this the
  tests would silently fall through to real 600k-iter PBKDF2 and blow
  past testing-library's default 1s waitFor window. Same pattern
  already used in ProposalVoteModal.test.js.
- DeleteAccountCard uses fireEvent.submit / fireEvent.change instead of
  userEvent.click to avoid a React 17 + jsdom + userEvent@13 stale-state
  race that bit NotificationPreferencesCard first; documented inline.

Made-with: Cursor
@sidhujag
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d717a7d3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/context/VaultContext.js Outdated
Comment on lines +759 to +760
if (stateRef.current.status !== UNLOCKED) return;
vaultKeyRef.current = newVaultKey;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update cached blob when rewrap commit runs after lock

The commit callback exits unless the vault is still UNLOCKED, which breaks a real race: if the user locks the vault while /auth/change-password is in flight, the server accepts the rewrapped blob but this callback becomes a no-op. That leaves state.blob/etag pointing to the old envelope, and later unlock attempts use the cached locked snapshot (via load() short-circuit) with the new password, causing repeated decrypt failures until a full page reload. The callback should still refresh blob/etag when locked, while avoiding key reinstallation.

Useful? React with 👍 / 👎.

Comment on lines +116 to +118
} catch (err) {
setErrCode((err && err.code) || 'http_error');
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear auth state on unauthorized delete-account response

When deleteAccount returns unauthorized (for example, session expiry mid-submit), this branch only stores an error and returns. Because /auth/* responses do not trigger the global auth-loss interceptor, AuthContext remains authenticated and the user can stay on private account UI with stale session state. Handle this case by calling handleAuthLost() (and redirecting) so client auth state matches the server.

Useful? React with 👍 / 👎.

P1 (VaultContext.rewrapForPasswordChange)
  If the user locks the vault between POST /auth/change-password and
  rewrap.commit(), the server has already accepted the new envelope
  but the commit used to no-op. state.{blob,etag} stayed pointing at
  the old wrap; load()'s cache short-circuit then served that stale
  snapshot for every subsequent unlock attempt, so the new password
  would fail decrypt until a full page reload.

  - Capture sessionGen at the start of rewrapForPasswordChange so the
    commit can cleanly distinguish "same session, vault locked
    mid-flight" (refresh blob/etag only) from "different session
    entirely" (e.g. logout: hard no-op, as before).
  - When UNLOCKED mid-commit: install new vaultKey + update blob/etag
    (existing happy path).
  - When LOCKED mid-commit: update blob/etag only — vault key is
    rederived from the new master on the next unlock anyway.
  - Test: lock mid-flight, commit, then unlockWithMaster(newMaster)
    succeeds WITHOUT an extra load() round-trip — proving the cache
    short-circuit now serves the fresh ciphertext.

P2 (DeleteAccountCard — unauthorized)
  deleteAccount returning `unauthorized` meant the server considered
  the client unauthenticated, but we only stored an errCode and sat
  there — user stranded on private account UI while AuthContext
  still thought they were signed in. The /auth/* apiClient path does
  NOT drive the global auth-loss interceptor, so this branch has to
  handle it itself: call handleAuthLost() and bounce to '/'. Test
  added.

Made-with: Cursor
@sidhujag
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9f98a07bb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +181 to +183
} catch (err) {
setErrCode((err && err.code) || 'http_error');
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear auth state on unauthorized password-change response

When /auth/change-password returns 401 unauthorized (e.g., session expired in another tab), this branch only sets errCode and returns, so AuthContext stays authenticated and the user remains on private Account UI with stale session state. Because /auth/* calls are excluded from the global auth-loss interceptor, this path needs to explicitly trigger auth loss (or force a refresh that can transition to anonymous), otherwise the user gets stuck in an inconsistent signed-in state.

Useful? React with 👍 / 👎.

Comment on lines +129 to +131
.catch((err) => {
if (cancelled || !mountedRef.current) return;
setErrCode((err && err.code) || 'http_error');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle unauthorized in notification prefs as auth loss

If /auth/prefs responds with 401 unauthorized during hydration or save, this code path only records an inline error and continues rendering the authenticated account screen. Since /auth/* responses do not trigger the shared auth-loss interceptor, failing to clear auth state here leaves the app believing the user is signed in after the server has rejected the session.

Useful? React with 👍 / 👎.

…odex round 2 P2)

Apply the same /auth/* → handleAuthLost pattern that PR 7 already
lands for DeleteAccountCard to the two companion cards. /auth/*
responses bypass the global auth-loss interceptor, so each component
has to explicitly flip AuthContext to ANONYMOUS when the server
reports the session as expired — otherwise the user stays rendered
on private Account UI with a stale signed-in state until they
navigate manually.

ChangePasswordCard
  - Wire up handleAuthLost from useAuth.
  - On `unauthorized` from authService.changePassword, call
    handleAuthLost() and bail. PrivateRoute redirects to /login on
    the next render.
  - Non-auth failures (invalid_credentials, precondition_failed, etc.)
    still surface the existing inline alert — the fix is targeted.
  - Added ChangePasswordCard.test.js covering both branches. Scope
    note at the top explains why we mock useVault + swap the whole
    authService rather than spinning up a real VaultProvider: the
    crypto/rewrap paths are already covered in authService.test.js
    and VaultContext.test.js, this file only guards the 401 branch.

NotificationPreferencesCard
  - Same useAuth wiring.
  - Both network paths (hydration GET /auth/prefs and save PUT
    /auth/prefs) now route 401 through handleAuthLost. Hydration's
    "show the form anyway so the user isn't behind an infinite
    spinner" recovery is deliberately skipped for the 401 case —
    the whole page is about to unmount.
  - New tests use a sibling AuthProbe to observe the AuthContext
    status transition, which is the user-visible effect.

Made-with: Cursor
@sidhujag
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 86c536b098

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return () => {
cancelled = true;
};
}, [needsHydration, authService, handleAuthLost]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-run prefs hydration when authenticated user changes

The fallback /auth/prefs hydration effect is keyed only by needsHydration, so it does not re-run when user.id changes but both identities have notificationPrefs == null. In that account-switch case, the card can keep the previous user’s in-memory toggle and submit that stale value for the new user. Please key this effect (and hydration reset) on identity, e.g. user.id, not just the boolean gate.

Useful? React with 👍 / 👎.

Comment on lines +139 to +141
const hasVault =
vault.isLocked || vault.isUnlocked || vault.isError;
if (hasVault && !vault.isUnlocked) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block submit while vault status is still loading

hasVault only checks isLocked/isUnlocked/isError, so during initial vault loading it evaluates false and allows submission to proceed into the expensive KDF path before failing at rewrapForPasswordChange with vault_not_unlocked. That creates a misleading error and unnecessary PBKDF2 work for a normal “page still hydrating” case. Treat unresolved vault states as not-ready and short-circuit before derivation.

Useful? React with 👍 / 👎.

…te prefs per identity (Codex round 2)

Frontend Codex PR 7 round-2 P2 findings addressed:

src/components/ChangePasswordCard.js:141 — submit now short-circuits
when vault is isIdle or isLoading, surfacing a "vault still loading"
message instead of running the full double-PBKDF2 derivation only to
fail inside rewrapForPasswordChange with the misleading
vault_not_unlocked copy. Added a regression test that asserts
deriveChangePasswordKeys, rewrapForPasswordChange, and changePassword
are all uncalled when the vault is in LOADING.

src/components/NotificationPreferencesCard.js:156 — fallback
GET /auth/prefs effect is now keyed on user.id, not just a boolean
gate. Previously, if user A hydrated via the fallback path
(notificationPrefs == null) and the app then swapped to user B with
notificationPrefs also null, no dep changed and the effect would
not re-run — user A's in-memory toggle would persist and a Save
could PUT that stale value under user B's credentials. On identity
change we now reset hydrated/errCode/success, refetch, and mark
lastHydratedUserIdRef so the effect settles per-user without
looping. Added a regression test that swaps /auth/me between two
user ids via AuthContext.refresh() and asserts getPrefs is called
once per identity with the form reflecting each user's stored value.

Made-with: Cursor
@sidhujag
Copy link
Copy Markdown
Member Author

@codex review

Round-2 P2 findings addressed in 3878b80:

  • src/components/ChangePasswordCard.js:141 — submit short-circuits when the vault is isIdle || isLoading, surfacing a vault_still_loading message instead of running the full double-PBKDF2 only to fail inside rewrapForPasswordChange. Regression test asserts deriveChangePasswordKeys, rewrapForPasswordChange, and changePassword are all uncalled during LOADING.
  • src/components/NotificationPreferencesCard.js:156 — fallback GET /auth/prefs hydration is now keyed on user.id (plus lastHydratedUserIdRef) so an identity change re-fires the effect, resets hydrated/errCode/success, and re-fetches per-user prefs instead of holding the previous user's in-memory toggle. Regression test swaps /auth/me between two user ids via AuthContext.refresh() and asserts the form reflects each user's stored value.

Note for schema reviewers on the companion backend PR (sysnode-backend#7): sysnode-backend is pre-production, so forward migrations are not relevant — in-place edits to 001_init.sql are the correct schema-lifecycle move until v1 ships. This PR is frontend-only and has no schema changes.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sidhujag sidhujag merged commit 35a6044 into master Apr 21, 2026
Copy link
Copy Markdown
Member

Follow-up security audit finding from current master:

Several non-login password flows derive the raw 32-byte PBKDF2 master and then leave it uncleared in JS memory after use, even though the main login path explicitly zeroes the same material in Login.finishLogin() and authService.deriveStepUpAuthHash().

Concrete spots on current head:

  • src/context/VaultContext.js reload unlock path: const master = await deriveMaster(password, email); return unlockWithMaster(master);
  • src/context/VaultContext.js EMPTY first-write path: derives master, uses it for deriveAuthHash() and deriveVaultKey(), but never wipes it
  • src/lib/authService.js deriveChangePasswordKeys(): deriveLoginKeys(oldPassword, email) allocates oldKeys.master, and newMaster is returned to the caller without the helper zeroing the old one
  • src/components/ChangePasswordCard.js: newMaster is passed into vault.rewrapForPasswordChange(newMaster) but never fill(0)ed afterward
  • src/components/DeleteAccountCard.js: deriveLoginKeys(password, user.email) allocates master, but the flow only reads authHash and never wipes the derived master bytes

This is a client-memory hygiene issue rather than a server-custody issue, but on a design that deliberately keeps voting keys client-side, shortening the lifetime of password-derived master material matters. I’d recommend wrapping these flows in try/finally zeroization the same way the login and step-up paths already do.

  • Nightglass Audit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants