Skip to content

Account tiers: verify email, onboarding, request upload access, admin approve - #306

Merged
neuromechanist merged 13 commits into
stagingfrom
feature/issue-301-account-tiers
Sep 6, 2026
Merged

Account tiers: verify email, onboarding, request upload access, admin approve#306
neuromechanist merged 13 commits into
stagingfrom
feature/issue-301-account-tiers

Conversation

@neuromechanist

Copy link
Copy Markdown
Contributor

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

pending stopped 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.

  • unverified (status === "pending") — /dashboard and /upload render a verify-your-email step and nothing else. Not a banner on top of a dataset list: an unverified session cannot pass authMiddleware, so every call underneath would 403. Withholding them is honest and cheaper.
  • base ("active", no grant) — a working account, said as such. The dashboard carries a request-upload-access card; /upload carries the CTA instead of the form.
  • upload (service_access === true) — the dropzone ships.

The dropzone gates on service_access alone. ADR 0011's block branch (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 own profile_incomplete refusal. ADR 0011's warn branch survives unchanged, for its original reason. uploadGate was deleted rather than left with a dead branch.

/onboarding is self-gating. It resolves what is outstanding — username (prefilled from GET /auth/profile/username-suggestion, live-validated against the backend's own rule, username_taken handled at the field), name, city/country — and redirects to next when 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, because PATCH /auth/profile would 409 name_is_orcid_canonical.

Settings gains a username field (editable until the grant, with the backend's username_locked still 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 its missing array as links straight at the fields that need filling.

Admin users defaults to ?awaiting_approval=1 instead of status=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 calling POST /admin/approve/by-id/:id. The username-keyed approveUser client 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/ under resolveDocsBase().

Contract fields I had to assume

Read read-only off the epic branch; four gaps, all handled as "unknown, never as no":

Field State What the site does
username on /auth/me Not there. publicUser in auth-web.ts does not select it. Read from GET /users/me, which does carry it (shared/contract/user.ts) and takes the same cookie. parseAuthMeResponse also reads it opportunistically, so it lands for free if /auth/me grows it. A failed lookup answers undefined, which is deliberately not null: "could not ask" must not raise a prompt to choose a handle the account may already have.
email_verified on /auth/me Present (phase 2). Parsed boolean-only. Not used to derive the tier — both roads out of pending set it, so an absent flag is an older backend, not an unproved inbox.
service_access_granted_at on /auth/me Not there (admin rows only). Settings renders the undated "Granted".
upload_access_requested_at on /auth/me Not there (admin rows only). Settings shows "Not requested" until the request endpoint's own 200 already_requested / 409 already_approved corrects it in the session that asked. Worth adding upstream — a request made in another session is invisible until then.

Also assumed: GET /admin/users/:username returns description (the why text) and upload_access_requested_at, since it selects u.*.

Testing

bun run test1829 passed / 76 files, up from 1799 on staging. bun run typecheck clean (0 errors). bunx biome check src test clean. bun run build clean.

New tests, all real-shape and no mocks — bodies transcribed from the nemar-cli route handlers, fetches driven by a fetch returning a real Response (the imports-admin-api.test.ts pattern):

  • src/lib/account-tier.test.ts (52) — tier derivation, upload-access state, the upload-page state machine, onboarding steps, username/why validation, the missing→links mapping, and every error-code mapper.
  • src/lib/account-api.test.ts — refusal parsing (including attempts_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, and approveUserById including the two-sentence refusal.
  • test/account-tiers-ui.test.ts — source-level guards (the repo's signin-notice.test.ts pattern): no surface says "under admin review" or mentions sandbox again, the dropzone stays wrapped in showsUploadForm, and every missing-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 /upload gate back to !== "verify_email", dropping service_access from the awaiting predicate, and collapsing the identity lookup's undefined into null. Each failed the expected tests and only those.

Both themes and narrow widths: every new surface uses tokens.css variables only, the tier pills follow the existing badge--* colour-mix treatment, and the verify step, onboarding form and upload-access card each have a max-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.test fixture (staging echoes dev_code), the request flow with a deliberately incomplete profile to see the missing links, and the admin queue against a seeded request. Local dev covers the shapes: @nemar.pending, @nemar.base, @nemar.asked and @nemar.new personas render the unverified, base, requested and onboarding states without a backend.

Open question

nemar-cli shared/contract/identity.ts now marks orcid_already_linked a deprecated alias for orcid_in_use and points at website#305 for the site treating one code. Settings still switches on orcid_already_have / orcid_linked_other and is untouched here — that belongs in #305, not this PR.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 6, 2026

Copy link
Copy Markdown

Deploying nemar-website with  Cloudflare Pages  Cloudflare Pages

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

View logs

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.
@neuromechanist
neuromechanist merged commit 48307a0 into staging Sep 6, 2026
5 checks passed
@neuromechanist
neuromechanist deleted the feature/issue-301-account-tiers branch September 6, 2026 02:47
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.

1 participant