Account tiers: verify email, onboarding, request upload access, admin approve - #306
Merged
Merged
Conversation
Three tiers derived in one module (unverified / base / upload), plus the client for email verification, the upload-access request, the username suggestion, and the /users/me identity lookup that resolves a username /auth/me does not carry. AuthUser gains username, email_verified, service_access_granted_at and upload_access_requested_at, all optional and parsed opportunistically: only email_verified is on /auth/me today.
Two same-origin proxies (the backend Origin-allow-lists both) plus the component the dashboard and /upload mount for an unverified account. Handles code_expired, code_incorrect with attempts remaining, the 429 and verification_incomplete; a spent code sends the user back to request a new one rather than into a second guaranteed failure.
Pending accounts get the verify step instead of the under-admin-review copy; base-tier accounts get a request-upload-access CTA, never a sandbox mention. The dropzone gates on service_access alone, so uploadGate's block branch is gone and deriveUploadPageState owns the decision.
Self-gating /onboarding: username (prefilled from the suggestion endpoint, live-validated, 409 handled at the field), name (editable only without a verified ORCID), and city/country. Redirects onward when nothing is outstanding, so sign-in can route through it unconditionally. Welcome and the legacy /login/pending page lose the approval-queue copy.
Username editable until an admin approves; name fields shown only without a verified ORCID iD, with the canonical note otherwise. New upload-access section with the three states and the request flow, whose refusals render the backend's missing list as links to the fields that need filling.
Default chip is backed by ?awaiting_approval=1 rather than status=verified, which is now the whole base tier. Each open request gets a review card (name, username, email plus verified state, ORCID, GitHub, city, country, affiliation, why text, requested date) and an Approve calling /admin/approve/by-id/:id, whose email_verified=0 refusal is rendered in full. Adds a tier column; revoke still clears upload access.
Marks the auto-approve half of ADR 0010 superseded by nemar-cli ADR 0040, and ADR 0011's block branch superseded by 0014. Neither is deleted.
Deploying nemar-website with
|
| Latest commit: |
796116f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b29303a5.nemar-website.pages.dev |
| Branch Preview URL: | https://feature-issue-301-account-ti.nemar-website.pages.dev |
A verified iD whose record publishes no name got missing: [given_name, family_name] from the request endpoint, linking two Settings inputs that only render WITHOUT a verified iD -- and /onboarding skips the name step for it and self-gates away. Settings now carries the explanation and both exits (re-link as a real POST; a link to the ORCID card for unlink, since duplicating that button would break its querySelector handler). The name links point at the row, which always renders.
canApproveUser, adminActionErrorText and approveErrorText were inline in the two admin surfaces, so nothing could reach them: dropping the signup_source guard -- which offers Approve on every unverified CLI signup for the backend to refuse -- passed all 46 tests. loadReviewDetails takes the bounded, fail-soft detail fan-out out of the page for the same reason.
status and service_access arrive as separate fields, so a stale row can carry a grant beside a disabled status; deriveAccountTier answered upload for it. Base, not a fourth tier: nothing the site renders differs between disabled and signed-in-without-a-grant. deriveUploadAccessState keys on the derived tier so Settings cannot say Granted at the same time.
Unit tests for canApproveUser, loadReviewDetails' bound and fail-soft, the two error renderers, and the four optional /auth/me fields. Source guards for the onboardingSteps/needsOnboarding call-site wiring field by field, welcome's tier CTA, onboarding's read-only name branch, the status badge labels and the review card's tri-state. /onboarding added to the app-route list. Five mutations checked and reverted.
neuromechanist
added a commit
that referenced
this pull request
Sep 6, 2026
website#306 makes #account-name the canonical anchor: the given/family inputs render only for an account with no verified ORCID iD, and owner_name_missing reaches the other kind too. Inert until #306 lands, which is the safe direction.
neuromechanist
added a commit
that referenced
this pull request
Sep 6, 2026
… validation failure (#307) * Add the publication block-reason vocabulary Copied from nemar-cli shared/contract/publication.ts (the repos share no package) with the source named in the header. Maps each reason to a badge state: owner_name_missing is an account property, not a dataset one, so it gets its own label; an unrecognised free-TEXT value degrades to a neutral Blocked rather than claiming a cause. * Badge a block by its reason, not as validation derivePublishState and deriveAdminBadgeState consult block_reason, so owner_name_missing reads Name required and an unknown reason reads Blocked. A block whose fix is in the account is re-requestable: nothing about the files changed and the backend's own message ends 'and re-request publication', so withholding the button was a dead end. * Show the block reason on the owner's card Renders the backend's message, which is the only place the fix is spelled out, with a Settings link for an account-shaped block and the CI link when one exists. Falls back to the raw code for a backend that sends no message. * Split pending validation out of Validation failed bids_validation_pending and bids_validation_in_progress get their own badge state. Both backend messages say to wait for CI, so the failed label contradicted the sentence printed directly under it. The three reasons where a gate actually failed keep Validation failed. * Explain a block with no reason on the wire The panel was gated on block_reason, so a blocked row whose reason is null (one predating migration 0015, or a payload that omitted it) rendered the Blocked badge with no text under it -- the exact failure this change exists to fix, and a case blockBadgeState already handles. Gated on the blocked status now, with a third fallback line that quotes nothing. * Link the Name row, not the given-name input website#306 makes #account-name the canonical anchor: the given/family inputs render only for an account with no verified ORCID iD, and owner_name_missing reaches the other kind too. Inert until #306 lands, which is the safe direction.
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #301
Companion to nemarOrg/nemar-cli#1250 phases 1-3 (PRs #1258, #1262, #1265). The endpoints are on that epic branch and not deployed yet, so every new surface degrades rather than breaking against today's
api-test.Summary
pendingstopped meaning "an admin is reviewing you" (nemar-cli ADR 0040) and the website had no vocabulary for what it means instead. Five surfaces said an account was under admin review, naming a next step nobody was going to take: an ORCID sign-up lands unverified with an emailed code waiting for it, and no admin is queued behind that. Approval still exists — it grants upload access — but there was no way to ask for it from the browser at all.Three tiers, derived once in
src/lib/account-tier.ts, each with something to do.status === "pending") —/dashboardand/uploadrender a verify-your-email step and nothing else. Not a banner on top of a dataset list: an unverified session cannot passauthMiddleware, so every call underneath would 403. Withholding them is honest and cheaper."active", no grant) — a working account, said as such. The dashboard carries a request-upload-access card;/uploadcarries the CTA instead of the form.service_access === true) — the dropzone ships.The dropzone gates on
service_accessalone. ADR 0011'sblockbranch (withhold the form from an ungranted account with an incomplete profile) is gone: an ungranted account cannot reach the form at all now, so asking a subset of them for city/country answered a question nobody had reached. City and country moved to where they are actually enforced —/onboarding, and the request endpoint's ownprofile_incompleterefusal. ADR 0011'swarnbranch survives unchanged, for its original reason.uploadGatewas deleted rather than left with a dead branch./onboardingis self-gating. It resolves what is outstanding — username (prefilled fromGET /auth/profile/username-suggestion, live-validated against the backend's own rule,username_takenhandled at the field), name, city/country — and redirects tonextwhen nothing is. Sign-in routes through it unconditionally rather than each caller re-deriving a condition it cannot see. The name step is skipped, never blocked, when a verified ORCID iD owns it; when the ORCID record publishes no name, the page explains rather than asking, becausePATCH /auth/profilewould 409name_is_orcid_canonical.Settings gains a username field (editable until the grant, with the backend's
username_lockedstill rendered if the session flag is stale), name fields shown only without a verified iD, and an Upload access card with the three states and the request flow. A refused request renders itsmissingarray as links straight at the fields that need filling.Admin users defaults to
?awaiting_approval=1instead ofstatus=verified— which since ADR 0040 is the entire base tier, i.e. a permanent three-figure badge with nothing actionable behind it. Each open request gets a review card (name, username, email plus verified state, ORCID, GitHub, city, country, affiliation, why text, requested date) and an Approve callingPOST /admin/approve/by-id/:id. The username-keyedapproveUserclient was deleted rather than kept as a second, weaker option that cannot address a web account.Docs links use
/web/account-settings/and/web/upload-access/underresolveDocsBase().Contract fields I had to assume
Read read-only off the epic branch; four gaps, all handled as "unknown, never as no":
usernameon/auth/mepublicUserinauth-web.tsdoes not select it.GET /users/me, which does carry it (shared/contract/user.ts) and takes the same cookie.parseAuthMeResponsealso reads it opportunistically, so it lands for free if/auth/megrows it. A failed lookup answersundefined, which is deliberately notnull: "could not ask" must not raise a prompt to choose a handle the account may already have.email_verifiedon/auth/mependingset it, so an absent flag is an older backend, not an unproved inbox.service_access_granted_aton/auth/meupload_access_requested_aton/auth/me200 already_requested/409 already_approvedcorrects it in the session that asked. Worth adding upstream — a request made in another session is invisible until then.Also assumed:
GET /admin/users/:usernamereturnsdescription(the why text) andupload_access_requested_at, since it selectsu.*.Testing
bun run test— 1829 passed / 76 files, up from 1799 onstaging.bun run typecheckclean (0 errors).bunx biome check src testclean.bun run buildclean.New tests, all real-shape and no mocks — bodies transcribed from the nemar-cli route handlers, fetches driven by a
fetchreturning a realResponse(theimports-admin-api.test.tspattern):src/lib/account-tier.test.ts(52) — tier derivation, upload-access state, the upload-page state machine, onboarding steps, username/why validation, themissing→links mapping, and every error-code mapper.src/lib/account-api.test.ts— refusal parsing (includingattempts_remaining: 0, which a truthiness check would drop), the 201/200/409 outcomes, and the tri-state identity lookup.src/lib/users-admin-api.test.ts— extended in place:adminUsersQuery,adminTier's third "unknown" state,isAwaitingUploadApproval,adminActionMessage, andapproveUserByIdincluding the two-sentence refusal.test/account-tiers-ui.test.ts— source-level guards (the repo'ssignin-notice.test.tspattern): no surface says "under admin review" or mentions sandbox again, the dropzone stays wrapped inshowsUploadForm, and everymissing-field link resolves to an id Settings actually carries.Four assertions were mutation-checked one at a time and reverted between: opening the form for a complete base-tier profile, widening the
/uploadgate back to!== "verify_email", droppingservice_accessfrom the awaiting predicate, and collapsing the identity lookup'sundefinedintonull. Each failed the expected tests and only those.Both themes and narrow widths: every new surface uses
tokens.cssvariables only, the tier pills follow the existingbadge--*colour-mix treatment, and the verify step, onboarding form and upload-access card each have amax-width: 480px/ single-column fallback.Not verified here
The endpoints are unmerged, so nothing was exercised against a live backend. Once nemar-cli#1250 reaches
api-test, the flows to walk on test.nemar.org are: verify-email on a@nemar.testfixture (staging echoesdev_code), the request flow with a deliberately incomplete profile to see themissinglinks, and the admin queue against a seeded request. Local dev covers the shapes:@nemar.pending,@nemar.base,@nemar.askedand@nemar.newpersonas render the unverified, base, requested and onboarding states without a backend.Open question
nemar-cli
shared/contract/identity.tsnow marksorcid_already_linkeda deprecated alias fororcid_in_useand points at website#305 for the site treating one code. Settings still switches onorcid_already_have/orcid_linked_otherand is untouched here — that belongs in #305, not this PR.