Implement the account settings page (task 97) #77
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/profile-settings-page"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Builds task 97 on the existing
/accountpage (task 56's placeholder named this ticket explicitly) rather than a floating panel — see ADR-0069 for the argument, since it departs from every panel-hosted UI ticket since ADR-0062.Scope: view/edit display name and email, change password, link an external sign-in provider (OIDC).
The three consequences the task calls out are surfaced, not left to discover:
PlaceMarkAuthenticationStateProvider.SignOutLocallyAsync(no redundantPOST /api/auth/logout, since ADR-0056 already revoked server-side) and sends the browser to/login?notice=password-changed, which renders an explanation rather than leaving the next request to fail with a bare 401.ApiProblemException.StatusCode/Errors, never by matchingDetailtext (task 155).OIDC linking is built, not deferred again — task 56's first deferral pointed at a ticket that was later closed unpicked.
Accountnow has a "Link an external sign-in" button usingBeginOidcLinkAsyncand the existing challenge/redeem machinery;OidcComplete's link-mode branch (a204with no tokens) now returns the visitor to/account?linked=trueinstead of the placeholder "Nothing further to do." error.Also: removed
NavMenu's separate "Settings" placeholder, now redundant with "Your account" pointing at the same content.Not verified in a browser (task 156): whether the notices read clearly, and layout on a narrow viewport. bUnit proves pre-population, warning-before-action ordering, the sign-out path, the 409/validation distinction, and every field's
aria-describedbypairing.Full CI sequence (build, test with Testcontainers,
dotnet format --verify-no-changes) run locally and clean.Verdict: changes needed
Build,
dotnet test(WebUI + Architecture), anddotnet format --verify-no-changesall clean on the pinned 10.0.100 SDK. Mutated a validate-on-blur handler intoAccount.razor/.csto confirmFieldErrorsValidatesOnSubmitOnlyTestsactually reddens against this new consumer (it does), then reverted. ADR numbering is fine: PR #76 claims 0068, this claims 0069, no collision, README rows both append cleanly againstmain.Traced the link redemption end to end (no browser available, task 156):
StartOidcLinkAsynccallsBeginOidcLinkAsync/RedeemOidcAsyncthroughPlaceMarkApiClient, which attaches the caller's bearer token viaBearerTokenHandleron every request, so step 4 is authenticated as the signed-in caller as ADR-0032 requires.OidcEndpoints.CompleteLinkAsyncrefuses unlesscallerUserId == payload.LinkUserId, andOidcEndpointsTests.Redeem_LinkHandleWasMintedForADifferentAccount_RefusesTheLinkalready covers the CSRF case server-side — nothing in this PR touches that. A failed, replayed or stale redemption throwsApiExceptioninsideOidcComplete.OnInitializedAsyncand never reaches the?linked=trueredirect, so that path is sound.One real problem:
Account.razor's "linked" banner trusts the query parameter outright, not server state.Linkedis[SupplyParameterFromQuery]and the markup is simply@if (Linked is not null)— anyone who types, bookmarks, or is sent/account?linked=truesees "This sign-in provider is now linked to your account" with zero connection to any actual link event. This isn't a hypothetical:AccountTests.Account_LinkedQueryParameterPresent_ShowsASuccessMessageproves it by navigating straight to?linked=truewith no OIDC flow involved at all. CompareOidcComplete, which strips its owncodeparameter viaNavigateTo(..., replace: true)once consumed —Accountnever does the equivalent forlinked, so the banner also persists across every reload of that URL, not just the one visit after a genuine link. There's no endpoint that exposes actual linked-provider state to re-derive this from, so the minimal fix is to stop it being replayable: striplinkedfrom the URL immediately after reading it (same patternOidcCompletealready uses forcode), and ideally haveAccountconsume it once via a local flag rather than re-readingLinkedon every render. Given the reviewing brief calls this out by name as a "misleading security signal," it should be fixed or the residual explicitly argued in ADR-0069 before merge, not left silent.Everything else checks out: warnings for both email-changes-sign-in and password-revokes-everything sit ahead of their submit buttons (asserted by markup order, not visually);
SignOutLocallyAsyncskippingPOST /api/auth/logoutis sound becausePutMyPasswordawaitsRevokeAllForUserAsyncbefore returning204, so any partial failure surfaces as an API error the WebUI's catch block handles without ever callingSignOutLocallyAsyncor navigating away — confirmed by readingUserEndpoints.PutMyPassworddirectly.Login'snoticeparameter is a closed vocabulary lookup (_noticesdictionary), not rendered free text, so it can't be used to inject arbitrary copy. The 409/validation distinction usesApiProblemException.StatusCode, matchingGroupForm's existing pattern.NavMenustill reaches settings via "Your account". ADR-0069's page-over-panel argument is substantive rather than asserted — it's grounded in "nothing here is about the map or a group" and the reversibility cost is stated, consistent with ADR-0062/0065/0067's own reasoning rather than contradicting it.Cannot verify without a browser (task 156, as the PR itself says): whether either notice actually reads clearly, whether the linked/password-changed confirmations are noticed on return to their pages, and layout on a narrow viewport. Also not exercised: an actual live round trip through a real OIDC provider — verified instead by static tracing plus the existing (unchanged) API-level tests for the link CSRF case.
Verdict: mergeable
Re-reviewed the delta (
f1f27c5→17094a8) in a fresh scratch worktree, pinned SDK (10.0.100). Full WebUI suite (356 passed), Architecture suite (27 passed), anddotnet format --verify-no-changesall clean.Checked each point:
/account?linked=truenow shows nothing. Confirmed byAccount_LinkedQueryParameterWithNoSessionStorageConfirmation_ShowsNoSuccessMessage(ran, passes) and by reading:_justLinkedis only true whensessionStorageactually heldOidcSessionStorageKeys.LinkConfirmed, which nothing butOidcComplete's own post-204 write ever sets.RemoveItemAsyncruns unconditionally, immediately after the read, before_justLinkedis even assigned — so a second load of the page finds nothing regardless of the first outcome. The shipped test proves the banner appears once and the query string is gone; it doesn't separately render a second time to prove the marker is gone too, but that follows directly from the unconditional removal and needs no further test.LinkConfirmed— grepped; onlyOidcCompletewrites it and onlyAccountreads/clears it. It remains forgeable by same-origin script (anyone with devtools on their own session), which is fine since nothing keys on it but display — but the doc comment onOidcSessionStorageKeys.LinkConfirmedcalls it "proof," which overstates that. Minor wording fix, not blocking: say it rules out a hand-typed/replayed URL specifically, not that it's authoritative.GetItemAsync/RemoveItemAsynchave no try/catch, so a throw from the read would skip the removal and also skip the profile load beneath it inOnInitializedAsync. This is the same unguarded shape already used forVerifier/ReturnUrlthroughout this file andOidcComplete, not a new gap introduced by this fix, so not blocking here — but it means asessionStoragefailure now takes the whole page down where before this change it didn't touchOnInitializedAsyncat all. Worth a wrapping ticket if it's ever seen in practice, not this PR's problem to solve.confirmedwas set. A failed/replayed/stale redemption never sets the query parameter in the first place, so there's nothing to strip on that path. UsesGetUriWithQueryParameter("linked", null), which removes only that parameter — actually more correct thanOidcComplete's ownGetLeftPart(UriPartial.Path)forcode, which wipes the whole query string; moot today since/accounttakes no other parameters, but worth knowing if one is ever added.UserResponsehas never carried that. Worth a follow-up ticket for "show current linked-provider state on account settings" — please file it.Unrelated to this delta: the PR now reports
mergeable: falseagainst currentmain— a textual conflict indocs/adr/README.md's index table (adjacent rows for 0067/0068/0069), not a numbering collision; 0068 and 0069 are still distinct. Needs a rebase before merge, not a code change.Verdict: mergeable
Clean. Reviewed the delta only (
88a2a7fsoftening commit plus the merge ofmain), pinned SDK, build/WebUI-tests(374 passed)/format all clean,mergeable: trueconfirmed via the PR API.All four locations —
OidcSessionStorageKeys.LinkConfirmed's doc comment,Account.Linked's doc comment, the write-site comment inOidcComplete, and ADR-0069's new paragraph — now agree: display cue, not a security control, forgeable by same-origin console, closes only the hand-typed/replayed-URL case. Grepped for "proof"/"evidence" across all four; the one hit left isOidcSessionStorageKeys.cs's own "not evidence that one did", which is the negation, correctly stated.ADR-0069 states plainly this came out of review: "The query string alone is not trusted — caught in review, not written correctly the first time," matching the house style already used in ADR-0058/0062/0067 for the same kind of correction, not presented as original intent.
mainmerge touched onlydocs/adr/README.md's table: 0067 → 0068 → 0069 in order, PR #76'sAccepted (partly superseded by 0068)annotation on 0067 intact, no conflict markers left anywhere. The merge commit's diff from its first parent matches PR #76's own file list exactly (18 files) — nothing else came along for the ride.