Skip to content

feat(account): let a signed-in user manage their own account - #131

Open
MOHITKOURAV01 wants to merge 1 commit into
udaycodespace:mainfrom
MOHITKOURAV01:feat/126-account-panel
Open

feat(account): let a signed-in user manage their own account#131
MOHITKOURAV01 wants to merge 1 commit into
udaycodespace:mainfrom
MOHITKOURAV01:feat/126-account-panel

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Closes #126.

The defect

Sixteen routes on /api/user, and not one of them read or wrote the account. No
/me, no /profile, no /change-password. The ⚙️ Settings dropdown held one
control, a theme toggle, and grep -rn "profile" frontend/src returned nothing.

The only way to set a new password was Log Out → Forgot password? → check email.

Why that is a security problem and not only a missing screen

Rotating a password required giving up the session and using a weaker path.
The signed-in user has already proved something the application can check: they
hold a valid token for this account. Sending them to /forgot-password makes
them prove something it cannot — that they can read a mailbox. That channel is
outside the application, #95 had to harden it precisely because it answers
uniformly to strangers, and anybody with mailbox access can drive it too.

The recovery path has side effects a rotation should not cause.

$set: { password: hashedPassword, isVerified: true },
$unset: { resetToken: "", ..., otp: "", otpExpiry: "", otpAttempts: "" },

Marking the address verified is correct for a reset — holding the emailed code
is proof of the mailbox — but it is a state change a routine password change
has no business making, and it silently discards a pending verification code.

A password change left no trace. The action enum was
["login", "logout", "login_failed"], so the log could not answer "was this
account's password changed, and from where" — the successor to the question #87
added login_failed for.

Nothing else could be corrected. A name typed wrong at registration was
permanent, and it is read by the navbar, the certificate and every review
byline.

What is here

GET /api/user/account — what the application stores about you. There was
no way for anyone to see it. toAccountView is an explicit allow-list rather
than a list of fields to strip: adminController projects with
-password -otp -resetToken …, which is correct but has to be extended every
time a sensitive field is added. An allow-list cannot leak a field nobody
thought about. There is a test that hands it a field nobody has declared yet.

PUT /api/user/account — the display name, and only that. Deliberately not
email: the address is the account's identity, unique-indexed since #72, and
moving it has to be proved against the new mailbox first, which is a
verification flow of its own rather than a text input. Deliberately not type:
that is the field #55 closed. The updated account comes back so the client can
replace the stored session user instead of rendering a stale name until the next
sign-in.

POST /api/user/change-password — requires the current password, and that
requirement is the point of the endpoint: possession of an unattended tab must
not be enough to lock the owner out of their own account.

It clears any reset code in flight, so one somebody else requested does not
still work afterwards. It deliberately does not touch isVerified or the
OTP fields — the reset flow sets them because holding the emailed code proves
the mailbox, and this route proves nothing about the mailbox, so it must not
claim to. The success message says plainly that sessions signed in elsewhere
stay signed in until their token expires; the token is stateless and there is
nothing to revoke, and anything else would be a promise the application cannot
keep.

Three new activity log actionspassword_changed,
password_change_failed, profile_updated — in the schema enum, the listing
filter's allow-list and the admin dropdown and badge labels.
password_change_failed is the one that matters most: a run of them against a
signed-in account is somebody working on a session they should not have. A test
asserts the storable set and the filterable set are identical, so an action the
log can write but the filter refuses cannot appear again.

The Account panel at /dashboard?panel=account, listed for every role.
lib/account.js mirrors the server's rules — the same table asserted on both
sides, the pattern #114 established. The confirmation field exists only in the
browser: the server takes one new password and has nothing to compare a second
field against.

Rate limiting

/change-password gets the same per-client rate limiter every credential
endpoint has: it is behind a valid token, which is the bound that matters, but
it also takes a password and tells you whether the guess was right.

It gets no failure throttle. That mechanism locks an email address, and
locking the owner of a live session out of the whole application because they
mistyped their current password twice is worse than the thing it prevents. The
failures go to the activity log instead.

The configured admin

authMiddleware recognises the reserved id "admin" without a lookup — it is a
credential pair in the environment, not a users row. Every route here would
otherwise hand "admin" to findById, which casts it to an ObjectId and
throws. It reads its account from what the middleware already knows, marked
editable: false, and both writes answer 403 saying where those credentials
actually live.

