10c0ca4cf73d396aefddeeaced042d616c5c34a2
24
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
10c0ca4cf7 |
feat(email): write to people in their own language (E9.5 S7)
Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.
The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.
Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.
Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.
That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.
S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.
Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.
Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
|
||
|
|
a76bab9b34 |
feat(auth): one mail, one click — the approval mail carries the confirmation link (E9.5 S6)
The funnel's common ordering made a person open TWO messages: one saying their access was approved, another asking them to confirm their address. The approval mail now branches on what they actually still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse than a plain one: telling someone to sign in while their address is unconfirmed contradicts the screen that will refuse them. That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval — it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in and sets no password. The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here too, and it was never a delivery failure: re-registering an address that already had an unconfirmed account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend already does exactly this for the same input. Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a possibility — usually already agreed with the person — and a row can sit approved for weeks with nobody registering; a mail fired automatically there would be nudging someone who never asked. Telling them is its own act on its own button, `invited_at` records it so a second click is a decision rather than an accident, and the button only appears where nobody is behind the address yet: "the door is open, come in" is meaningless to someone already inside. Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may sign in, and this account still can't", which leaves an undecided request with the queue that owns it. And the invitation button was offered on every approved row, half of which are existing users. Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail always has, while this one left the reader to discover both by meeting a dead link. The card's "didn't get the email?" moves up under the form, and the three waiting messages name the spam folder, which is where a first message to a fresh recipient sometimes lands. Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a preference nobody recorded cannot be backfilled later. UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed nobody; the invitation recorded itself and the row then warned that sending again would write twice. Test data removed. |
||
|
|
7acdf5d577 |
feat(admin): surface the sign-ups that are stuck outside, and let an admin finish letting them in (E9.5 S5)
Approve an address BEFORE the person registers and their sign-up produces no queue row at all — `_decide` only takes a still-pending invite — so the Access-requests screen sat empty, the tab badge read zero, and the only working move was re-typing the address into the whitelist box. Nobody would guess that, and on 2026-09-03 nobody did. This is roadmap UX-25, where two earlier attempts were reverted after review. Both moved AUTHORITY: auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval. What was actually missing was visibility, so this changes nothing about what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the address; they just had no way to know they had to. The section lists every account that cannot sign in yet and says WHICH gate is shut, because the two dead ends look the same from the admin's chair: waiting for activation, or waiting on a confirmation mail. Naming the unconfirmed ones is not decoration — on a no-SMTP instance that is the difference between the owner and whoever registered the address first (SEC-15), and an admin about to let someone in should see it. A person the queue above already covers is deliberately NOT listed here; two buttons for one person is worse than one. Activate is gated on `is_allowed` — the same function the sign-in paths consult, in core, so the button that appears and the badge that counts cannot disagree. Without that gate it would manufacture an inconsistent account: password sign-in only checks `is_active` and would let them in, while their Google sign-in would be turned away and silently reopened as a fresh access request. Letting in someone with no approved invite stays the whitelist box's job, which says so out loud. The badge counts activations SEPARATELY from pending invites rather than widening an existing field's meaning, and only counts what genuinely waits on the admin: an unconfirmed account waits on its owner, so it is listed with a re-send but does not nag. The admin-side resend says plainly what happened, including refusing when the instance has no working SMTP — mail is fail-soft everywhere else, and that silence is exactly what left the 0.78.5 sign-ups with nothing to fall back on. `send_verification_link` is now one function with three callers (registration, the public resend, this one), so the link's shape and TTL cannot drift by who triggered it. UAT on localdev, both languages: the UX-25 shape reproduced end to end (approve the address, then register), the badge counting it, Activate letting them in and dropping the badge, the row losing its Activate but keeping the re-send, a fresh token issued by the re-send, and both actions in the audit log. Test data removed. |
||
|
|
b1086e05dc |
fix(auth): stop drawing a reset form the link can no longer fill (E9.5 S4)
Reported from UAT: delete an account, then open its password-reset link. The card drew the full "set
a new password" form — because a `#reset=` fragment was present, and nothing more — and only after
the user had typed a password did the server answer 400. Measured first: no security hole. A user's
tokens cascade away with the row, so nothing can be set on a deleted or a re-created account. What
was left is a dead end of exactly the kind this epic exists to remove, and it is not rare: a reset
link lives one hour, mail is slow or lands in spam, and whoever got two mails clicks the older one.
password-reset/check answers whether a link is still usable WITHOUT spending it, so asking can never
cost the user their one real attempt — the property the tests pin hardest. It gets its own limiter,
because opening a link must not eat the budget for requesting one, and a throttled caller is told
"ok", never "dead": this check may only ever save someone a wasted form, so when it cannot answer it
degrades to the old behaviour instead of declaring a live link finished. The confirm endpoint stays
the authority, and its failure raises the same named screen, since the token can die between the two
calls or the probe itself may have failed.
A too-short password is deliberately NOT that screen. The length check runs before the token is
spent, so a typo costs the link nothing, and the card tells the two 400s apart by `reason` so only
one of them takes the form down.
Both email-link confirmations (verify, password-reset/confirm) now answer in the {code, reason}
shape S4 introduced. They were the last refusals a user meets by simply clicking an old link while
still being answered in hardcoded English under a bilingual UI. The seven remaining bare strings in
this module (register, request-access, the demo guards, set-password) sit behind client-side
validation the UI does first; they are logged as a follow-up rather than swept in silently here.
One bug found by the UAT itself, in the new code: the check went out FOUR times per opened link —
StrictMode double-invokes effects and the shell remounts the card as the session probe settles.
Harmless per request, but against a per-IP limit it would exhaust the budget in two reloads and then
silently fall back to drawing the form unchecked: nothing errors, the feature just stops working.
Deduped in a module-level map holding the in-flight promise (a ref dies with the remount that causes
it); a failed probe drops itself so a blip is never cached as "dead". The test renders the card
twice, because with one render the guard would rot undetected.
UAT on localdev: the reported scenario end to end (delete the account, open the link) shows the named
screen and no form; a live link still opens the form and completes; the CTA lands on the request-a-
link form; one probe per opened link. Throwaway accounts removed.
|
||
|
|
ed91c83dd7 |
fix(auth): name the state and the address behind a refused sign-in (E9.5 S4)
"Your account is awaiting admin approval." was true and useless. It never said WHICH mailbox the account was on, and on 2026-09-03 that silence hid a Gmail alias mismatch for a day: a registration as np.temu@gmail.com met a Google claim of nptemu@gmail.com, and every screen in the funnel agreed to say nothing about it. Every refusal password-login can answer with now uses the codebase's structured {code, reason} detail, built in one helper. The three that arrive AFTER the password is proven carry the address on the account plus both gate flags; the two that answer before it (invalid, rate_limited) carry nothing, so a wrong password and an unknown account stay byte-identical. The frontend renders the first three as a named screen in place of the form — there is nothing to retype once the password is correct — with an icon, the two-gate checklist, the address in monospace, and a resend CTA where one helps. Reporting each gate from its own flag is the payoff of S1: approved-but-unverified is a real state now, and a screen that assumed a sequence would send that user off to wait for an admin who has already decided. The Google access-request notice names the address too, and says so when the request was already on file under ANOTHER spelling of the same mailbox — the honest version of "we didn't open a second one", stated only when it applies. It moved from the query string to the fragment: the notice now carries an address, and a query string lands in every reverse-proxy access log. Same reasoning that put the verify/reset tokens there, and the same owner already clears it. urlencode, not an f-string, because URLSearchParams reads a bare + in a fragment as a space and would mangle exactly the +tag addresses this epic exists to protect. upsert_pending_invite returns the row it settled on beside the is-new flag, since the row's stored address is what the notice has to name and what mail will go to. Deviation from the PendingScreen mockup, deliberately: no "Sign out" button and no separate "Not your address?" link. The mockup drew the panel in the app shell; the accepted decision (a) keeps the pending user session-less, so there is no session to end, and the address is named directly above a "Back to sign in" that answers the same question. UAT on localdev, both languages: all three refusals with the typed spelling deliberately differing from the stored one (the panel named the stored one), the resend CTA carrying the typed address into the resend form, and both access-request banners with a +tag address surviving the fragment round trip. Throwaway accounts and the language switch reverted. |
||
|
|
a614ee2435 |
feat(auth): show the Google address beside the one the account registered with (E9.5 S3)
An account can now hold two true addresses: the one its owner typed, which a Google sign-in no longer overwrites, and the one Google answers with. Only the first was stored, so Settings could say 'Connected' without saying connected AS WHOM -- exactly what someone who registered with a +tag needs to know. users.google_email (migration 0080) records Google's answer at sign-in and at an in-app link. /api/me exposes it ONLY when it differs from the account address, so the comparison lives in one place and the majority, whose two addresses are the same string, see no change at all. Rendered in the account menu as a muted third line under the identity, and in the Settings sign-in row under the existing hint. A short 'Google' label rather than a brand glyph: the app draws one icon set (lucide), and a generic mail icon next to an email address says nothing. It also reads to a screen reader, which an unlabelled logo would not. No backfill is possible -- Google's answer is only known at sign-in -- so accounts linked earlier stay NULL until their owner next signs in with Google. |
||
|
|
c74487fbb3 |
fix(auth): keep the address the user registered with, and let it sign them in (E9.5 S3)
Two halves of one rule: the address someone typed is theirs to keep, and it must be the one that gets them back in. A plus-tagged or dotted Gmail is often a deliberate choice -- registering as me+siftlode@gmail.com is how you find out later which site leaked your address. The Google callback overwrote it with the account's primary, which can never contain a '+', so the tag vanished on the first Google sign-in and, because sign-in matched the stored address, took their chosen address with it. The rewrite line is older than this epic, but S3 made it reachable here by matching adoption across spellings. It now runs only when the mailbox GENUINELY changed, and lives in a named helper rather than inline, so the rule has one home. The three lookups that resolve a typed address -- password login, password reset, verify resend -- go through find_user_by_email: exact spelling first, then the unique canonical candidate, and None when several accounts share a mailbox rather than guessing which one a password should open. Reset and resend previously answered a different spelling with silence (they reply uniformly by design), which was a dead end with no way out. Their mail now goes to the address on the account, never the one typed into the form. |
||
|
|
876cece881 |
fix(auth): match identity on a canonical email, so one Gmail mailbox is one account (E9.5 S3)
Gmail delivers x.y@, xy@ and xy+tag@ to one inbox; the invite gate only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent SECOND access request -- OAuth returns the account's stored primary, which need not be the spelling they typed -- and left prod with two user rows for one mailbox (ids 13, 14). The +tag case fails every time: a Google account cannot contain +, so OAuth can only ever answer with the base address. Mail still goes to the address AS TYPED; only identity matching uses the canonical form. utils.canonical_email is the single definition, and a model validator derives users.email_canonical / invites.email_canonical on every write, so no call site can forget it. Scope is narrow on purpose: dots are significant on most providers and the Gmail rule does not hold for Workspace custom domains. Swept every sibling of the same principle, not just the reported one: is_allowed, upsert_pending_invite, the Google-callback adoption, _register_account (so no new duplicate pair can be created), _activate_user_by_email, admin _accounts_for (an invite backing a live user under another spelling used to look deletable) and add_invite, plus purge_user, which now erases every canonically-equal invite instead of leaving one behind that still grants access. Migration 0079 backfills both columns and indexes them NON-uniquely: prod already carries canonical duplicates (approved invite pairs since 2026-06-16), and merging accounts is a judgement call about people's data, not something a migration does on its own. A test pins the backfill SQL against the Python helper. |
||
|
|
c134b514c1 |
fix(auth): confirm an emailed verification link wherever it is opened (E9.5 S2)
The token was only ever consumed inside Welcome.tsx, which App renders ONLY when there is no session. Opening the emailed link in a browser that already held a Siftlode session posted nothing, the fragment was then stripped, and the user got no signal at all -- the link simply did nothing. Incognito worked, which is why it survived. Confirmation now happens in App, above that branch: signed out the outcome still drives the auth card's banners, signed in it drives a top-of-app bar. The entry URL is snapshotted once at import time (lib/entryUrl) instead of per component, so no future URL writer can strip a token before whoever needs it has read it -- that ordering race is what broke this in the first place. POST /auth/verify now names the address it confirmed, which matters when the browser holds a session for a different account; the same reasoning as password_reset_required already covers the disclosure. Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both senders emit the fragment form, and the last query-token link expired 24h after the 0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and the auth-card banners get role=status. The verifyOkReset copy no longer blames admin approval, which E9.5 S1 made false. |
||
|
|
243869401a |
fix(auth): stop discarding a legitimate password on admin approval (E9.5 S1)
SEC-10 revoked any password set while an account's email was unverified, on both the Google-adoption and the admin-approval path. On the approval path that was redundant: password_login refuses an unverified account before it ever reads is_active, so approval hands a pre-emptor's password nothing it could use. All the revoke achieved there was destroying the password a legitimate registrant had just chosen -- and since the admin normally decides before the user reads their mail, 'your password was cleared for security' was the DEFAULT experience, not an edge case. The guard stays on the Google-adoption path, where the real owner does attest ownership. password_reset_required and the ok_reset banner stay: accounts stripped on prod before this change have no other way back in. Two existing tests pinned the old rule, so they are rewritten rather than deleted, and the SEC-10 recovery pack now builds the revoked state through the surviving producer. |
||
|
|
3ac3d382e7 |
fix(auth): stop the address-bar mirror eating emailed auth tokens, and unlock revoked accounts
Two independent bugs made an email+password registration unrecoverable in prod. The verification link did nothing. urlWithParams/clearParams rebuilt the URL as pathname+query and dropped the fragment — but the one-time auth tokens ride the fragment (#verify=/#reset=, SB3), and the providers that mirror nav params (useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded <App> in main.tsx. Their mount effect replaceState'd the token away before the auth card's chunk had loaded, so the card read an empty hash: the link opened a bare sign-in page, no request, no banner. Both helpers now carry the fragment over. The account was then locked out for good. SEC-10 nulls password_hash when an unverified account is attested (here: admin approval landing before the user could verify — which the bug above made impossible). That is indistinguishable from a Google-only account, and reset eligibility keyed off password_hash alone, so no reset link was ever issued: no password to sign in with, and no way to get one. users.password_revoked_at records the revocation, reset eligibility honours it, and setting a password again (reset-confirm or set-password) clears it. An account that never had a password stays ineligible on purpose — its owner adds one from Settings. /auth/verify now also answers password_reset_required so the card can say why the registered password stopped working, instead of leaving the user at the uniform "Invalid email or password". Telling the holder of a valid verify token leaks nothing: they have already proved they own the mailbox. The 0078 migration backfills the stamp for rows carrying the exact lockout signature (local, non-demo, no password, unverified), so existing victims recover unaided. |
||
|
|
cb1f4f00b0 |
docs(auth): correct the last round of over-claiming comments and notes
Round 5's correctness/security pass came back clean; what was left was text that said more than the code does. - The register-path notify comment (and its test) claimed the admin "is still told". The call fires, but on a no-SMTP instance _send drops it — which is the open half of the problem, not something this branch fixed. Both now say so. - The release notes tell admins what to actually do: an account whose address was whitelisted in advance has no pending request to approve, so the whitelist box is what activates it. That instruction only existed in the roadmap and in chores, which the dialog does not render. - "That one line is the whole production change" now accounts for the test-only RateLimiter.reset() that also ships. - The rate-limiter test claimed to pin that blocked attempts are not counted, on an assertion that held either way. Replaced with one that drives the clock across the window boundary and fails if hammering extends it. |
||
|
|
f6e1d1e69f |
fix(auth): keep the admin notice unconditional on the register path
Round 4 showed the `inv is not None` gate added in
|
||
|
|
b64c3f514d |
fix(auth): drop the invite reopen — keep the fix to the lost account alone
Round 3 of the review killed the reopen from
|
||
|
|
b5a1002412 |
fix(auth): reopen a pre-dated approval instead of auto-granting it
Replaces the activation added in
|
||
|
|
ea931dca30 |
fix(auth): honour an approval granted before sign-up instead of parking the account
Follow-up to the commit fix, from the deep-review. Persisting the account was only half the story: when the admin had approved the address BEFORE the person registered, the account landed is_active=False against an invite that is no longer pending — absent from the Access-requests queue, and approve_invite 409s on an already-decided invite. The sign-up worked and still could not sign in. Derive activation from is_allowed() at the moment ownership is attested: - verify_email_confirm activates a pre-approved account when the emailed link is confirmed (only the mailbox owner can follow it), so SEC-10 stays intact; - _register_account activates only when there is no SMTP to verify with, where the admin whitelist is by design the sole gate — the same one add_invite already activates on. Tests: the pre-approved no-SMTP case and the verify-activates case are real regression tests (both fail without this change); guards cover the unknown-email and denied->pending branches, verification of an unapproved account, and the first end-to-end coverage of the /auth/register route and its allow_registration gate. Release notes rewritten — the previous entry miscounted its regression tests and advertised an email fix this release has no code for. |
||
|
|
d954e71a77 |
fix(auth): commit the account on registration so a pre-approved sign-up isn't lost
_register_account() flushed the new User but relied on someone else to commit: upsert_pending_invite() commits only when it actually writes an invite row, and _issue_token() runs only when SMTP is enabled. For an email an admin had ALREADY approved, on an instance where email is unavailable, neither fired — the session closed and the account was rolled back. The would-be user got the uniform "ok" and no account, could not sign in, and re-registering was a silent no-op. Commit explicitly after the invite upsert (a no-op when upsert already committed the same transaction). Three DB-lane regression tests cover the pre-approved no-SMTP path (fails without the fix), the unknown-email path that always worked, and the pre-approved path with SMTP on. |
||
|
|
b1832afd18 |
style: apply ruff format across backend + scripts (line-length 100)
Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in .git-blame-ignore-revs (next commit) so git blame skips it. |
||
|
|
d37b6ef07f |
refactor(routes): delete 5 dead endpoints the frontend never calls (E3 S3, B-core-8)
None are referenced by the SPA or tests; the Scheduler dashboard's "run now"
covers every manual trigger. Removed: POST /api/sync/{rss,backfill,enrich}
(+ the now-dead _user_channels helper and three sync.runner imports),
POST /api/tags/recompute (+ its admin_user / run_autotag_all imports), and
GET /auth/me (a lesser duplicate of the canonical /api/me in routes/me.py).
This also retires a latent bug: POST /api/sync/backfill reset backfill_done /
backfill_cursor on already-fully-backfilled channels (tipping them back to
"needs full history") for up to 25 channels per curl. Full lane 365 pass.
|
||
|
|
0fab0ce779 |
refactor(auth): typed Pydantic request bodies for the 9 payload:dict handlers (E3 S3, B-core-6)
request-access / demo / register / verify / verify-resend / password-login / password-reset(request|confirm) / set-password each took an untyped `payload: dict` and hand-picked fields with `.get(...)`. Replace with five small BaseModels (EmailIn, EmailPasswordIn, TokenIn, TokenPasswordIn, SetPasswordIn) so FastAPI validates the body shape and publishes the schema. Fields are `str | None` to preserve exact behaviour — a missing/null field still coerces via the existing `(... or "")`, no stricter 422s. Green under the auth-session suite (7), ruff clean. |
||
|
|
0340d0981c |
fix(e1): address /code-review high findings on the E1 stack
Review round on the whole E1 stack (main..HEAD). 6 findings, all low/medium, none blocking; fixed for a clean gate-green handoff. - plex/sync (correctness): the un-enriched cast guard compared the stored cast against THIS listing's length, so a trimmed Plex cast on a not-yet-enriched row was never applied. Compare against the listing CAP (_LISTING_CAST_CAP=20) instead: >cap = enriched (keep, survives a watermark reset), <=cap = listing-sourced (track edits in both directions). +2 regression tests. - main (security): add X-Frame-Options: DENY as the legacy-browser fallback for the CSP frame-ancestors clickjacking guard. - downloads/service (altitude): finalize_if_running now uses a SQLAlchemy 2.0 update() construct instead of a hand-built text() UPDATE string — same atomic WHERE status='running', idiomatic, no identifier interpolation, no manual StrEnum coercion. Drops the now-unused text import. - auth/admin (reuse): extract the SEC-10 "drop untrusted preset password + bump epoch" invariant into one helper (revoke_preset_password_if_unverified) so the Google-callback and admin-activation paths can't drift. - tests/migrations (robustness): guarantee the shared schema is restored to head in a finally, so a downgrade/re-upgrade failure can't cascade into later tests. - worker (simplification): align _finish_from_cache's display_name fill with the _download path's guard (no behavior change). Gate green: ruff clean, 334 pytest (0 skip), frontend unchanged. |
||
|
|
18edbecb1e |
fix(auth): close the account pre-emption + wrong-account link holes (E1 S5, SEC-10/11)
SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose email was never verified is untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. Both the Google-adoption callback and admin activation (_activate_user_by_email) now drop that pre-set password and bump the session epoch when the account isn't email_verified, so a later Google login / admin approval can't hand the pre-set password a working login. No-op for a verified account (the legit no-SMTP flow). +2 tests. SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs resolved to the session DEFAULT account, so a tab acting as a secondary account would link Google / the YouTube grant to the wrong account. The frontend now threads the active account through the navigation URL (withActiveAccount -> ?account=<active>), and _complete_link accepts any wallet account, not only the default, so the identity lands on the account that started the flow. |
||
|
|
ac6383e92c |
refactor(auth): address /code-review on the S2 split
Cleanup only, no behaviour change (ruff clean + pytest 193): - keep the whole OAuth scope vocabulary together in app/auth.py: the bundles (BASE_SCOPES/READ_SCOPES/WRITE_SCOPES/_YOUTUBE_SCOPES) move back next to the atoms (READ_SCOPE/WRITE_SCOPE) as core; routes/auth.py imports the bundles it needs instead of rebuilding them from the atoms. - compact the const groups the split generator spread out (the six rate limiters, the TTLs, the demo/scope constants) back into blocks. |
||
|
|
1b07d54663 |
refactor(auth): split app/auth.py — endpoints to app/routes/auth.py
R12 S2. app/auth.py (1154 lines) mixed the core auth surface with 18 HTTP endpoints on one /auth router. Split by dependency graph: - app/auth.py (223 lines) keeps ONLY the core surface other modules import (current_user, resolved_user_id, admin_user, require_*, has_*_scope, purge_user, count_admins, get_or_create_demo_user, google_enabled, is_allowed, operator_contact, optional_current_user) plus everything it transitively depends on. - app/routes/auth.py (883 lines, new) gets the router + all 18 endpoints + their endpoint-only helpers, importing the core surface from app.auth. - main.py includes auth_routes.router (was app.auth.router). - test_oauth_redirect imports _oauth_redirect_uri from its new home. The STAY/MOVE partition is driven by an AST scan of the COMPLETE external surface (every `from app.auth import` incl. multiline + `from app import auth; auth.X`), so no still-referenced symbol was moved. Behaviour-equivalent: every symbol relocated verbatim (get_source_segment equality), route set proven identical, no undefined names (symtable sweep). Gate: ruff clean (backend + scripts), pytest 193 passed. |