Tests

backend/tests/account-management.test.js — 21 tests with injected models, so
no database. An update cannot reach type, email or isVerified; the wrong
current password changes nothing and is recorded; a change clears the reset
credential but leaves isVerified and the OTP alone; and the admin branch is
asserted with a User stub whose findById throws, so the test fails if the
lookup is ever reached.

frontend/src/lib/account.test.js — 16 tests, including a seven-row table of
password cases the server asserts too.

frontend/src/lib/dashboardPanels.test.js — the existing expectations updated
for the new panel, plus tests that every real role may open it and an unreadable
role falls back to home.

docs/issue-126-account-panel.md has the write-up.

Checklist

  • cd backend && npm test — 526 pass (505 on main, 21 added)
  • cd frontend && npm test — 230 pass (214 on main, 16 added)
  • cd frontend && npm run build
  • npm run lint — does not pass on main (69 problems) and does not here.
    ActivityLogs.jsx and Dashboard.jsx report the same 1 each before and
    after, a pre-existing unused React import. lib/account.js and
    AccountPanel.jsx lint clean.

Notes

styles/account.css is its own file rather than another block appended to
theme.css, for the reason theme.css's own header gives: several branches
each appending to one shared file conflicts on every pair of them.

The emailed reset flow is unchanged, for the case it was built for — somebody
who cannot sign in.

Sixteen routes on /api/user and not one of them read or wrote the account:
no /me, no /profile, no /change-password. The Settings dropdown held a
theme toggle. The only way to set a new password was Log Out → Forgot
password? → check email.

That is not only a missing screen. The signed-in user has already proved
something the server can check — they hold a valid token for this account.
The reset flow makes them prove something it cannot, that they can read a
mailbox, which is outside the application, which udaycodespace#95 had to harden against
strangers, and which anyone with mailbox access can drive too. It also
sets isVerified and discards any pending OTP, side effects a routine
rotation has no business causing. And nothing was recorded: the activity
log's action enum was login, logout, login_failed.

- GET /api/user/account returns what is stored, through an allow-list
  rather than a list of fields to strip, so it cannot leak a field nobody
  has thought about yet.
- PUT /api/user/account changes the display name and only that. Not email
  — that is the account's unique identity since udaycodespace#72 and moving it needs
  proof against the new mailbox. Not type — that is the field udaycodespace#55 closed.
- POST /api/user/change-password requires the current password. It clears
  any reset code in flight and deliberately leaves isVerified and the OTP
  alone, because it proves nothing about the mailbox and must not claim
  to. The response says plainly that other sessions stay signed in: the
  token is stateless and there is nothing to revoke.
- password_changed, password_change_failed and profile_updated added to
  the schema enum, the listing filter and the admin dropdown. A test keeps
  the storable set and the filterable set identical.
- An Account panel for every role, with the rules mirrored in
  lib/account.js and asserted on both sides.

Rate limited like the other credential endpoints, but with no failure
throttle: that locks an email address, and locking someone out of the app
for mistyping their current password twice is worse than what it prevents.

The configured admin has no users row, so its id would cast and throw on
findById. It reads as editable: false and both writes answer 403.

backend 526 pass (505 before), frontend 230 pass (214 before).

Closes udaycodespace#126
@gitguardian

gitguardian Bot commented Aug 28, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
- - Generic Password 89d4edd frontend/src/lib/account.test.js View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor Author

One note for whoever merges this alongside #130.

This PR defines the reserved-admin check locally, in accountController.js:

const ADMIN_ID = "admin";
const isAdminId = (id) => String(id ?? "") === ADMIN_ID;

It has to, to stand alone against main — every route here would otherwise
hand "admin" to findById, which casts it to an ObjectId and throws.

#130 introduces utils/adminAccount.js with the same check, shared between
authMiddleware and the admin login response. The two do not conflict
textually and both are correct on their own, but once both are in, this local
pair should be replaced by the import from utils/adminAccount. Happy to push
that as a follow-up commit here once #130 lands, or to fold it into whichever
of the two merges second.

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.

[Security]: A signed-in user cannot change their password, and there is no account screen — the only path is the emailed reset code

1 participant