Compare commits
12
Commits
v0.108.1
...
fdf8076013
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf8076013 | ||
|
|
6287fc9cf8 | ||
|
|
3c6c23507b | ||
|
|
ef99f7b5c7 | ||
|
|
94418a087e | ||
|
|
28b77fed1e | ||
|
|
50764a916d | ||
|
|
f21cdfa156 | ||
|
|
d6dada7fe5 | ||
|
|
6645caa157 | ||
|
|
59ec4a28ba | ||
|
|
dc4f4f5143 |
@@ -7,6 +7,21 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.109.0 — 2026-10-21
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Accounts: a browser is signed in to one account at a time. The account menu's "Sign in as someone else" replaces it; the account list and switcher are gone. After this update everyone signs in once more.
|
||||||
|
- Settings follow you: the Home start page, the channel manager and Plex playlist layouts, the discovery sorts and a dismissed onboarding or release banner are saved to your account, on every device. Performance mode stays per device.
|
||||||
|
- Development: every key the browser stores declares its kind, held by a contract test; one Google connect route serves the sign-in and the YouTube access grants.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Accounts: a tab still showing another account than the one signed in now reloads as that one and says why; a failed "Sign in as someone else" says what happened.
|
||||||
|
- A link whose path starts with // no longer opens the error screen.
|
||||||
|
- A member following a link to an admin page lands on the feed, with the rail and the address bar in step.
|
||||||
|
- Google: an unknown access request is refused instead of quietly asking for read access, and a YouTube grant on an instance without Google says so.
|
||||||
|
|
||||||
## 0.108.1 — 2026-10-20
|
## 0.108.1 — 2026-10-20
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ boundary is one-way and **lint-enforced**, not just described: a platform compon
|
|||||||
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
|
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
|
||||||
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
|
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
|
||||||
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||||
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
|
||||||
`lib/api/*` slices over `core.req<T>`.
|
prefs; API through `lib/api/*` slices over `core.req<T>`.
|
||||||
|
|
||||||
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
||||||
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
||||||
@@ -103,7 +103,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
||||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
| FE browser storage | a key in `storage.ts` with its kind (contract-held): a `pref` through `usePref` (the server's, follows the account); `device` / `view` / `cache` this browser's; `public` kept for the /watch page | a `"siftlode.x"` literal; a preference kept only in `localStorage` (it stays on one device) |
|
||||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||||
|
|||||||
+47
-88
@@ -25,8 +25,6 @@ def operator_contact() -> str | None:
|
|||||||
return next(iter(sorted(settings.admin_email_set)), None)
|
return next(iter(sorted(settings.admin_email_set)), None)
|
||||||
|
|
||||||
|
|
||||||
MAX_SESSION_ACCOUNTS = 6
|
|
||||||
|
|
||||||
# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check
|
# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check
|
||||||
# the atoms against a user's stored grant; the *_SCOPES bundles are what the login/callback endpoints
|
# the atoms against a user's stored grant; the *_SCOPES bundles are what the login/callback endpoints
|
||||||
# request (imported by app/routes/auth.py).
|
# request (imported by app/routes/auth.py).
|
||||||
@@ -253,94 +251,63 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
|||||||
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
|
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
|
||||||
|
|
||||||
|
|
||||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
def session_user_id(conn: HTTPConnection) -> int | None:
|
||||||
|
"""The account this browser session signed in as, read from the signed cookie alone (no DB).
|
||||||
|
One account per session: signing in as another replaces it (`routes.auth._establish_session`)."""
|
||||||
|
return conn.session.get("user_id")
|
||||||
|
|
||||||
|
|
||||||
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
|
def stamp_session(request: Request, user: User) -> None:
|
||||||
"""Which account this connection acts as, and whether that's the session's default account.
|
"""Record the account's current `session_epoch` in the signed cookie, so `current_user` can later
|
||||||
Takes an HTTPConnection rather than a Request: it reads only the session and the headers.
|
reject the cookie once the epoch moves (password reset/change, or "log out everywhere")."""
|
||||||
|
request.session["epoch"] = user.session_epoch or 0
|
||||||
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
|
||||||
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
|
|
||||||
`?account=`), but ONLY an account in the wallet — so a tab can't impersonate an account that
|
|
||||||
never authenticated here. A connection that names no account acts as the cookie default.
|
|
||||||
|
|
||||||
A connection naming an account NOT in the wallet acts as nobody (`None`) — typically a tab still
|
|
||||||
pinned to an account that was signed out in another tab. It must not fall back to the default:
|
|
||||||
that tab would go on acting as an account it never showed, its player checkpoints and pref saves
|
|
||||||
landing there and its Sign out signing THAT account out. Only the bootstrap /api/me probe passes
|
|
||||||
`strict=False` and gets the default, which is how such a tab learns who it now is (the frontend
|
|
||||||
re-pins it and reloads). An account whose session ended while it stayed in the wallet (deleted,
|
|
||||||
suspended, revoked) leaves the wallet when it is refused (`_session_user`), so a tab pinned to it
|
|
||||||
takes the same way back.
|
|
||||||
"""
|
|
||||||
default_id = conn.session.get("user_id")
|
|
||||||
wallet = conn.session.get("account_ids") or []
|
|
||||||
# Header for normal XHR; ?account= for a context that can't set headers (a plain <a> file
|
|
||||||
# download). Both are wallet-gated below.
|
|
||||||
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
|
||||||
if hdr:
|
|
||||||
try:
|
|
||||||
hid = int(hdr)
|
|
||||||
except ValueError:
|
|
||||||
# Under `strict` this becomes a 401 and the tab authenticates as nobody, which reads
|
|
||||||
# in the browser exactly like a session that expired. The value itself stays out —
|
|
||||||
# it came from a request, and `wallet` says which accounts were actually on offer.
|
|
||||||
log.debug(
|
|
||||||
"the active-account header could not be read as an id",
|
|
||||||
extra=logs.event("auth.account_header.invalid", accounts=len(wallet)),
|
|
||||||
)
|
|
||||||
hid = None
|
|
||||||
if hid is not None and hid in wallet:
|
|
||||||
return hid, hid == default_id
|
|
||||||
if strict:
|
|
||||||
return None, False
|
|
||||||
return default_id, True
|
|
||||||
|
|
||||||
|
|
||||||
def drop_from_wallet(session: dict, user_id: int) -> None:
|
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
|
||||||
"""Take one account out of this browser's wallet (`account_ids`); the session default is untouched."""
|
# frontend). The cookie alone says who is asking, and one browser holds one account: a sign-in as
|
||||||
session["account_ids"] = [a for a in (session.get("account_ids") or []) if a != user_id]
|
# someone else in another tab replaces it under every open tab. A tab still showing the previous
|
||||||
|
# account would otherwise act as the new one — its player checkpoints, pref saves, Sign out and
|
||||||
|
# Delete account landing there — until it noticed. It never selects an account; a mismatch is
|
||||||
|
# refused, and the tab reloads as whoever is signed in now.
|
||||||
|
EXPECTED_ACCOUNT_HEADER = "x-siftlode-expect-account"
|
||||||
|
|
||||||
|
|
||||||
def _end_account_session(request: Request, user_id: int, is_default: bool) -> None:
|
def built_for_another_account(user_id: int, expected: str | None) -> bool:
|
||||||
"""An account whose session here is no longer valid. Only nuke the whole session when it is the
|
"""Whether a tab naming `expected` is showing another account than the one signed in. No name is
|
||||||
SESSION DEFAULT; a per-tab account just leaves the wallet (other tabs' default may still be valid).
|
no claim (a caller from before this check, a script): only a mismatch is refused, and the
|
||||||
Leaving is what lets a tab pinned to it recover: its next /api/me names an account no longer signed
|
session stays — it is the other tab's sign-in, and it holds. The one comparison for every way a
|
||||||
in here and gets the default. Left in, it answered "signed out" — the landing page, with the default
|
tab names its account: this header on a request, `expect` on a navigation."""
|
||||||
still signed in, and no way off it but closing the tab."""
|
if expected is None or expected == str(user_id):
|
||||||
if is_default:
|
return False
|
||||||
request.session.clear()
|
log.info(
|
||||||
else:
|
"a request from a tab built for another account was refused",
|
||||||
drop_from_wallet(request.session, user_id)
|
extra=logs.event("auth.session.account_changed", user_id=user_id),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
return _session_user(request, db, *resolved_user_id(request))
|
user = _session_user(request, db, session_user_id(request))
|
||||||
|
if built_for_another_account(user.id, request.headers.get(EXPECTED_ACCOUNT_HEADER)):
|
||||||
|
raise refused(401, "account_changed")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
def _session_user(request: Request, db: Session, user_id: int | None) -> User:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise refused(401, "not_authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None or not user.is_active or user.is_suspended:
|
if user is None or not user.is_active or user.is_suspended:
|
||||||
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||||
_end_account_session(request, user_id, is_default)
|
request.session.clear()
|
||||||
raise refused(401, "not_authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
|
# A cookie whose epoch is behind the account's (revoked by a password reset/change or "log out
|
||||||
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
|
# everywhere") is refused — and so is one with no epoch at all, which only a build before the
|
||||||
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
# one-account session wrote: its holder signs in once more.
|
||||||
epochs = request.session.get("epochs") or {}
|
epoch = request.session.get("epoch")
|
||||||
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
if epoch is None or int(epoch) != (user.session_epoch or 0):
|
||||||
_end_account_session(request, user_id, is_default)
|
request.session.clear()
|
||||||
raise refused(401, "not_authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
# Always keep the session default in the switchable list — covers sessions created before
|
|
||||||
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
|
||||||
default_id = request.session.get("user_id")
|
|
||||||
accounts = request.session.get("account_ids") or []
|
|
||||||
if default_id and default_id not in accounts:
|
|
||||||
accounts.append(default_id)
|
|
||||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
|
||||||
# Attribute this request's log lines to the account. Here, not in the request middleware:
|
# Attribute this request's log lines to the account. Here, not in the request middleware:
|
||||||
# this is the one place a session becomes an account, and the middleware runs outside both the
|
# this is the one place a session becomes an account, and the middleware runs outside both the
|
||||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||||
@@ -358,25 +325,17 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
|
|||||||
def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||||
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
|
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
|
||||||
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
|
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
|
||||||
the browser console (a non-2xx fetch is a console error no matter how the app handles it).
|
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
|
||||||
The one lenient resolution (`strict=False`): a tab naming an account no longer signed in here gets
|
user_id = session_user_id(request)
|
||||||
the default account, which is how it learns who it now is — see `resolved_user_id`. That includes an
|
if user_id is None:
|
||||||
account refused right here, which `_session_user` has just taken out of the wallet."""
|
return None
|
||||||
user_id, is_default = resolved_user_id(request, strict=False)
|
|
||||||
try:
|
try:
|
||||||
return _session_user(request, db, user_id, is_default)
|
return _session_user(request, db, user_id)
|
||||||
# silent-ok: this is the anonymous case as much as the refused one — every landing page hit
|
|
||||||
# comes through here. The second attempt below is the handling, and it reports.
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
if is_default:
|
# A session that named an account, refused: the browser is signed out without being told
|
||||||
return None
|
# why, so say it here.
|
||||||
try:
|
|
||||||
return _session_user(request, db, *resolved_user_id(request, strict=False))
|
|
||||||
except HTTPException:
|
|
||||||
# Both the pinned account AND the default one were refused, so the tab is signed out
|
|
||||||
# without being told why — and it got here from a session that named a real account.
|
|
||||||
log.info(
|
log.info(
|
||||||
"a session was resolved to nobody: neither the named nor the default account held",
|
"a session was refused: its account no longer holds",
|
||||||
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
|
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|||||||
+105
-107
@@ -5,9 +5,10 @@ helpers) stays in app/auth.py, imported below."""
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import NamedTuple
|
from typing import Literal, NamedTuple
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from authlib.common.security import generate_token
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
@@ -25,10 +26,11 @@ from app import sysconfig
|
|||||||
from app.auth import (
|
from app.auth import (
|
||||||
_YOUTUBE_SCOPES,
|
_YOUTUBE_SCOPES,
|
||||||
BASE_SCOPES,
|
BASE_SCOPES,
|
||||||
MAX_SESSION_ACCOUNTS,
|
EXPECTED_ACCOUNT_HEADER,
|
||||||
PASSWORD_MIN_LEN,
|
PASSWORD_MIN_LEN,
|
||||||
READ_SCOPES,
|
READ_SCOPES,
|
||||||
WRITE_SCOPES,
|
WRITE_SCOPES,
|
||||||
|
built_for_another_account,
|
||||||
current_user,
|
current_user,
|
||||||
env_admin_canonicals,
|
env_admin_canonicals,
|
||||||
env_allowed_canonicals,
|
env_allowed_canonicals,
|
||||||
@@ -37,7 +39,9 @@ from app.auth import (
|
|||||||
is_allowed,
|
is_allowed,
|
||||||
log,
|
log,
|
||||||
operator_contact,
|
operator_contact,
|
||||||
resolved_user_id,
|
optional_current_user,
|
||||||
|
session_user_id,
|
||||||
|
stamp_session,
|
||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
@@ -320,6 +324,25 @@ class SetPasswordIn(BaseModel):
|
|||||||
current_password: str | None = None
|
current_password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# A connect in flight, in the session: `{state, uid, explicit}`. It names the OAuth state of the flow
|
||||||
|
# that set it, because the session is the browser's, not the flow's: a callback coming back with
|
||||||
|
# another state (an older flow, which authlib then refuses) must not spend the marker of the connect
|
||||||
|
# still open, or that connect would return as a plain sign-in — signing someone else in, or storing
|
||||||
|
# a YouTube grant on whichever account the Google identity already belongs to.
|
||||||
|
_LINK_MARKER = "oauth_link"
|
||||||
|
|
||||||
|
# Where a navigation from a tab showing another account than the one signed in lands: the app, which
|
||||||
|
# boots as the account signed in now and says why (the frontend's `ACCOUNT_CHANGED_FLAG`).
|
||||||
|
ACCOUNT_CHANGED_LANDING = "/?account=changed"
|
||||||
|
|
||||||
|
|
||||||
|
def _take_link_marker(request: Request) -> dict | None:
|
||||||
|
marker = request.session.get(_LINK_MARKER)
|
||||||
|
if not isinstance(marker, dict) or marker.get("state") != request.query_params.get("state"):
|
||||||
|
return None
|
||||||
|
return request.session.pop(_LINK_MARKER)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request, db: Session = Depends(get_db)):
|
async def login(request: Request, db: Session = Depends(get_db)):
|
||||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||||
@@ -330,6 +353,10 @@ async def login(request: Request, db: Session = Depends(get_db)):
|
|||||||
# Google sign-in isn't configured on this instance — land back on the login page (the UI
|
# Google sign-in isn't configured on this instance — land back on the login page (the UI
|
||||||
# hides the button, so this is just a safety net for a stale/direct hit).
|
# hides the button, so this is just a safety net for a stale/direct hit).
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
# A sign-in, never a link. Starting it retires every earlier flow's state (authlib keeps only the
|
||||||
|
# newest), so a connect's marker left from before — abandoned at Google, or still open in
|
||||||
|
# another tab — names a flow that can no longer finish.
|
||||||
|
request.session.pop(_LINK_MARKER, None)
|
||||||
return await client.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
_oauth_redirect_uri(request),
|
_oauth_redirect_uri(request),
|
||||||
@@ -350,24 +377,23 @@ async def callback(
|
|||||||
if client is None:
|
if client is None:
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
# Linking mode: an already-authenticated account is attaching this Google identity (a
|
# Linking mode: an already-authenticated account is attaching this Google identity (a
|
||||||
# password account enabling YouTube or connecting SSO). The markers are set by /auth/link and
|
# password account enabling YouTube or connecting SSO). The marker is set by
|
||||||
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
|
# /auth/google/connect; when it is this flow's we attach to the current user instead of
|
||||||
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
|
# identifying by sub. Taken BEFORE the token exchange: if the user aborts Google's consent screen
|
||||||
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
|
# the exchange throws below, and a marker left behind would silently turn the NEXT clean sign-in
|
||||||
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
|
# into a link attempt. A connect that never comes back here at all is cleared by /auth/login.
|
||||||
link_uid = request.session.pop("oauth_link_uid", None)
|
link = _take_link_marker(request)
|
||||||
link_explicit = request.session.pop("oauth_link_explicit", False)
|
|
||||||
try:
|
try:
|
||||||
token = await client.authorize_access_token(request)
|
token = await client.authorize_access_token(request)
|
||||||
except OAuthError as exc:
|
except OAuthError as exc:
|
||||||
# The user cancelled or denied consent at Google — land back with a friendly notice instead
|
# The user cancelled or denied consent at Google — land back with a friendly notice instead
|
||||||
# of a raw 400 JSON error page. An already-signed-in user (adding another account via
|
# of a raw 400 JSON error page. An already-signed-in user (signing in again via /auth/login,
|
||||||
# /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login
|
# or connecting via /auth/google/connect) never sees the pre-auth login banner, so
|
||||||
# banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on
|
# surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on the login page
|
||||||
# the login page with the oauth_cancelled banner instead. Keyed on the live session, not on
|
# with the oauth_cancelled banner instead. Keyed on the live session, not on link_uid, so a
|
||||||
# link_uid, so the add-account path (no marker) is covered too.
|
# sign-in started while signed in (no marker) is covered too.
|
||||||
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
||||||
if request.session.get("user_id") is not None:
|
if session_user_id(request) is not None:
|
||||||
return RedirectResponse(url="/?oauth=cancelled")
|
return RedirectResponse(url="/?oauth=cancelled")
|
||||||
return RedirectResponse(url="/?login=oauth_cancelled")
|
return RedirectResponse(url="/?login=oauth_cancelled")
|
||||||
|
|
||||||
@@ -378,8 +404,8 @@ async def callback(
|
|||||||
sub = userinfo["sub"]
|
sub = userinfo["sub"]
|
||||||
email = (userinfo.get("email") or "").lower()
|
email = (userinfo.get("email") or "").lower()
|
||||||
|
|
||||||
if link_uid is not None:
|
if link is not None:
|
||||||
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
|
return _complete_link(request, db, link["uid"], link["explicit"], sub, userinfo, token)
|
||||||
|
|
||||||
if not is_allowed(db, email):
|
if not is_allowed(db, email):
|
||||||
# No uid to name — having no account here is the branch. And the address is left out on
|
# No uid to name — having no account here is the branch. And the address is left out on
|
||||||
@@ -580,19 +606,16 @@ def _complete_link(
|
|||||||
token: dict,
|
token: dict,
|
||||||
):
|
):
|
||||||
"""Attach the just-authorized Google identity to the already-signed-in account that started
|
"""Attach the just-authorized Google identity to the already-signed-in account that started
|
||||||
the link/upgrade. No is_allowed gate — they're already an active user. Refuses to hijack a
|
the connect. No is_allowed gate — they're already an active user. Refuses to hijack a
|
||||||
Google identity owned by another account, or to silently swap a different linked identity.
|
Google identity owned by another account, or to silently swap a different linked identity.
|
||||||
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
`explicit` (from the link marker) distinguishes a "Connect Google" click from a YouTube grant —
|
||||||
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
|
the caller takes the marker up front so an aborted flow leaves nothing behind."""
|
||||||
# SEC-11: the marker must name an account signed into THIS browser — the session default OR any
|
# The marker must name the account still signed into THIS browser, and that session must still
|
||||||
# account in the wallet — not only the default. A tab acting as a secondary account links Google to
|
# hold: a sign-in as someone else between the start and the callback replaced it (the identity
|
||||||
# ITSELF (the marker was set from current_user, which resolves the ?account= it navigated with), so
|
# must not land on the newcomer), and a session revoked meanwhile ("log out everywhere", a
|
||||||
# accepting wallet membership stops the identity/token landing on the default account A instead of B.
|
# password change, a suspension) must not finish what it started.
|
||||||
accounts = request.session.get("account_ids") or []
|
user = optional_current_user(request, db)
|
||||||
if link_uid != request.session.get("user_id") and link_uid not in accounts:
|
if user is None or user.id != link_uid:
|
||||||
return RedirectResponse(url="/?link=error")
|
|
||||||
user = db.get(User, link_uid)
|
|
||||||
if user is None:
|
|
||||||
return RedirectResponse(url="/?link=error")
|
return RedirectResponse(url="/?link=error")
|
||||||
other = db.execute(
|
other = db.execute(
|
||||||
select(User).where(User.google_sub == sub, User.id != user.id)
|
select(User).where(User.google_sub == sub, User.id != user.id)
|
||||||
@@ -612,45 +635,31 @@ def _complete_link(
|
|||||||
_store_token(db, user, token)
|
_store_token(db, user, token)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
|
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
|
||||||
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade
|
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube grant
|
||||||
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
|
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
|
||||||
return RedirectResponse(url="/?link=ok" if explicit else "/")
|
return RedirectResponse(url="/?link=ok" if explicit else "/")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(request: Request):
|
async def logout(request: Request):
|
||||||
"""Sign the requesting tab's active account out of this browser (removing it from the wallet).
|
"""Sign this browser out: the session holds one account, and it ends — unless the tab asking was
|
||||||
Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
|
built for another account than the one signed in now (a sign-in as someone else in another tab):
|
||||||
out in one tab doesn't disturb another tab running a different account. If the account being
|
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
|
||||||
removed was the session default, promote the most recent remaining account (or clear the
|
signed_in = session_user_id(request)
|
||||||
session when none remain)."""
|
if signed_in is not None and built_for_another_account(
|
||||||
# A tab still pinned to an account already signed out in another tab resolves to nobody, so it
|
signed_in, request.headers.get(EXPECTED_ACCOUNT_HEADER)
|
||||||
# signs nobody out — it used to fall back to the default and sign THAT account out in its place.
|
):
|
||||||
active, is_default = resolved_user_id(request)
|
raise refused(401, "account_changed")
|
||||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
|
request.session.clear()
|
||||||
request.session["account_ids"] = remaining
|
return JSONResponse({"ok": True})
|
||||||
if is_default:
|
|
||||||
if remaining:
|
|
||||||
request.session["user_id"] = remaining[-1]
|
|
||||||
return JSONResponse({"ok": True, "switched": True})
|
|
||||||
request.session.clear()
|
|
||||||
return JSONResponse({"ok": True, "switched": False})
|
|
||||||
# A non-default (per-tab) account signed out: it's gone from the wallet and the session default
|
|
||||||
# is untouched. The tab drops its own override and falls back to the default on reload.
|
|
||||||
return JSONResponse({"ok": True, "switched": False})
|
|
||||||
|
|
||||||
|
|
||||||
def _establish_session(request: Request, user: User) -> None:
|
def _establish_session(request: Request, user: User) -> None:
|
||||||
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
|
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
|
||||||
and password login, so both join the same account switcher and both stamp `last_login_at` (the
|
and password login, so both stamp `last_login_at` (the caller commits). One account per session:
|
||||||
caller commits). Multi-session: every account that authenticated in this browser is remembered so
|
the sign-in replaces whoever was signed in here before."""
|
||||||
the user can switch between them; only accounts that actually proved themselves HERE land in that
|
|
||||||
wallet (it is the proof they may be switched to)."""
|
|
||||||
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
|
|
||||||
accounts.append(user.id)
|
|
||||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
_remember_epoch(request, user)
|
stamp_session(request, user)
|
||||||
user.last_login_at = datetime.now(timezone.utc)
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@@ -1135,15 +1144,6 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _remember_epoch(request: Request, user: User) -> None:
|
|
||||||
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
|
|
||||||
can later reject the cookie if the account's epoch was bumped (password reset/change, or
|
|
||||||
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
|
|
||||||
epochs = dict(request.session.get("epochs") or {})
|
|
||||||
epochs[str(user.id)] = user.session_epoch
|
|
||||||
request.session["epochs"] = epochs
|
|
||||||
|
|
||||||
|
|
||||||
def bump_session_epoch(db: Session, user: User) -> None:
|
def bump_session_epoch(db: Session, user: User) -> None:
|
||||||
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
|
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
|
||||||
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
|
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
|
||||||
@@ -1220,35 +1220,11 @@ def logout_others(
|
|||||||
# session at a newer epoch than the DB (see set_password).
|
# session at a newer epoch than the DB (see set_password).
|
||||||
bump_session_epoch(db, user)
|
bump_session_epoch(db, user)
|
||||||
db.commit()
|
db.commit()
|
||||||
_remember_epoch(request, user)
|
stamp_session(request, user)
|
||||||
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/link")
|
|
||||||
async def link_google(
|
|
||||||
request: Request,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
|
|
||||||
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
|
|
||||||
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
|
|
||||||
client = google_oauth(db)
|
|
||||||
if client is None:
|
|
||||||
return RedirectResponse(url="/?link=error")
|
|
||||||
request.session["oauth_link_uid"] = user.id
|
|
||||||
request.session["oauth_link_explicit"] = True
|
|
||||||
return await client.authorize_redirect(
|
|
||||||
request,
|
|
||||||
_oauth_redirect_uri(request),
|
|
||||||
access_type="offline",
|
|
||||||
prompt="select_account",
|
|
||||||
include_granted_scopes="true",
|
|
||||||
scope=BASE_SCOPES,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/set-password")
|
@router.post("/set-password")
|
||||||
def set_password(
|
def set_password(
|
||||||
body: SetPasswordIn,
|
body: SetPasswordIn,
|
||||||
@@ -1272,35 +1248,57 @@ def set_password(
|
|||||||
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||||
bump_session_epoch(db, user)
|
bump_session_epoch(db, user)
|
||||||
db.commit()
|
db.commit()
|
||||||
_remember_epoch(request, user)
|
stamp_session(request, user)
|
||||||
log.info("Password set/changed: uid=%s", user.id)
|
log.info("Password set/changed: uid=%s", user.id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/upgrade")
|
# What each Google connect asks Google for. `identity` attaches a Google sign-in to the account
|
||||||
async def upgrade(
|
# (Settings → Connect Google) and lets the person pick which Google account; `read` and `write` grant
|
||||||
|
# YouTube access (the onboarding wizard, the Settings access rows) and always show the consent screen,
|
||||||
|
# so a scope granted earlier and since revoked at Google is asked for again.
|
||||||
|
_CONNECT: dict[str, tuple[str, str]] = {
|
||||||
|
"identity": (BASE_SCOPES, "select_account"),
|
||||||
|
"read": (READ_SCOPES, "consent"),
|
||||||
|
"write": (WRITE_SCOPES, "consent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/google/connect")
|
||||||
|
async def google_connect(
|
||||||
request: Request,
|
request: Request,
|
||||||
access: str = "read",
|
access: Literal["identity", "read", "write"],
|
||||||
|
expect: str | None = None,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
"""Attach Google to the signed-in account: its identity, or YouTube read/write access on top.
|
||||||
read-only (enough to build the feed); `access=write` additionally grants management
|
The shared callback sees the link marker and attaches what Google returns to THIS account —
|
||||||
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
|
without it a password account (no google_sub) would get a brand-new, separate Google user
|
||||||
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
|
created instead. It stores whatever scopes Google grants, so `can_read` / `can_write` flip on
|
||||||
the least-privileged read scope."""
|
once granted.
|
||||||
|
|
||||||
|
`expect` is the account the tab was built for. A navigation carries no headers, so the check
|
||||||
|
every request gets (`EXPECTED_ACCOUNT_HEADER`) rides the query here: a tab still showing the
|
||||||
|
account signed in before would otherwise attach a Google identity to the one signed in now."""
|
||||||
|
if built_for_another_account(user.id, expect):
|
||||||
|
return RedirectResponse(url=ACCOUNT_CHANGED_LANDING)
|
||||||
client = google_oauth(db)
|
client = google_oauth(db)
|
||||||
if client is None:
|
if client is None:
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/?link=error")
|
||||||
# Attach the grant to THIS account in the callback. Without it a password account (no
|
scope, prompt = _CONNECT[access]
|
||||||
# google_sub) would get a brand-new, separate Google user created instead of being linked.
|
state = generate_token(48)
|
||||||
request.session["oauth_link_uid"] = user.id
|
request.session[_LINK_MARKER] = {
|
||||||
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
"state": state,
|
||||||
|
"uid": user.id,
|
||||||
|
"explicit": access == "identity",
|
||||||
|
}
|
||||||
return await client.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
_oauth_redirect_uri(request),
|
_oauth_redirect_uri(request),
|
||||||
|
state=state,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
prompt="consent",
|
prompt=prompt,
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
scope=scope,
|
scope=scope,
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-81
@@ -9,11 +9,9 @@ from app.auth import (
|
|||||||
count_admins,
|
count_admins,
|
||||||
count_pending_activations,
|
count_pending_activations,
|
||||||
current_user,
|
current_user,
|
||||||
drop_from_wallet,
|
|
||||||
google_enabled,
|
google_enabled,
|
||||||
has_read_scope,
|
has_read_scope,
|
||||||
has_write_scope,
|
has_write_scope,
|
||||||
is_allowed,
|
|
||||||
optional_current_user,
|
optional_current_user,
|
||||||
purge_user,
|
purge_user,
|
||||||
)
|
)
|
||||||
@@ -26,79 +24,10 @@ from app.db import get_db
|
|||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
from app.routes._common import not_found
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts")
|
|
||||||
def my_accounts(
|
|
||||||
request: Request,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""The accounts that have authenticated in this browser session (switchable without a
|
|
||||||
new Google sign-in). The active one is flagged."""
|
|
||||||
ids = request.session.get("account_ids") or [user.id]
|
|
||||||
rows = {u.id: u for u in db.execute(select(User).where(User.id.in_(ids))).scalars()}
|
|
||||||
out = []
|
|
||||||
for uid in ids: # preserve recency order from the session
|
|
||||||
u = rows.get(uid)
|
|
||||||
if u is not None:
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"id": u.id,
|
|
||||||
"email": u.email,
|
|
||||||
"display_name": u.display_name,
|
|
||||||
"avatar_url": u.avatar_url,
|
|
||||||
"active": u.id == user.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class SwitchAccountIn(BaseModel):
|
|
||||||
user_id: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/switch")
|
|
||||||
def switch_account(
|
|
||||||
body: SwitchAccountIn,
|
|
||||||
request: Request,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""Switch the active account to another one already authenticated in this browser. No
|
|
||||||
Google round-trip — but only accounts present in the session list (proof they signed in
|
|
||||||
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
|
||||||
target = body.user_id
|
|
||||||
if target not in (request.session.get("account_ids") or []):
|
|
||||||
raise refused(403, "not_your_account")
|
|
||||||
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
|
||||||
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
|
||||||
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
|
||||||
# wallet session, logging out every account here. (B-core-9)
|
|
||||||
u = db.get(User, target)
|
|
||||||
if u is None:
|
|
||||||
drop_from_wallet(request.session, target)
|
|
||||||
raise not_found("account")
|
|
||||||
if not is_allowed(db, u.email):
|
|
||||||
raise refused(403, "account_no_access")
|
|
||||||
if not u.is_active:
|
|
||||||
drop_from_wallet(request.session, target)
|
|
||||||
raise refused(403, "account_no_access")
|
|
||||||
if u.is_suspended:
|
|
||||||
drop_from_wallet(request.session, target)
|
|
||||||
raise refused(403, "account_suspended")
|
|
||||||
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
|
||||||
epochs = request.session.get("epochs") or {}
|
|
||||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
|
||||||
drop_from_wallet(request.session, target)
|
|
||||||
raise refused(403, "account_signed_out")
|
|
||||||
request.session["user_id"] = target
|
|
||||||
return {"ok": True, "user_id": target}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def get_me(
|
def get_me(
|
||||||
user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
|
user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
|
||||||
@@ -187,17 +116,11 @@ def delete_my_account(
|
|||||||
surfaces)."""
|
surfaces)."""
|
||||||
if user.role == "admin" and count_admins(db) <= 1:
|
if user.role == "admin" and count_admins(db) <= 1:
|
||||||
raise refused(400, "only_admin_delete")
|
raise refused(400, "only_admin_delete")
|
||||||
user_id = user.id
|
|
||||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
||||||
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
# admin delete path.
|
||||||
purge_user(db, user, background)
|
purge_user(db, user, background)
|
||||||
# Drop this account from the browser session; switch to another signed-in account if any.
|
# The session's one account is gone: so is the session.
|
||||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id]
|
request.session.clear()
|
||||||
if remaining:
|
|
||||||
request.session["account_ids"] = remaining
|
|
||||||
request.session["user_id"] = remaining[-1]
|
|
||||||
else:
|
|
||||||
request.session.clear()
|
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -213,7 +136,13 @@ def update_preferences(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
# Merge so partial updates don't wipe other keys.
|
# Merge so partial updates don't wipe other keys — over the stored row read again under a row
|
||||||
|
# lock: a client saves keys one at a time (several at once on a boot that moves this browser's
|
||||||
|
# copies to the account), and two merges over the same earlier read would keep only the last.
|
||||||
|
# FOR NO KEY UPDATE, as bump_session_epoch: it still orders two merges, and a transaction that
|
||||||
|
# only added a row referring to the account (a playlist pull, then minutes of YouTube) does not
|
||||||
|
# hold the save up.
|
||||||
|
db.refresh(user, with_for_update={"key_share": True})
|
||||||
merged = dict(user.preferences or {})
|
merged = dict(user.preferences or {})
|
||||||
merged.update(preferences.root)
|
merged.update(preferences.root)
|
||||||
user.preferences = merged
|
user.preferences = merged
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from fastapi.responses import FileResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user, resolved_user_id
|
from app.auth import current_user, session_user_id
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.plex import imgcache, queries, service, subtitles
|
from app.plex import imgcache, queries, service, subtitles
|
||||||
@@ -42,7 +42,7 @@ def _require_session(request: Request) -> int:
|
|||||||
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
|
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
|
||||||
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
|
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
|
||||||
low-sensitivity; a valid session is enough."""
|
low-sensitivity; a valid session is enough."""
|
||||||
uid, _ = resolved_user_id(request)
|
uid = session_user_id(request)
|
||||||
if not uid:
|
if not uid:
|
||||||
raise refused(401, "not_authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
return uid
|
return uid
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
|
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
|
||||||
# bumped in place instead of a full regeneration, which would move every other package too — then
|
# bumped in place instead of a full regeneration, which would move every other package too — then
|
||||||
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
||||||
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403).
|
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
|
||||||
|
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2).
|
||||||
APScheduler==3.11.3
|
APScheduler==3.11.3
|
||||||
Authlib==1.7.2
|
Authlib==1.7.2
|
||||||
Mako==1.4.1
|
Mako==1.4.1
|
||||||
@@ -21,7 +22,7 @@ argon2-cffi-bindings==25.1.0
|
|||||||
argon2-cffi==25.1.0
|
argon2-cffi==25.1.0
|
||||||
bgutil-ytdlp-pot-provider==1.3.1
|
bgutil-ytdlp-pot-provider==1.3.1
|
||||||
brotli==1.2.0
|
brotli==1.2.0
|
||||||
certifi==2026.7.22
|
certifi==2026.10.19
|
||||||
cffi==2.1.1
|
cffi==2.1.1
|
||||||
charset-normalizer==3.5.1
|
charset-normalizer==3.5.1
|
||||||
click==8.4.2
|
click==8.4.2
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ def _configured(monkeypatch):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(db, override_dep, _configured):
|
def client(db, override_dep, _configured):
|
||||||
"""A TestClient wired to the test DB that goes through the REAL auth/session stack — no
|
"""A TestClient wired to the test DB that goes through the REAL auth/session stack — no
|
||||||
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
|
`current_user` override — so login, the one-account session and epoch revoke are exercised end to end.
|
||||||
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
|
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
|
||||||
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
|
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
|
||||||
override_dep(get_db, db.info["request"])
|
override_dep(get_db, db.info["request"])
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
"""Auth / session security core (TEST-1), DB-lane.
|
"""Auth / session security core (TEST-1), DB-lane.
|
||||||
|
|
||||||
The wallet impersonation gate, epoch-based session revocation, single-use tokens and the
|
One account per browser session, epoch-based session revocation, single-use tokens and the
|
||||||
enumeration-safe uniform 401 are the security mag, and had zero tests (only `_oauth_redirect_uri` was
|
enumeration-safe uniform 401 are the security mag. Starlette's SessionMiddleware round-trips the
|
||||||
covered). Starlette's SessionMiddleware round-trips the signed cookie under TestClient, so the whole
|
signed cookie under TestClient, so the whole browser-less flow is exercisable. Uses the real auth
|
||||||
browser-less flow is exercisable. Uses the real auth stack via the `client` fixture (NO current_user
|
stack via the `client` fixture (NO current_user override) so login, the session and epoch revoke are
|
||||||
override) so login, the wallet gate and epoch revoke are the code under test.
|
the code under test.
|
||||||
|
|
||||||
Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a
|
Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a
|
||||||
401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401.
|
401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from itsdangerous import TimestampSigner
|
||||||
|
|
||||||
from app.auth import ACTIVE_ACCOUNT_HEADER
|
from app.config import settings
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
_PW = "correct horse battery"
|
_PW = "correct horse battery"
|
||||||
@@ -46,74 +50,48 @@ def test_wrong_password_and_unknown_email_give_a_byte_identical_401(client, make
|
|||||||
assert wrong_pw.json() == unknown.json()
|
assert wrong_pw.json() == unknown.json()
|
||||||
|
|
||||||
|
|
||||||
def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user):
|
def _cookie(payload: dict) -> str:
|
||||||
# The X-Siftlode-Account header may only re-target an account already signed into THIS browser
|
"""A session cookie signed the way SessionMiddleware signs one — for a cookie an older build wrote."""
|
||||||
# (in the session wallet). A header naming someone else must be ignored, not honored — otherwise
|
data = base64.b64encode(json.dumps(payload).encode())
|
||||||
# it's an impersonation gate hole.
|
return TimestampSigner(str(settings.secret_key)).sign(data).decode()
|
||||||
make_user(email="alice2@example.com", password=_PW)
|
|
||||||
mallory = make_user(email="mallory@example.com", password=_PW)
|
|
||||||
assert _login(client, "alice2@example.com", _PW).status_code == 200
|
|
||||||
me = client.get("/api/me", headers={ACTIVE_ACCOUNT_HEADER: str(mallory.id)})
|
|
||||||
assert me.status_code == 200
|
|
||||||
assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_tab_pinned_to_an_account_signed_out_elsewhere_acts_as_nobody(client, make_user):
|
def test_signing_in_as_another_account_replaces_the_first(client, make_user):
|
||||||
# Two accounts in one browser: a tab pinned to Alice, Bob the default. Alice signs out in another
|
# One account per browser session: a second sign-in is a fresh session, not a second account
|
||||||
# tab. The pinned tab used to go on acting as the default, Bob — its player checkpoints and pref
|
# beside the first — so signing out leaves nobody signed in here.
|
||||||
# saves landed in his account, and its own Sign out signed HIM out.
|
make_user(email="alice3@example.com", password=_PW)
|
||||||
alice = make_user(email="alice3@example.com", password=_PW)
|
|
||||||
make_user(email="bob3@example.com", password=_PW)
|
make_user(email="bob3@example.com", password=_PW)
|
||||||
assert _login(client, "alice3@example.com", _PW).status_code == 200
|
assert _login(client, "alice3@example.com", _PW).status_code == 200
|
||||||
assert _login(client, "bob3@example.com", _PW).status_code == 200 # Bob is now the default
|
assert _login(client, "bob3@example.com", _PW).status_code == 200
|
||||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
assert client.get("/api/me").json()["email"] == "bob3@example.com"
|
||||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
|
||||||
assert client.post("/auth/logout", headers=tab).status_code == 200 # Alice signs out
|
|
||||||
|
|
||||||
assert client.get("/api/feed", headers=tab).status_code == 401
|
assert client.post("/auth/logout").status_code == 200
|
||||||
assert client.post("/auth/logout", headers=tab).status_code == 200
|
assert client.get("/api/feed").status_code == 401
|
||||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in, the session intact
|
assert client.get("/api/me").json() == {"authenticated": False}
|
||||||
# The bootstrap probe still answers as the default: how the stale tab learns who it is now.
|
|
||||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob3@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("ended", ["revoked", "suspended"])
|
def test_no_header_or_parameter_picks_another_account(client, make_user):
|
||||||
def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_default(
|
# Alice signed in here before Bob: nothing a request carries makes it act as her any more.
|
||||||
client, make_user, db, ended
|
|
||||||
):
|
|
||||||
# A tab pinned to Alice, Bob the default. Alice's session then ends while she is still in this
|
|
||||||
# browser's wallet ("log out everywhere" on another device, or an admin suspends her). The tab's
|
|
||||||
# bootstrap probe used to answer "signed out": the landing page with Bob still signed in, and no way
|
|
||||||
# off it but closing the tab (signing in as anyone else there kept the tab pinned to Alice).
|
|
||||||
alice = make_user(email="alice4@example.com", password=_PW)
|
alice = make_user(email="alice4@example.com", password=_PW)
|
||||||
make_user(email="bob4@example.com", password=_PW)
|
make_user(email="bob4@example.com", password=_PW)
|
||||||
assert _login(client, "alice4@example.com", _PW).status_code == 200
|
assert _login(client, "alice4@example.com", _PW).status_code == 200
|
||||||
assert _login(client, "bob4@example.com", _PW).status_code == 200
|
assert _login(client, "bob4@example.com", _PW).status_code == 200
|
||||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
me = client.get(f"/api/me?account={alice.id}", headers={"X-Siftlode-Account": str(alice.id)})
|
||||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
assert me.json()["email"] == "bob4@example.com"
|
||||||
if ended == "revoked":
|
|
||||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
|
||||||
else:
|
|
||||||
alice.is_suspended = True
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob4@example.com"
|
|
||||||
assert client.get("/api/feed", headers=tab).status_code == 401 # still nobody, never Bob
|
@pytest.mark.parametrize("path", ["/api/me/accounts", "/api/me/switch"])
|
||||||
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
|
def test_there_is_no_account_list_or_switch(client, make_user, path):
|
||||||
|
make_user(email="alice5@example.com", password=_PW)
|
||||||
|
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||||
|
r = client.get(path) if path.endswith("accounts") else client.post(path, json={"user_id": 1})
|
||||||
|
assert r.status_code in (404, 405)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("ended", ["revoked", "suspended", "deactivated"])
|
@pytest.mark.parametrize("ended", ["revoked", "suspended", "deactivated"])
|
||||||
def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_from_the_list(
|
def test_a_session_whose_account_ended_signs_the_browser_out(client, make_user, db, ended):
|
||||||
client, make_user, db, monkeypatch, ended
|
alice = make_user(email="alice6@example.com", password=_PW)
|
||||||
):
|
assert _login(client, "alice6@example.com", _PW).status_code == 200
|
||||||
# The account switcher lists this browser's wallet. A revoked account was dropped from it on a refused
|
|
||||||
# switch, but a suspended one stayed, refused on every click; a deactivated one was not refused at all —
|
|
||||||
# made the default, the next request found it inactive and signed every account here out.
|
|
||||||
monkeypatch.setattr("app.routes.me.is_allowed", lambda _db, _email: True) # both hold an invite
|
|
||||||
alice = make_user(email="alice5@example.com", password=_PW)
|
|
||||||
make_user(email="bob5@example.com", password=_PW)
|
|
||||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
|
||||||
assert _login(client, "bob5@example.com", _PW).status_code == 200
|
|
||||||
if ended == "revoked":
|
if ended == "revoked":
|
||||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
alice.session_epoch = (alice.session_epoch or 0) + 1
|
||||||
elif ended == "suspended":
|
elif ended == "suspended":
|
||||||
@@ -121,10 +99,111 @@ def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_fro
|
|||||||
else:
|
else:
|
||||||
alice.is_active = False
|
alice.is_active = False
|
||||||
db.commit()
|
db.commit()
|
||||||
|
assert client.get("/api/feed").status_code == 401
|
||||||
|
assert "user_id" not in _session(client) # the refusal ended the session, not just this request
|
||||||
|
assert client.get("/api/me").json() == {"authenticated": False}
|
||||||
|
|
||||||
assert client.post("/api/me/switch", json={"user_id": alice.id}).status_code == 403
|
|
||||||
assert [a["email"] for a in client.get("/api/me/accounts").json()] == ["bob5@example.com"]
|
def test_a_cookie_from_the_multi_account_build_signs_in_again(client, make_user):
|
||||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in
|
# The old session held a wallet and one epoch per account. It is not read: whoever holds one
|
||||||
|
# signs in once more.
|
||||||
|
alice = make_user(email="alice7@example.com", password=_PW)
|
||||||
|
old = {"user_id": alice.id, "account_ids": [alice.id], "epochs": {str(alice.id): 0}}
|
||||||
|
client.cookies.set("session", _cookie(old))
|
||||||
|
assert client.get("/api/feed").status_code == 401
|
||||||
|
assert client.get("/api/me").json() == {"authenticated": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_the_account_signs_the_browser_out(client, make_user):
|
||||||
|
make_user(email="alice8@example.com", password=_PW)
|
||||||
|
assert _login(client, "alice8@example.com", _PW).status_code == 200
|
||||||
|
assert client.delete("/api/me/account").status_code == 200
|
||||||
|
# The cookie names nobody now: the probe answers signed out without looking the account up.
|
||||||
|
assert "user_id" not in _session(client)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(client) -> dict:
|
||||||
|
"""The decoded payload of the client's session cookie."""
|
||||||
|
raw = client.cookies.get("session")
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
data = TimestampSigner(str(settings.secret_key)).unsign(raw.encode())
|
||||||
|
return json.loads(base64.b64decode(data))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("signed_in_as", ["starter", "someone_else"])
|
||||||
|
def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user, signed_in_as):
|
||||||
|
# The link marker names who started it; a sign-in as someone else before the callback must not
|
||||||
|
# get that Google identity attached to the account that started it from the other person's session.
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.routes.auth import _complete_link
|
||||||
|
|
||||||
|
starter, other = make_user(), make_user()
|
||||||
|
session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0}
|
||||||
|
request = Request({"type": "http", "session": session, "headers": []})
|
||||||
|
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
|
||||||
|
db.refresh(starter)
|
||||||
|
if signed_in_as == "starter":
|
||||||
|
assert resp.headers["location"] == "/?link=ok"
|
||||||
|
assert starter.google_sub == "sub-123"
|
||||||
|
else:
|
||||||
|
assert resp.headers["location"] == "/?link=error"
|
||||||
|
assert starter.google_sub is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_app_code_reads_a_second_account():
|
||||||
|
# The multi-account machinery is gone; a module reading its session keys or its header again
|
||||||
|
# would bring back a session that names more than one account.
|
||||||
|
app_dir = Path(__file__).parent.parent / "app"
|
||||||
|
hits = [
|
||||||
|
f"{path.relative_to(app_dir)}: {word}"
|
||||||
|
for path in app_dir.rglob("*.py")
|
||||||
|
for word in ("account_ids", '"epochs"', "x-siftlode-account", 'query_params.get("account")')
|
||||||
|
if word in path.read_text(encoding="utf-8").lower()
|
||||||
|
]
|
||||||
|
assert hits == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tab_built_for_the_account_signed_in_before_is_refused(client, make_user):
|
||||||
|
# Two tabs of one browser: one signed in as someone else, so the cookie is Bob's under both. The
|
||||||
|
# tab still showing Alice says so with every request; acting as Bob there would put her player
|
||||||
|
# positions, pref saves and Sign out on his account. Refused, and the session stays Bob's.
|
||||||
|
alice = make_user(email="alice5@example.com", password=_PW)
|
||||||
|
bob = make_user(email="bob5@example.com", password=_PW)
|
||||||
|
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||||
|
assert _login(client, "bob5@example.com", _PW).status_code == 200
|
||||||
|
|
||||||
|
stale = client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(alice.id)})
|
||||||
|
assert stale.status_code == 401
|
||||||
|
assert stale.json()["detail"]["reason"] == "account_changed"
|
||||||
|
assert (
|
||||||
|
client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(bob.id)}).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
assert client.get("/api/feed").status_code == 200
|
||||||
|
# Nor may its Sign out end Bob's session.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/auth/logout", headers={"X-Siftlode-Expect-Account": str(alice.id)}
|
||||||
|
).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
# The probe answers who is signed in now, so the stale tab can reload as Bob.
|
||||||
|
probe = client.get("/api/me", headers={"X-Siftlode-Expect-Account": str(alice.id)})
|
||||||
|
assert probe.json()["email"] == "bob5@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_a_password_keeps_the_caller_signed_in(client, make_user):
|
||||||
|
# The change ends every OTHER session (the epoch moves); the caller is stamped with the new one.
|
||||||
|
make_user(email="dave@example.com", password=_PW)
|
||||||
|
assert _login(client, "dave@example.com", _PW).status_code == 200
|
||||||
|
r = client.post(
|
||||||
|
"/auth/set-password",
|
||||||
|
json={"password": "a brand new passphrase", "current_password": _PW},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.get("/api/feed").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ the refused sign-in that doubles as an access request, and the link flow's confl
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from authlib.integrations.starlette_client import OAuthError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
@@ -19,15 +20,20 @@ _PW = "correct horse battery"
|
|||||||
|
|
||||||
class _FakeGoogle:
|
class _FakeGoogle:
|
||||||
"""authlib's client, reduced to the two calls the routes make: the redirect out to Google and
|
"""authlib's client, reduced to the two calls the routes make: the redirect out to Google and
|
||||||
the token exchange when Google redirects back."""
|
the token exchange when Google redirects back. Like authlib, it honours only the newest flow's
|
||||||
|
state: starting a flow retires every earlier one."""
|
||||||
|
|
||||||
def __init__(self, userinfo: dict):
|
def __init__(self, userinfo: dict):
|
||||||
self.userinfo = userinfo
|
self.userinfo = userinfo
|
||||||
|
self.asked: dict = {}
|
||||||
|
|
||||||
async def authorize_redirect(self, _request, _redirect_uri, **_kw):
|
async def authorize_redirect(self, _request, _redirect_uri, **kw):
|
||||||
|
self.asked = kw
|
||||||
return RedirectResponse(url="/fake-google")
|
return RedirectResponse(url="/fake-google")
|
||||||
|
|
||||||
async def authorize_access_token(self, _request):
|
async def authorize_access_token(self, request):
|
||||||
|
if request.query_params.get("state") != self.asked.get("state"):
|
||||||
|
raise OAuthError(error="mismatching_state")
|
||||||
return {"userinfo": self.userinfo, "access_token": "at", "scope": "openid email profile"}
|
return {"userinfo": self.userinfo, "access_token": "at", "scope": "openid email profile"}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,9 +50,13 @@ def google(monkeypatch):
|
|||||||
return fake
|
return fake
|
||||||
|
|
||||||
|
|
||||||
def _sign_in(client, google, **userinfo):
|
def _sign_in(client, google, state: str | None = None, **userinfo):
|
||||||
|
"""Google redirecting back: by default with the state of the flow started last."""
|
||||||
google.userinfo = {"email_verified": True, **userinfo}
|
google.userinfo = {"email_verified": True, **userinfo}
|
||||||
return client.get("/auth/callback", follow_redirects=False)
|
state = state or google.asked.get("state")
|
||||||
|
return client.get(
|
||||||
|
"/auth/callback", params={"state": state} if state else {}, follow_redirects=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rows(db, model, column, value):
|
def _rows(db, model, column, value):
|
||||||
@@ -218,12 +228,20 @@ def test_a_refused_sign_in_records_a_request_in_the_reported_language(
|
|||||||
assert _rows(db, User, User.email, "new@example.com") == []
|
assert _rows(db, User, User.email, "new@example.com") == []
|
||||||
|
|
||||||
|
|
||||||
def _start_link(client, email: str):
|
def _password_login(client, email: str):
|
||||||
assert (
|
assert (
|
||||||
client.post("/auth/password-login", json={"email": email, "password": _PW}).status_code
|
client.post("/auth/password-login", json={"email": email, "password": _PW}).status_code
|
||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
assert client.get("/auth/link", follow_redirects=False).headers["location"] == "/fake-google"
|
|
||||||
|
|
||||||
|
def _connect(client, access: str):
|
||||||
|
return client.get(f"/auth/google/connect?access={access}", follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_link(client, email: str):
|
||||||
|
_password_login(client, email)
|
||||||
|
assert _connect(client, "identity").headers["location"] == "/fake-google"
|
||||||
|
|
||||||
|
|
||||||
def test_linking_an_identity_another_account_holds_is_a_conflict(client, db, google, make_user):
|
def test_linking_an_identity_another_account_holds_is_a_conflict(client, db, google, make_user):
|
||||||
@@ -258,3 +276,151 @@ def test_linking_a_free_identity_attaches_it(client, db, google, make_user):
|
|||||||
assert r.headers["location"] == "/?link=ok"
|
assert r.headers["location"] == "/?link=ok"
|
||||||
db.refresh(me)
|
db.refresh(me)
|
||||||
assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com")
|
assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_connect_abandoned_at_google_leaves_the_next_sign_in_a_sign_in(
|
||||||
|
client, db, google, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
# A connect left at Google's screen (Back, a closed tab) never reaches the callback that pops
|
||||||
|
# its marker. "Sign in as someone else" afterwards must sign that person in, not link their
|
||||||
|
# Google identity onto the account still signed in here.
|
||||||
|
me = make_user(email="me4@example.com")
|
||||||
|
make_user(email="them4@example.com", google_sub="sub-them4", password=None)
|
||||||
|
monkeypatch.setattr(settings, "allowed_emails", "them4@example.com")
|
||||||
|
_start_link(client, "me4@example.com")
|
||||||
|
assert client.get("/auth/login", follow_redirects=False).headers["location"] == "/fake-google"
|
||||||
|
|
||||||
|
r = _sign_in(client, google, sub="sub-them4", email="them4@example.com")
|
||||||
|
|
||||||
|
assert r.headers["location"] == "/"
|
||||||
|
assert client.get("/api/me").json()["email"] == "them4@example.com"
|
||||||
|
db.refresh(me)
|
||||||
|
assert me.google_sub is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_link_whose_session_was_revoked_meanwhile_does_not_finish(client, db, google, make_user):
|
||||||
|
# "Log out everywhere" (or a password change) between the start and the callback: the cookie
|
||||||
|
# still names the account, but its session no longer holds, so it may not attach an identity.
|
||||||
|
me = make_user(email="me5@example.com")
|
||||||
|
_start_link(client, "me5@example.com")
|
||||||
|
me.session_epoch = (me.session_epoch or 0) + 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = _sign_in(client, google, sub="sub-late", email="late@example.com")
|
||||||
|
|
||||||
|
assert r.headers["location"] == "/?link=error"
|
||||||
|
db.refresh(me)
|
||||||
|
assert me.google_sub is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_older_flow_coming_back_leaves_the_connect_in_flight_a_link(
|
||||||
|
client, db, google, make_user
|
||||||
|
):
|
||||||
|
# Two round-trips from one browser: an older one (another tab's, or Back to Google's screen)
|
||||||
|
# returns after a connect started. Its state is refused, and it must not spend the connect's
|
||||||
|
# marker: the connect itself, coming back, would then land as a plain sign-in.
|
||||||
|
me = make_user(email="me6@example.com")
|
||||||
|
_start_link(client, "me6@example.com")
|
||||||
|
|
||||||
|
older = _sign_in(client, google, state="an-older-flow", sub="sub-6", email="g6@example.com")
|
||||||
|
assert older.headers["location"] == "/?oauth=cancelled"
|
||||||
|
r = _sign_in(client, google, sub="sub-6", email="g6@example.com")
|
||||||
|
|
||||||
|
assert r.headers["location"] == "/?link=ok"
|
||||||
|
db.refresh(me)
|
||||||
|
assert me.google_sub == "sub-6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_connect_from_a_tab_showing_another_account_starts_nothing(client, google, make_user):
|
||||||
|
# A navigation carries no headers, so the tab names the account it was built for in the query.
|
||||||
|
# One still showing the account signed in before must not start a link onto the one signed in
|
||||||
|
# now: it lands on the app, which boots as that one and says why.
|
||||||
|
me = make_user(email="me7@example.com")
|
||||||
|
_password_login(client, "me7@example.com")
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
f"/auth/google/connect?access=identity&expect={me.id + 1}", follow_redirects=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.headers["location"] == auth_routes.ACCOUNT_CHANGED_LANDING == "/?account=changed"
|
||||||
|
assert google.asked == {}
|
||||||
|
assert client.get("/api/me").json()["email"] == "me7@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_connect_naming_the_signed_in_account_goes_to_google(client, google, make_user):
|
||||||
|
me = make_user(email="me8@example.com")
|
||||||
|
_password_login(client, "me8@example.com")
|
||||||
|
|
||||||
|
r = client.get(f"/auth/google/connect?access=identity&expect={me.id}", follow_redirects=False)
|
||||||
|
|
||||||
|
assert r.headers["location"] == "/fake-google"
|
||||||
|
assert google.asked["state"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("access", "scope", "prompt"),
|
||||||
|
[
|
||||||
|
("identity", "openid email profile", "select_account"),
|
||||||
|
("read", auth_routes.READ_SCOPES, "consent"),
|
||||||
|
("write", auth_routes.WRITE_SCOPES, "consent"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_one_connect_route_asks_google_for_what_the_access_needs(
|
||||||
|
client, google, make_user, access, scope, prompt
|
||||||
|
):
|
||||||
|
make_user(email=f"ask-{access}@example.com")
|
||||||
|
_password_login(client, f"ask-{access}@example.com")
|
||||||
|
|
||||||
|
assert _connect(client, access).headers["location"] == "/fake-google"
|
||||||
|
|
||||||
|
assert (google.asked["scope"], google.asked["prompt"]) == (scope, prompt)
|
||||||
|
assert google.asked["access_type"] == "offline"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("access", ["read", "write"])
|
||||||
|
def test_a_youtube_grant_attaches_to_the_password_account_and_returns_to_the_app(
|
||||||
|
client, db, google, make_user, access
|
||||||
|
):
|
||||||
|
me = make_user(email=f"grant-{access}@example.com")
|
||||||
|
_password_login(client, f"grant-{access}@example.com")
|
||||||
|
_connect(client, access)
|
||||||
|
|
||||||
|
r = _sign_in(client, google, sub=f"sub-grant-{access}", email=f"g-{access}@example.com")
|
||||||
|
|
||||||
|
# Back to the app, not the "linked" notice: the onboarding wizard picks up where it was.
|
||||||
|
assert r.headers["location"] == "/"
|
||||||
|
db.refresh(me)
|
||||||
|
assert me.google_sub == f"sub-grant-{access}"
|
||||||
|
assert _rows(db, User, User.google_sub, f"sub-grant-{access}") == [me]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("access", ["", "admin", "READ"])
|
||||||
|
def test_an_unknown_access_starts_no_connect(client, google, make_user, access):
|
||||||
|
make_user(email="odd@example.com")
|
||||||
|
_password_login(client, "odd@example.com")
|
||||||
|
|
||||||
|
assert _connect(client, access).status_code == 422
|
||||||
|
assert google.asked == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_connect_needs_a_signed_in_account(client, google):
|
||||||
|
assert _connect(client, "identity").status_code == 401
|
||||||
|
assert google.asked == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("access", ["identity", "read", "write"])
|
||||||
|
def test_without_google_configured_a_connect_says_it_failed(client, make_user, monkeypatch, access):
|
||||||
|
monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: None)
|
||||||
|
make_user(email=f"nog-{access}@example.com")
|
||||||
|
_password_login(client, f"nog-{access}@example.com")
|
||||||
|
|
||||||
|
assert _connect(client, access).headers["location"] == "/?link=error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["/auth/link", "/auth/upgrade", "/auth/upgrade?access=write"])
|
||||||
|
def test_the_old_connect_routes_are_gone(client, google, make_user, path):
|
||||||
|
make_user(email="old@example.com")
|
||||||
|
_password_login(client, "old@example.com")
|
||||||
|
|
||||||
|
assert client.get(path, follow_redirects=False).status_code == 404
|
||||||
|
assert google.asked == {}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""PUT /api/me/preferences merges the keys it is sent into the stored ones, DB-lane.
|
||||||
|
|
||||||
|
A client saves its preferences a key at a time, and several at once on the boot that moves this
|
||||||
|
browser's copies to the account. Each merge must start from the row as stored when it runs, not as
|
||||||
|
the request first read it — two merges over the same earlier read keep only the last one's keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from sqlalchemy import event, update
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import SessionLocal, get_db
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
_PW = "correct horse battery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account(
|
||||||
|
client, db, make_user, override_dep
|
||||||
|
):
|
||||||
|
user = make_user(email="prefs@example.com", password=_PW)
|
||||||
|
assert (
|
||||||
|
client.post("/auth/password-login", json={"email": "prefs@example.com", "password": _PW})
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
def read_then_another_save(request: Request, session: Session = Depends(get_db)) -> User:
|
||||||
|
# The account as this request reads it, then a second save committing in the meantime.
|
||||||
|
caller = current_user(request, session)
|
||||||
|
with SessionLocal() as other:
|
||||||
|
other.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == caller.id)
|
||||||
|
.values(preferences={"seenVersion": "1.2.3"})
|
||||||
|
)
|
||||||
|
other.commit()
|
||||||
|
return caller
|
||||||
|
|
||||||
|
override_dep(current_user, read_then_another_save)
|
||||||
|
r = client.put("/api/me/preferences", json={"startOnHome": False})
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_merge_reads_the_account_under_a_lock_that_orders_merges_and_nothing_else(
|
||||||
|
client, make_user
|
||||||
|
):
|
||||||
|
# The re-read above is half of it: two merges that both read before either commits would still
|
||||||
|
# keep only the last, unless the read takes a lock the other merge waits for. FOR NO KEY UPDATE
|
||||||
|
# is that lock, and the weakest one: a plain FOR UPDATE would also wait behind any transaction
|
||||||
|
# that only added a row referring to the account (a playlist pull, then minutes of YouTube).
|
||||||
|
make_user(email="prefs-lock@example.com", password=_PW)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/auth/password-login", json={"email": "prefs-lock@example.com", "password": _PW}
|
||||||
|
)
|
||||||
|
).status_code == 200
|
||||||
|
sent: list[str] = []
|
||||||
|
|
||||||
|
def record(_conn, _cursor, statement, *_rest):
|
||||||
|
sent.append(statement)
|
||||||
|
|
||||||
|
event.listen(Engine, "before_cursor_execute", record)
|
||||||
|
try:
|
||||||
|
assert client.put("/api/me/preferences", json={"startOnHome": False}).status_code == 200
|
||||||
|
finally:
|
||||||
|
event.remove(Engine, "before_cursor_execute", record)
|
||||||
|
|
||||||
|
locks = [s for s in sent if "FROM users" in s and " FOR " in s]
|
||||||
|
assert locks, sent
|
||||||
|
assert all(s.rstrip().endswith("FOR NO KEY UPDATE") for s in locks), locks
|
||||||
@@ -48,7 +48,7 @@ def _setup(db, make_user, monkeypatch, tmp_path) -> TestClient:
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
monkeypatch.setattr(app_state, "_configured_cache", True)
|
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||||
monkeypatch.setattr(playback, "resolved_user_id", lambda request: (user.id, True))
|
monkeypatch.setattr(playback, "session_user_id", lambda request: user.id)
|
||||||
monkeypatch.setattr(playback, "PlexClient", _FakePlex)
|
monkeypatch.setattr(playback, "PlexClient", _FakePlex)
|
||||||
monkeypatch.setattr(imgcache, "CACHE_DIR", tmp_path)
|
monkeypatch.setattr(imgcache, "CACHE_DIR", tmp_path)
|
||||||
_FakePlex.calls = []
|
_FakePlex.calls = []
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def test_a_suspended_account_cannot_drive_the_subtitle_extractor(
|
|||||||
monkeypatch.setattr(app_state, "_configured_cache", True)
|
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||||
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
||||||
for mod in (auth, playback):
|
for mod in (auth, playback):
|
||||||
monkeypatch.setattr(mod, "resolved_user_id", lambda request: (user.id, True))
|
monkeypatch.setattr(mod, "session_user_id", lambda request: user.id)
|
||||||
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
||||||
pool = _NoPool()
|
pool = _NoPool()
|
||||||
monkeypatch.setattr(subtitles, "_WIN_POOL", pool)
|
monkeypatch.setattr(subtitles, "_WIN_POOL", pool)
|
||||||
|
|||||||
+2
-1
@@ -107,7 +107,8 @@ project checks needs a row.
|
|||||||
|
|
||||||
| Scope | Narrowed | Why | Measured |
|
| Scope | Narrowed | Why | Measured |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 5 specs × 3 engines |
|
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 9 specs: 6 × 3 engines, 3 on chromium only (the row below) |
|
||||||
|
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 9 specs |
|
||||||
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
|
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
|
||||||
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
||||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ the server logs) can run setup. Then click through:
|
|||||||
|
|
||||||
That's it. You can change any of the optional settings later under the admin **Configuration** page.
|
That's it. You can change any of the optional settings later under the admin **Configuration** page.
|
||||||
|
|
||||||
|
> **Set a password even if you sign in with Google** (Settings → Account → Password). If Google
|
||||||
|
> sign-in stops working — the client secret rotated, the consent revoked, Google down — email and
|
||||||
|
> password still let you in. An account that only ever used Google is otherwise locked out until an
|
||||||
|
> admin creates a set-password link for it (Users).
|
||||||
|
|
||||||
> The setup wizard only exists until you finish it. After that, the setup routes are disabled and
|
> The setup wizard only exists until you finish it. After that, the setup routes are disabled and
|
||||||
> the token is invalidated — there's no setup surface left on a configured instance.
|
> the token is invalidated — there's no setup surface left on a configured instance.
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -25,7 +25,8 @@ that one error again and passes every other failure straight on. Don't run two e
|
|||||||
same machine at once.
|
same machine at once.
|
||||||
|
|
||||||
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
|
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
|
||||||
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile). Install the engines once per
|
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and
|
||||||
|
`devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per
|
||||||
machine with `npx playwright install chromium webkit`; run one project with
|
machine with `npx playwright install chromium webkit`; run one project with
|
||||||
`npx playwright test --project=ipad`. WebKit catches what every iOS browser inherits from the engine
|
`npx playwright test --project=ipad`. WebKit catches what every iOS browser inherits from the engine
|
||||||
(JS, CSS/layout, event order). It does **not** reproduce iPad media refusals: Playwright's WebKit
|
(JS, CSS/layout, event order). It does **not** reproduce iPad media refusals: Playwright's WebKit
|
||||||
@@ -57,9 +58,15 @@ admin so the suite can reach the admin-only pages / render branches, matching th
|
|||||||
user), reset to a deterministic baseline every run by
|
user), reset to a deterministic baseline every run by
|
||||||
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
|
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
|
||||||
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
|
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
|
||||||
one in-progress + one watched + one saved video, prefs pinned to `language: en`. Credentials come
|
one in-progress + one watched + one saved video, prefs pinned to `language: en`. A second account,
|
||||||
from `E2E_EMAIL` (default `e2e@siftlode.local`) and `E2E_PASSWORD` — **required, no default**: run
|
the plain **member** `e2e-member@siftlode.local`, gets the same baseline and its own session
|
||||||
the suite through the ops wrapper, which exports it from the secret store. Override the container name with
|
(`e2e/.auth/member.json`). Two specs run as it: `member.spec.ts`, holding what a non-admin sees (no
|
||||||
|
admin entry, no admin call, no failed request, an admin link lands on the feed), and `devices.spec.ts`,
|
||||||
|
which signs it in on a desktop and an iPad at once and changes its channel layout on the server (put
|
||||||
|
back in a `finally`) — a spec that reads the member's server prefs must not run beside it. Credentials come from `E2E_EMAIL` (default
|
||||||
|
`e2e@siftlode.local`), `E2E_MEMBER_EMAIL` (default `e2e-member@siftlode.local`) and `E2E_PASSWORD`
|
||||||
|
(both accounts) — **required, no default**: run the suite through the ops wrapper, which exports it
|
||||||
|
from the secret store. Override the container name with
|
||||||
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
|
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
|
||||||
|
|
||||||
## Selectors
|
## Selectors
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect, devices, type Browser, type Page } from "@playwright/test";
|
||||||
|
import { MEMBER_STORAGE, gotoPage, openApp } from "./helpers";
|
||||||
|
|
||||||
|
// One member on two devices, a desktop and an iPad, each with its own browser storage. A preference is
|
||||||
|
// a choice about the account, so a change on one device shows on the other; a device setting (here
|
||||||
|
// performance mode, for a weak GPU) stays on the device it was set on. Kept in browser storage only, a
|
||||||
|
// preference used to stay on the one device it was changed on.
|
||||||
|
|
||||||
|
async function device(browser: Browser, descriptor: (typeof devices)[string]): Promise<Page> {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
...descriptor,
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
locale: "en-US",
|
||||||
|
storageState: MEMBER_STORAGE,
|
||||||
|
});
|
||||||
|
return context.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedPrefs = (page: Page) =>
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function pickLayout(page: Page, from: string, to: string): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: `Layout: ${from}` }).click();
|
||||||
|
await Promise.all([savedPrefs(page), page.getByRole("menuitemradio", { name: to }).click()]);
|
||||||
|
await expect(page.getByRole("button", { name: `Layout: ${to}` })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a preference follows the member to another device, a device setting stays", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const desktop = await device(browser, devices["Desktop Chrome"]);
|
||||||
|
const ipad = await device(browser, devices["iPad (gen 7) landscape"]);
|
||||||
|
try {
|
||||||
|
await openApp(desktop);
|
||||||
|
await gotoPage(desktop, "channels");
|
||||||
|
await pickLayout(desktop, "Table", "Cards");
|
||||||
|
await gotoPage(desktop, "settings");
|
||||||
|
await desktop.getByRole("switch", { name: "Performance mode" }).click();
|
||||||
|
await expect(desktop.locator("html")).toHaveAttribute("data-perf", "1");
|
||||||
|
// The Settings prefs save as one payload: the next one (a hints change) would carry performance
|
||||||
|
// mode if it went to the account. Waited for, so the iPad below cannot simply load first.
|
||||||
|
const hints = desktop.getByRole("switch", { name: "Show hints" });
|
||||||
|
const [save] = await Promise.all([savedPrefs(desktop), hints.click()]);
|
||||||
|
expect(Object.keys(save.request().postDataJSON() as object)).not.toContain("performanceMode");
|
||||||
|
await Promise.all([savedPrefs(desktop), hints.click()]);
|
||||||
|
|
||||||
|
await openApp(ipad);
|
||||||
|
await expect(ipad.locator("html")).not.toHaveAttribute("data-perf", "1");
|
||||||
|
await gotoPage(ipad, "channels");
|
||||||
|
await expect(ipad.getByRole("button", { name: "Layout: Cards" })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
// The member is shared by the suite: put the layout back (performance mode went with the
|
||||||
|
// desktop's storage).
|
||||||
|
await gotoPage(desktop, "channels");
|
||||||
|
await pickLayout(desktop, "Cards", "Table");
|
||||||
|
await desktop.context().close();
|
||||||
|
await ipad.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
import { test as setup, expect } from "@playwright/test";
|
import { test as setup, expect, type Page } from "@playwright/test";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdirSync, readFileSync } from "node:fs";
|
import { mkdirSync, readFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { MEMBER_STORAGE } from "./helpers";
|
||||||
import { retryConnect } from "./retryConnect";
|
import { retryConnect } from "./retryConnect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time setup for the characterization net (the `setup` project the tests depend on):
|
* One-time setup for the characterization net (the `setup` project the tests depend on):
|
||||||
* 1. preflight — the localdev :8080 stack must be up (owned by `siftlode dev`);
|
* 1. preflight — the localdev :8080 stack must be up (owned by `siftlode dev`);
|
||||||
* 2. seed — reset the dedicated E2E account to a known baseline, in-container (idempotent);
|
* 2. seed — reset the dedicated E2E accounts to a known baseline, in-container (idempotent);
|
||||||
* 3. authenticate ONCE via password-login and persist the session + `en` locale to
|
* 3. authenticate each ONCE via password-login and persist its session + `en` locale to its own
|
||||||
* storageState, so the real specs never re-auth (auth endpoints are rate-limited).
|
* storageState, so the real specs never re-auth (auth endpoints are rate-limited). The suite
|
||||||
|
* runs as the admin; `member.spec.ts` and `devices.spec.ts` run as the plain member, from
|
||||||
|
* MEMBER_STORAGE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE = "e2e/.auth/state.json";
|
const STORAGE = "e2e/.auth/state.json";
|
||||||
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
|
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
|
||||||
|
const MEMBER_EMAIL = process.env.E2E_MEMBER_EMAIL || "e2e-member@siftlode.local";
|
||||||
// No default: a password committed to a public repo is not a credential. The ops wrapper
|
// No default: a password committed to a public repo is not a credential. The ops wrapper
|
||||||
// (`siftlode e2e` / `check-full`) exports it from the secret store.
|
// (`siftlode e2e` / `check-full`) exports it from the secret store.
|
||||||
const PASSWORD = process.env.E2E_PASSWORD ?? "";
|
const PASSWORD = process.env.E2E_PASSWORD ?? "";
|
||||||
@@ -27,14 +31,44 @@ const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py");
|
|||||||
// The in-container seed only makes sense when the target is the local stack.
|
// The in-container seed only makes sense when the target is the local stack.
|
||||||
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
|
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
|
||||||
|
|
||||||
setup("seed + authenticate", async ({ page }) => {
|
/** Sign `email` in on `page`'s context and save that context as `path`. */
|
||||||
|
async function signIn(page: Page, email: string, path: string): Promise<void> {
|
||||||
|
// These requests run in Node and share the page's cookie jar — so the `session` cookie the login
|
||||||
|
// receives is what storageState saves below — and a localhost connect the machine dropped is
|
||||||
|
// tried again (retryConnect).
|
||||||
|
const api = retryConnect(page.request);
|
||||||
|
const login = await api.post("/auth/password-login", { data: { email, password: PASSWORD } });
|
||||||
|
expect(
|
||||||
|
login.ok(),
|
||||||
|
`password-login failed for ${email} — is the E2E account seeded?`,
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
const me = await (await api.get("/api/me")).json();
|
||||||
|
expect(me.authenticated, `session not established after login (${email})`).toBeTruthy();
|
||||||
|
|
||||||
|
// Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay that
|
||||||
|
// would intercept every click — the test accounts have no YouTube connection, so it auto-opens),
|
||||||
|
// and opt the account out of starting on Home. A bare URL lands on Home, and the feed, channel
|
||||||
|
// and player flows this net locks all open "/" expecting the feed; the opt-out is the one
|
||||||
|
// Settings → Appearance → Startup writes. Both flags are the account's local state, held for the
|
||||||
|
// account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData).
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate((id: number) => {
|
||||||
|
localStorage.setItem("siftlode.owner", String(id));
|
||||||
|
localStorage.setItem("siftlode.lang", "en");
|
||||||
|
localStorage.setItem("siftlode.onboarding.dismissed", "1");
|
||||||
|
localStorage.setItem("siftlode.startOnHome", "0");
|
||||||
|
}, me.id);
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
await page.context().storageState({ path });
|
||||||
|
}
|
||||||
|
|
||||||
|
setup("seed + authenticate", async ({ page, browser }) => {
|
||||||
expect(
|
expect(
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
"E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)",
|
"E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)",
|
||||||
).not.toBe("");
|
).not.toBe("");
|
||||||
|
|
||||||
// These requests run in Node (sharing the page's cookie jar), so a localhost connect the machine
|
|
||||||
// dropped is tried again (retryConnect).
|
|
||||||
const api = retryConnect(page.request);
|
const api = retryConnect(page.request);
|
||||||
|
|
||||||
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
|
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
|
||||||
@@ -55,6 +89,8 @@ setup("seed + authenticate", async ({ page }) => {
|
|||||||
"-e",
|
"-e",
|
||||||
`E2E_EMAIL=${EMAIL}`,
|
`E2E_EMAIL=${EMAIL}`,
|
||||||
"-e",
|
"-e",
|
||||||
|
`E2E_MEMBER_EMAIL=${MEMBER_EMAIL}`,
|
||||||
|
"-e",
|
||||||
`E2E_PASSWORD=${PASSWORD}`,
|
`E2E_PASSWORD=${PASSWORD}`,
|
||||||
CONTAINER,
|
CONTAINER,
|
||||||
"python",
|
"python",
|
||||||
@@ -69,28 +105,12 @@ setup("seed + authenticate", async ({ page }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Authenticate. page.request shares the browser context's cookie jar, so the `session`
|
// 3. Authenticate each account in its own context: one cookie jar holds one session.
|
||||||
// cookie it receives is captured by storageState below.
|
await signIn(page, EMAIL, STORAGE);
|
||||||
const login = await api.post("/auth/password-login", {
|
const memberContext = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
data: { email: EMAIL, password: PASSWORD },
|
try {
|
||||||
});
|
await signIn(await memberContext.newPage(), MEMBER_EMAIL, MEMBER_STORAGE);
|
||||||
expect(login.ok(), "password-login failed — is the E2E account seeded?").toBeTruthy();
|
} finally {
|
||||||
|
await memberContext.close();
|
||||||
const me = await (await api.get("/api/me")).json();
|
}
|
||||||
expect(me.authenticated, "session not established after login").toBeTruthy();
|
|
||||||
|
|
||||||
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
|
|
||||||
// that would intercept every click — the test account has no YouTube connection, so it
|
|
||||||
// auto-opens), and opt the account out of starting on Home. A bare URL lands on
|
|
||||||
// Home, and the feed, channel and player flows this net locks all open "/" expecting the
|
|
||||||
// feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are
|
|
||||||
// per-account: `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
|
|
||||||
await page.goto("/");
|
|
||||||
await page.evaluate((id: number) => {
|
|
||||||
localStorage.setItem("siftlode.lang", "en");
|
|
||||||
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
|
|
||||||
localStorage.setItem(`siftlode.startOnHome.${id}`, "0");
|
|
||||||
}, me.id);
|
|
||||||
mkdirSync(dirname(STORAGE), { recursive: true });
|
|
||||||
await page.context().storageState({ path: STORAGE });
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
|
||||||
|
* `devices.spec.ts` run from it. */
|
||||||
|
export const MEMBER_STORAGE = "e2e/.auth/member.json";
|
||||||
|
|
||||||
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
|
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
|
||||||
export async function openApp(page: Page, path = "/"): Promise<void> {
|
export async function openApp(page: Page, path = "/"): Promise<void> {
|
||||||
await page.goto(path);
|
await page.goto(path);
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { test, expect, type Page, type Request, type Response } from "@playwright/test";
|
||||||
|
import { MEMBER_STORAGE, openApp } from "./helpers";
|
||||||
|
|
||||||
|
// What a plain member (not an admin) sees. The rest of the suite runs as the admin, so without this
|
||||||
|
// spec nothing end to end holds the member's shell: the backend refuses the admin routes (pytest),
|
||||||
|
// but a member shown an admin entry, sent to an admin page or met by a refused request would pass.
|
||||||
|
test.use({ storageState: MEMBER_STORAGE });
|
||||||
|
|
||||||
|
const ADMIN_PAGES = ["scheduler", "config", "users", "activity"];
|
||||||
|
// The pages every member has; Plex joins them only where the instance enables it.
|
||||||
|
const CORE = [
|
||||||
|
"home",
|
||||||
|
"feed",
|
||||||
|
"channels",
|
||||||
|
"playlists",
|
||||||
|
"notifications",
|
||||||
|
"files",
|
||||||
|
"stats",
|
||||||
|
"settings",
|
||||||
|
];
|
||||||
|
|
||||||
|
const isApi = (url: string) => new URL(url).pathname.startsWith("/api/");
|
||||||
|
|
||||||
|
/** Collect every admin call and every failed API answer the page gets while `run` goes. `run` is
|
||||||
|
* handed `settled`, which waits until no API request of the page is in flight: an error state
|
||||||
|
* renders only once its request has answered, and a check made before that passes on a page that
|
||||||
|
* is still loading. */
|
||||||
|
async function watchRequests(
|
||||||
|
page: Page,
|
||||||
|
run: (settled: () => Promise<void>) => Promise<void>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const bad: string[] = [];
|
||||||
|
const pending = new Set<Request>();
|
||||||
|
const onRequest = (r: Request) => {
|
||||||
|
if (!isApi(r.url())) return;
|
||||||
|
pending.add(r);
|
||||||
|
if (new URL(r.url()).pathname.startsWith("/api/admin")) bad.push(`admin call ${r.url()}`);
|
||||||
|
};
|
||||||
|
const onDone = (r: Request) => pending.delete(r);
|
||||||
|
const onResponse = (r: Response) => {
|
||||||
|
if (isApi(r.url()) && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`);
|
||||||
|
};
|
||||||
|
const settled = () => expect.poll(() => pending.size, { timeout: 15_000 }).toBe(0);
|
||||||
|
page.on("request", onRequest);
|
||||||
|
page.on("requestfinished", onDone);
|
||||||
|
page.on("requestfailed", onDone);
|
||||||
|
page.on("response", onResponse);
|
||||||
|
try {
|
||||||
|
await run(settled);
|
||||||
|
await settled();
|
||||||
|
} finally {
|
||||||
|
page.off("request", onRequest);
|
||||||
|
page.off("requestfinished", onDone);
|
||||||
|
page.off("requestfailed", onDone);
|
||||||
|
page.off("response", onResponse);
|
||||||
|
}
|
||||||
|
return bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("a member", () => {
|
||||||
|
test("gets no admin entry, and every page of the rail opens without an error", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const bad = await watchRequests(page, async (settled) => {
|
||||||
|
await openApp(page);
|
||||||
|
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
|
||||||
|
|
||||||
|
const rail = page
|
||||||
|
.getByRole("navigation", { name: "Primary navigation" })
|
||||||
|
.locator('[data-testid^="nav-"]');
|
||||||
|
const ids = (await rail.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid"))))
|
||||||
|
.filter((id): id is string => id !== null)
|
||||||
|
.map((id) => id.replace(/^nav-/, ""));
|
||||||
|
expect(ids).toEqual(expect.arrayContaining(CORE));
|
||||||
|
expect(ids.filter((id) => ADMIN_PAGES.includes(id))).toEqual([]);
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
await page.getByTestId(`nav-${id}`).click();
|
||||||
|
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
|
||||||
|
await settled();
|
||||||
|
// An error state is a `role=alert` StateMessage; a page that failed to load shows one.
|
||||||
|
await expect(page.getByRole("alert"), `error state on ${id}`).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(bad).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a link to an admin page opens no admin page", async ({ page }) => {
|
||||||
|
for (const id of ADMIN_PAGES) {
|
||||||
|
const bad = await watchRequests(page, () => openApp(page, `/?page=${id}`));
|
||||||
|
expect(bad, id).toEqual([]);
|
||||||
|
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
|
||||||
|
// The member lands on the feed instead, and the rail says so.
|
||||||
|
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,9 @@ import { openApp, gotoPage } from "./helpers";
|
|||||||
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
|
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
|
||||||
// it; leaving Settings after a change is no longer guarded.
|
// it; leaving Settings after a change is no longer guarded.
|
||||||
test.describe("settings auto-save", () => {
|
test.describe("settings auto-save", () => {
|
||||||
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
|
// An Appearance switch saved to the account (locale pinned to en). Not performance mode: that one
|
||||||
|
// is the device's and is never sent.
|
||||||
|
const TOGGLE = "Show hints";
|
||||||
|
|
||||||
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
|
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
|
||||||
const putPrefs = (page: Page) =>
|
const putPrefs = (page: Page) =>
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
|
|
||||||
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
||||||
const STORAGE_STATE = "e2e/.auth/state.json";
|
const STORAGE_STATE = "e2e/.auth/state.json";
|
||||||
// The axe scan reads the DOM and the accessibility tree, which the engines build alike: one engine
|
// The axe scan reads the DOM and the accessibility tree, which the engines build alike, and the
|
||||||
// answers for all, so the other projects leave it out instead of tripling its time.
|
// member spec checks which pages and requests a role gets, which no engine changes, and the devices
|
||||||
const CHROMIUM_ONLY = /a11y\.spec\.ts/;
|
// spec opens its own desktop and iPad contexts: one engine answers for all, so the other projects
|
||||||
|
// leave them out instead of tripling their time.
|
||||||
|
const CHROMIUM_ONLY = /(a11y|member|devices)\.spec\.ts/;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|||||||
+20
-19
@@ -7,7 +7,8 @@ import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
|||||||
import { notifyActionError } from "./lib/actionErrors";
|
import { notifyActionError } from "./lib/actionErrors";
|
||||||
import { setLanguage, type LangCode } from "./i18n";
|
import { setLanguage, type LangCode } from "./i18n";
|
||||||
import { clearParams } from "./lib/urlState";
|
import { clearParams } from "./lib/urlState";
|
||||||
import type { Page } from "./lib/urlState";
|
import { reloadAfterRefusal } from "./lib/entryUrl";
|
||||||
|
import { useAccountSwitchNotice } from "./lib/useAccountSwitchNotice";
|
||||||
import {
|
import {
|
||||||
PREF_KEY,
|
PREF_KEY,
|
||||||
saveLayoutLocal,
|
saveLayoutLocal,
|
||||||
@@ -22,7 +23,7 @@ import { useVerifyEntry } from "./lib/useVerifyEntry";
|
|||||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
|
||||||
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
|
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
|
||||||
import { moduleDef, moduleOrder } from "./lib/modules";
|
import { moduleOrder } from "./lib/modules";
|
||||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
|
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
|
||||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||||
import { LoadingState } from "./components/ui/QueryState";
|
import { LoadingState } from "./components/ui/QueryState";
|
||||||
@@ -231,16 +232,19 @@ export default function App() {
|
|||||||
if (signedIn) clearParams("access", "verify", "reset");
|
if (signedIn) clearParams("access", "verify", "reset");
|
||||||
}, [signedIn]);
|
}, [signedIn]);
|
||||||
|
|
||||||
|
// A sign-in as someone else that the server refused, or a reload as another account: say so, once.
|
||||||
|
useAccountSwitchNotice(signedIn);
|
||||||
|
|
||||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||||
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
|
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
|
||||||
// can't loop the public landing.
|
// can't loop the public landing. A tab showing another account than the one signed in now
|
||||||
|
// reloads as that one instead, and says why (`reloadAfterRefusal`).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler((err) => {
|
||||||
if (queryClient.getQueryData(qk.me())) {
|
if (!queryClient.getQueryData(qk.me())) return;
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
window.location.reload();
|
reloadAfterRefusal(err);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return () => setUnauthorizedHandler(null);
|
return () => setUnauthorizedHandler(null);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
@@ -271,8 +275,8 @@ export default function App() {
|
|||||||
clearParams("link");
|
clearParams("link");
|
||||||
}, [t, setPage, refetchMe]);
|
}, [t, setPage, refetchMe]);
|
||||||
|
|
||||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
|
||||||
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
||||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
||||||
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
||||||
const oauthAnswered = useRef(false);
|
const oauthAnswered = useRef(false);
|
||||||
@@ -373,14 +377,11 @@ export default function App() {
|
|||||||
? `yt:${ytSearch}`
|
? `yt:${ytSearch}`
|
||||||
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
||||||
|
|
||||||
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A
|
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
|
||||||
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
|
// the account can't reach never gets here: the navigation state already moved it to the feed
|
||||||
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
|
// (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
|
||||||
// side rail shows behind it.
|
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page];
|
||||||
const pageGate = moduleDef(page).gate;
|
const railDef = channelView ? undefined : PAGE_RAIL[page];
|
||||||
const activePage: Page = !pageGate || pageGate(me!) ? page : "feed";
|
|
||||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
|
|
||||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-dvh flex text-fg">
|
<div className="h-dvh flex text-fg">
|
||||||
@@ -444,7 +445,7 @@ export default function App() {
|
|||||||
</PageShell>
|
</PageShell>
|
||||||
) : (
|
) : (
|
||||||
<PageShell
|
<PageShell
|
||||||
title={t(pageTitleKey(activePage))}
|
title={t(pageTitleKey(page))}
|
||||||
scrollKey={normalScrollKey}
|
scrollKey={normalScrollKey}
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
|
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab built
|
||||||
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
|
// the app for "no account" first — so with Settings → Appearance → Startup turned off,
|
||||||
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
|
// NavigationProvider's initializer could not read the opt-out and landed on Home, mirrored ?page=home
|
||||||
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
|
// into the address bar, and then re-read its own mirror once the account was known, staying on Home.
|
||||||
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
|
|
||||||
//
|
//
|
||||||
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
|
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
|
||||||
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
|
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
|
||||||
@@ -17,7 +16,6 @@ import BootGate from "./BootGate";
|
|||||||
import { NavigationProvider } from "./NavigationProvider";
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
import { useNavigation } from "./navigationContext";
|
import { useNavigation } from "./navigationContext";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
|
|
||||||
@@ -47,13 +45,23 @@ function mount() {
|
|||||||
);
|
);
|
||||||
return qc;
|
return qc;
|
||||||
}
|
}
|
||||||
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
|
// On a Plex-enabled instance: the remembered page these cases restore is the Plex module, which only
|
||||||
|
// an instance with Plex lets an account open.
|
||||||
|
const account = (over: Partial<Me> = {}) =>
|
||||||
|
({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me;
|
||||||
let savePrefs: MockInstance<typeof api.savePrefs>;
|
let savePrefs: MockInstance<typeof api.savePrefs>;
|
||||||
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
||||||
|
// Whose local state the browser holds (storage.ts claimLocalData).
|
||||||
|
const owner = () => localStorage.getItem("siftlode.owner");
|
||||||
|
// The browser already holds account 5's state, as on any visit after the first.
|
||||||
|
const heldBy5 = (entries: Record<string, string>) => {
|
||||||
|
localStorage.setItem("siftlode.owner", "5");
|
||||||
|
for (const [k, v] of Object.entries(entries)) localStorage.setItem(k, v);
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear(); // a NEW tab: nothing pinned yet
|
sessionStorage.clear(); // a NEW tab
|
||||||
window.history.replaceState(null, "", "/");
|
window.history.replaceState(null, "", "/");
|
||||||
renders.length = 0;
|
renders.length = 0;
|
||||||
mounts = 0;
|
mounts = 0;
|
||||||
@@ -69,15 +77,14 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("BootGate", () => {
|
describe("BootGate", () => {
|
||||||
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
|
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
|
||||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
heldBy5({ "siftlode.startOnHome": "0", "siftlode.page": "plex" });
|
||||||
localStorage.setItem("siftlode.page.5", "plex");
|
|
||||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
||||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
||||||
expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
|
expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
|
expect(owner()).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the app in the account's language from its first render", async () => {
|
it("renders the app in the account's language from its first render", async () => {
|
||||||
@@ -117,12 +124,12 @@ describe("BootGate", () => {
|
|||||||
expect(renders).toHaveLength(0);
|
expect(renders).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
|
it("signed out: mounts the app (App shows the landing) without claiming the local state", async () => {
|
||||||
vi.spyOn(api, "me").mockResolvedValue(null);
|
vi.spyOn(api, "me").mockResolvedValue(null);
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
await screen.findByTestId("app");
|
await screen.findByTestId("app");
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
expect(owner()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
|
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
|
||||||
@@ -132,7 +139,7 @@ describe("BootGate", () => {
|
|||||||
|
|
||||||
await screen.findByTestId("app");
|
await screen.findByTestId("app");
|
||||||
expect(me).not.toHaveBeenCalled();
|
expect(me).not.toHaveBeenCalled();
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
expect(owner()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
|
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
|
||||||
@@ -145,7 +152,7 @@ describe("BootGate", () => {
|
|||||||
fireEvent.click(await retryButton());
|
fireEvent.click(await retryButton());
|
||||||
await screen.findByTestId("app");
|
await screen.findByTestId("app");
|
||||||
expect(renders.length).toBeGreaterThan(0);
|
expect(renders.length).toBeGreaterThan(0);
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
|
expect(owner()).toBe("5"); // it mounted for the account
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an unanswered first account probe is an error with a retry", async () => {
|
it("an unanswered first account probe is an error with a retry", async () => {
|
||||||
@@ -154,7 +161,7 @@ describe("BootGate", () => {
|
|||||||
|
|
||||||
fireEvent.click(await retryButton());
|
fireEvent.click(await retryButton());
|
||||||
await screen.findByTestId("app");
|
await screen.findByTestId("app");
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
expect(owner()).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a background refetch that fails keeps the app mounted, as it was", async () => {
|
it("a background refetch that fails keeps the app mounted, as it was", async () => {
|
||||||
@@ -172,17 +179,30 @@ describe("BootGate", () => {
|
|||||||
expect(mounts).toBe(1);
|
expect(mounts).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
|
it("an account signing in where another one's state is held never reads it, not for one render", async () => {
|
||||||
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
|
// Account 9 used this browser last, and left it on Plex with Startup off.
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
|
localStorage.setItem("siftlode.owner", "9");
|
||||||
|
localStorage.setItem("siftlode.startOnHome", "0");
|
||||||
|
localStorage.setItem("siftlode.page", "plex");
|
||||||
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||||
|
mount();
|
||||||
|
|
||||||
|
expect((await screen.findByTestId("app")).textContent).toBe("home");
|
||||||
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "home"));
|
||||||
|
expect(owner()).toBe("5");
|
||||||
|
expect(localStorage.getItem("siftlode.page")).not.toBe("plex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an account's state left under the old per-account keys is the one it boots on", async () => {
|
||||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
localStorage.setItem("siftlode.startOnHome.5", "0");
|
||||||
localStorage.setItem("siftlode.page.5", "plex");
|
localStorage.setItem("siftlode.page.5", "plex");
|
||||||
|
localStorage.setItem("siftlode.page.9", "files");
|
||||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
||||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
expect(localStorage.getItem("siftlode.page.9")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
|
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
|
||||||
@@ -198,20 +218,20 @@ describe("BootGate", () => {
|
|||||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an identity change reloads without moving the pin first", async () => {
|
it("an identity change reloads without handing the local state over first", async () => {
|
||||||
// The tree being torn down still runs its unmount saves before the page goes; with the pin already
|
// The tree being torn down still runs its unmount saves before the page goes; with the state
|
||||||
// moved they would land under the NEW account's local keys (a player volume still pending, say).
|
// already handed over they would land in the NEW account's (a player volume still pending, say).
|
||||||
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
||||||
const qc = mount();
|
const qc = mount();
|
||||||
await screen.findByTestId("app");
|
await screen.findByTestId("app");
|
||||||
const reload = vi.fn();
|
const reload = vi.fn();
|
||||||
vi.stubGlobal("location", { ...window.location, reload });
|
vi.stubGlobal("location", { ...window.location, reload });
|
||||||
|
|
||||||
me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6
|
me.mockResolvedValue(account({ id: 6 })); // another tab signed this browser in as 6
|
||||||
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
||||||
|
|
||||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
expect(owner()).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
|
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react";
|
import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
|
import { claimLocalData } from "../lib/storage";
|
||||||
import { saveLanguage } from "../lib/saveLanguage";
|
import { saveLanguage } from "../lib/saveLanguage";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import i18n, { isSupported, setLanguage } from "../i18n";
|
import i18n, { isSupported, setLanguage } from "../i18n";
|
||||||
@@ -14,17 +15,16 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
|||||||
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
|
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
|
||||||
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
|
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
|
||||||
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
|
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
|
||||||
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
|
// patched each piece once <App> knew the account. Every patch was a visible jump at best, and at
|
||||||
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
|
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
|
||||||
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
|
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
|
||||||
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
|
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
|
||||||
// the provider tree — whose initializers are then simply right, with nothing to re-read.
|
// the provider tree — whose initializers are then simply right, with nothing to re-read.
|
||||||
//
|
//
|
||||||
// The per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to
|
// The per-account initializers read the browser's local state, which holds one account's: it is handed
|
||||||
// match the account the server answered as before they run. And the tree is only ever built for one
|
// to the account the server answered as (claimLocalData) before they run. And the tree is only ever
|
||||||
// identity: if a mounted tab's identity changes without a reload (its session ended, or the account it
|
// built for one identity: if a mounted tab's identity changes without a reload (its session ended, or
|
||||||
// was pinned to was signed out in another tab and the server now answers as the browser's default),
|
// another tab signed this browser in as someone else), the tab reloads — which also drops the previous
|
||||||
// the tab reloads, like every account switch does (NavSidebar) — which also drops the previous
|
|
||||||
// account's query cache, whose keys are not per account.
|
// account's query cache, whose keys are not per account.
|
||||||
|
|
||||||
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared
|
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared
|
||||||
@@ -76,13 +76,10 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
|||||||
: (me?.id ?? null);
|
: (me?.id ?? null);
|
||||||
|
|
||||||
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
|
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
|
||||||
// before the browser paints: the frame that first shows the app already has the account pinned.
|
// before the browser paints: the frame that first shows the app already reads the account's state.
|
||||||
// • Pin this tab to the account the server answered as. Without the pin, a tab riding the
|
// • Hand the browser's local state to the account the server answered as: a different account
|
||||||
// session's default account would silently swap identity when ANOTHER tab changes that default
|
// than the one whose state the browser holds starts from a clean slate, so it never reads (or
|
||||||
// (adds or switches an account); once pinned it sends its own account header. The server honours
|
// writes over) the previous one's filters, history or positions.
|
||||||
// that header only for an account still signed in to this browser, so a pin naming one that is
|
|
||||||
// not is corrected here — otherwise the tab would run as the default account on the server while
|
|
||||||
// reading and writing the gone account's local state.
|
|
||||||
// • Adopt the account's language, which follows the account across devices — the local copy only
|
// • Adopt the account's language, which follows the account across devices — the local copy only
|
||||||
// knows what this browser last showed. An account with none stored yet (the setup wizard's admin,
|
// knows what this browser last showed. An account with none stored yet (the setup wizard's admin,
|
||||||
// an account older than the stamp sign-up gives) stores the language it is shown in: the server
|
// an account older than the stamp sign-up gives) stores the language it is shown in: the server
|
||||||
@@ -98,16 +95,16 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
|||||||
if (identity === undefined) return;
|
if (identity === undefined) return;
|
||||||
if (readyFor !== undefined) {
|
if (readyFor !== undefined) {
|
||||||
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
|
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
|
||||||
// see the header. The pin stays as it is: the tree being torn down still runs its unmount saves
|
// see the header. The local state is not handed over here: the tree being torn down still runs
|
||||||
// before the page goes, and with the pin moved they would land under the new account's local
|
// its unmount saves before the page goes, and they belong to the account it was built for. The
|
||||||
// keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for
|
// reloaded page's claim settles it. Comparing rather than merely testing for
|
||||||
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
|
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
|
||||||
// instead of a reload.
|
// instead of a reload.
|
||||||
if (readyFor !== identity) location.reload();
|
if (readyFor !== identity) location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const account = adoptedMe();
|
const account = adoptedMe();
|
||||||
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
|
if (account) claimLocalData(account.id);
|
||||||
const lang = account?.preferences?.language;
|
const lang = account?.preferences?.language;
|
||||||
if (typeof lang === "string" && isSupported(lang)) {
|
if (typeof lang === "string" && isSupported(lang)) {
|
||||||
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
|
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
|
||||||
@@ -121,7 +118,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the local state is claimed and the language applied, and both are writes to things outside React (storage, i18n). A layout effect so the frame that first shows the app already has them.
|
||||||
setReadyFor(identity);
|
setReadyFor(identity);
|
||||||
}, [identity, readyFor, qc]);
|
}, [identity, readyFor, qc]);
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { invalidateSubscription } from "../lib/subscriptionCache";
|
import { invalidateSubscription } from "../lib/subscriptionCache";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
import { api, type DiscoveredChannel } from "../lib/api";
|
import { api, type DiscoveredChannel } from "../lib/api";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { LS, ownKey } from "../lib/storage";
|
||||||
import { formatCountOrDash } from "../lib/format";
|
import { formatCountOrDash } from "../lib/format";
|
||||||
import { sortRows } from "../lib/columnSort";
|
import { hydrateSort, sortRows, type SortState } from "../lib/columnSort";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { usePref } from "../lib/usePref";
|
||||||
import { useCardPager } from "../lib/useCardPager";
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
@@ -48,7 +48,10 @@ export default function ChannelDiscovery({
|
|||||||
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
||||||
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
||||||
const layout = useChannelLayout();
|
const layout = useChannelLayout();
|
||||||
const [sort, setSort] = useSortState(LS.channelDiscoverySort);
|
const [sort, setSort] = usePref<SortState>("channelDiscoverySort", null, {
|
||||||
|
localKey: LS.channelDiscoverySort,
|
||||||
|
parse: (raw) => hydrateSort(raw == null ? null : JSON.stringify(raw), []),
|
||||||
|
});
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: qk.discoveredChannels(),
|
queryKey: qk.discoveredChannels(),
|
||||||
@@ -234,7 +237,7 @@ export default function ChannelDiscovery({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
persistKey={ownKey(LS.channelDiscoveryTable) ?? undefined}
|
||||||
controlsPosition="both"
|
controlsPosition="both"
|
||||||
controlsInBand
|
controlsInBand
|
||||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import ExcludedChannelList from "./ExcludedChannelList";
|
|||||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { LS, ownKey } from "../lib/storage";
|
||||||
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
|
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
@@ -824,7 +824,7 @@ export default function Channels() {
|
|||||||
rows={syncedChannels}
|
rows={syncedChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
persistKey={ownKey(LS.channelsTable) ?? undefined}
|
||||||
// Folded, the status / pager row leaves the band; the list's own end keeps it.
|
// Folded, the status / pager row leaves the band; the list's own end keeps it.
|
||||||
controlsPosition={fold.folded ? "bottom" : "both"}
|
controlsPosition={fold.folded ? "bottom" : "both"}
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
||||||
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
|
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
|
||||||
//
|
//
|
||||||
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
|
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
|
||||||
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
|
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
|
||||||
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
|
// navigationContext is mocked, pinned to the channels page. The layout is an account preference
|
||||||
|
// (usePref), read from the `me` cache BootGate fills before the app mounts.
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
||||||
import { useEffect, useEffectEvent, type ReactNode } from "react";
|
import { useEffect, useEffectEvent, type ReactNode } from "react";
|
||||||
@@ -17,17 +18,28 @@ vi.mock("./navigationContext", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { ChannelsProvider } from "./ChannelsProvider";
|
import { ChannelsProvider } from "./ChannelsProvider";
|
||||||
import { useChannels, useChannelsActions } from "./channelsContext";
|
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||||
|
import { claimLocalData } from "../lib/storage";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
let qc = new QueryClient();
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<ChannelsProvider>{children}</ChannelsProvider>
|
<QueryClientProvider client={qc}>
|
||||||
|
<ChannelsProvider>{children}</ChannelsProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const withClient = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
);
|
);
|
||||||
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
||||||
const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id)
|
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
claimLocalData(1); // the browser holds account 1's state
|
||||||
|
qc = new QueryClient();
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, preferences: {} });
|
||||||
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
||||||
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
||||||
// this provider's mount-time URL seed and change the starting tab.
|
// this provider's mount-time URL seed and change the starting tab.
|
||||||
@@ -107,10 +119,10 @@ describe("ChannelsProvider — shareable URL", () => {
|
|||||||
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
|
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
|
||||||
// persisting the seed overwrote the account's real stored sort + filter map.
|
// persisting the seed overwrote the account's real stored sort + filter map.
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"siftlode.channelSort.1",
|
"siftlode.channelSort",
|
||||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||||
);
|
);
|
||||||
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
|
localStorage.setItem("siftlode.channelsFilterMap", JSON.stringify({ sync: "needsFull" }));
|
||||||
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||||
|
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
@@ -120,11 +132,11 @@ describe("ChannelsProvider — shareable URL", () => {
|
|||||||
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
||||||
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
||||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||||
expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
|
expect(JSON.parse(localStorage.getItem("siftlode.channelSort")!)).toEqual({
|
||||||
key: "subscribers",
|
key: "subscribers",
|
||||||
dir: "desc",
|
dir: "desc",
|
||||||
});
|
});
|
||||||
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
|
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap")!)).toEqual({
|
||||||
sync: "needsFull",
|
sync: "needsFull",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -165,6 +177,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
|
|||||||
<Probe />
|
<Probe />
|
||||||
<Harness mounted />
|
<Harness mounted />
|
||||||
</ChannelsProvider>,
|
</ChannelsProvider>,
|
||||||
|
{ wrapper: withClient },
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => api.focusChannel("Kurzgesagt"));
|
act(() => api.focusChannel("Kurzgesagt"));
|
||||||
@@ -201,6 +214,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
|
|||||||
<Probe />
|
<Probe />
|
||||||
<Harness mounted />
|
<Harness mounted />
|
||||||
</ChannelsProvider>,
|
</ChannelsProvider>,
|
||||||
|
{ wrapper: withClient },
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => api.focusChannel("Kurzgesagt"));
|
act(() => api.focusChannel("Kurzgesagt"));
|
||||||
@@ -209,3 +223,14 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
|
|||||||
expect(api.search).toBe("Kurzgesagt");
|
expect(api.search).toBe("Kurzgesagt");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ChannelsProvider — the layout", () => {
|
||||||
|
// The table or the cards is a choice about the account, so it follows it to every device: the
|
||||||
|
// server's word wins over what this browser last showed.
|
||||||
|
it("is the account's preference, over this browser's copy", () => {
|
||||||
|
localStorage.setItem("siftlode.channelLayout", "table");
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, preferences: { channelLayout: "cards" } });
|
||||||
|
const { result } = renderHook(useChannelLayout, { wrapper });
|
||||||
|
expect(result.current).toBe("cards");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { parseChannelLayout } from "../lib/channelLayout";
|
|||||||
import { hydrateSort } from "../lib/columnSort";
|
import { hydrateSort } from "../lib/columnSort";
|
||||||
import { useLatest } from "../lib/useLatest";
|
import { useLatest } from "../lib/useLatest";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { useSortState } from "../lib/useSortState";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import { useFilterState } from "../lib/useFilterState";
|
import { useFilterState } from "../lib/useFilterState";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { rememberNavPosition } from "../lib/history";
|
import { rememberNavPosition } from "../lib/history";
|
||||||
@@ -80,7 +81,7 @@ function readStoredFilters(): FilterMap {
|
|||||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||||
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
|
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
|
||||||
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
||||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -100,12 +101,15 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||||
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||||
// view + layout are persisted tabs (useAccountPersistedState = the shared account-scoped tab-state hook).
|
// The view is this browser's (useAccountPersistedState), the layout an account preference
|
||||||
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
|
// (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers
|
||||||
// logic (below) on top of the raw setter.
|
// the history logic (below) on top of the raw setter.
|
||||||
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
|
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||||
const view = clampView(viewRaw);
|
const view = clampView(viewRaw);
|
||||||
const [layoutRaw, setLayout] = useAccountPersistedState(LS.channelLayout, "table");
|
const [layoutRaw, setLayout] = usePref<string>("channelLayout", "table", {
|
||||||
|
localKey: LS.channelLayout,
|
||||||
|
parse: (raw) => (typeof raw === "string" ? raw : "table"),
|
||||||
|
});
|
||||||
const layout = parseChannelLayout(layoutRaw);
|
const layout = parseChannelLayout(layoutRaw);
|
||||||
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
||||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||||
|
|||||||
@@ -5,30 +5,31 @@
|
|||||||
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
|
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { loadAccountFilters } from "./feedFiltersInit";
|
import { loadAccountFilters } from "./feedFiltersInit";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { claimLocalData, LS } from "../lib/storage";
|
||||||
|
|
||||||
const ID = 7;
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
beforeEach(() => localStorage.clear());
|
claimLocalData(7);
|
||||||
|
});
|
||||||
|
|
||||||
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||||
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
|
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
|
||||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
|
localStorage.setItem(LS.filters, JSON.stringify({ q: "hi", tagMode: "and" }));
|
||||||
const f = loadAccountFilters(ID);
|
const f = loadAccountFilters();
|
||||||
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
||||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||||
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
expect(loadAccountFilters().tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves an already-migrated per-category tagModes blob", () => {
|
it("preserves an already-migrated per-category tagModes blob", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
accountKey(LS.filters, ID)!,
|
LS.filters,
|
||||||
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
|
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
|
||||||
);
|
);
|
||||||
expect(loadAccountFilters(ID).tagModes).toEqual({
|
expect(loadAccountFilters().tagModes).toEqual({
|
||||||
language: "and",
|
language: "and",
|
||||||
topic: "or",
|
topic: "or",
|
||||||
other: "and",
|
other: "and",
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { NavigationProvider } from "./NavigationProvider";
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
import { FeedFiltersProvider } from "./FeedFiltersProvider";
|
import { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||||
import { useFeedFilters } from "./feedFiltersContext";
|
import { useFeedFilters } from "./feedFiltersContext";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import type { Me } from "../lib/api";
|
import type { Me } from "../lib/api";
|
||||||
|
import { claimLocalData } from "../lib/storage";
|
||||||
|
|
||||||
const scopes: string[] = [];
|
const scopes: string[] = [];
|
||||||
function Probe() {
|
function Probe() {
|
||||||
@@ -34,7 +34,7 @@ function mount(me: Partial<Me>) {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
claimLocalData(5);
|
||||||
scopes.length = 0;
|
scopes.length = 0;
|
||||||
window.history.replaceState(null, "", "/");
|
window.history.replaceState(null, "", "/");
|
||||||
});
|
});
|
||||||
@@ -53,6 +53,6 @@ describe("FeedFiltersProvider seed", () => {
|
|||||||
window.history.replaceState(null, "", "/?scope=all");
|
window.history.replaceState(null, "", "/?scope=all");
|
||||||
mount({});
|
mount({});
|
||||||
expect(scopes[0]).toBe("all");
|
expect(scopes[0]).toBe("all");
|
||||||
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
|
expect(JSON.parse(localStorage.getItem("siftlode.filters") ?? "{}").scope).toBe("all");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { getActiveAccount, type FeedFilters } from "../lib/api";
|
import { type FeedFilters } from "../lib/api";
|
||||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { accountKey, writeJSON } from "../lib/storage";
|
import { ownKey, writeJSON } from "../lib/storage";
|
||||||
import { logFilterChange } from "../lib/filterLog";
|
import { logFilterChange } from "../lib/filterLog";
|
||||||
import { useNavigation } from "./navigationContext";
|
import { useNavigation } from "./navigationContext";
|
||||||
import {
|
import {
|
||||||
@@ -29,12 +29,12 @@ import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersI
|
|||||||
function loadInitialFilters(): FeedFilters {
|
function loadInitialFilters(): FeedFilters {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
||||||
return loadAccountFilters(getActiveAccount());
|
return loadAccountFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||||
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
|
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
|
||||||
// pinned, so this is the account's own view from the first render.
|
// claimed, so this is the account's own view from the first render.
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||||
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||||
@@ -45,8 +45,8 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
|||||||
const setFilters = useCallback((next: FeedFilters) => {
|
const setFilters = useCallback((next: FeedFilters) => {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
logFilterChange("feed", next, next.q);
|
logFilterChange("feed", next, next.q);
|
||||||
// Persist under THIS tab's active account so filters don't bleed between accounts.
|
// Persist as the account's own view.
|
||||||
const key = accountKey(FILTERS_KEY, getActiveAccount());
|
const key = ownKey(FILTERS_KEY);
|
||||||
if (key) writeJSON(key, next);
|
if (key) writeJSON(key, next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
|
|||||||
import { focusNavItem } from "../lib/listNav";
|
import { focusNavItem } from "../lib/listNav";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { LS, accountKey } from "../lib/storage";
|
import { LS, ownKey } from "../lib/storage";
|
||||||
import { useCardPager } from "../lib/useCardPager";
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
@@ -628,7 +628,7 @@ export default function Files() {
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(f) => String(f.id)}
|
rowKey={(f) => String(f.id)}
|
||||||
persistKey={accountKey(LS.filesTable) ?? undefined}
|
persistKey={ownKey(LS.filesTable) ?? undefined}
|
||||||
pageSize={50}
|
pageSize={50}
|
||||||
keyboardNav
|
keyboardNav
|
||||||
fixedLayout
|
fixedLayout
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// FilesProvider's arrival at one file: `openFile` (a notice's "Open") and a `?item=` link both make a
|
// FilesProvider's arrival at one file: `openFile` (a notice's "Open") and a `?item=` link both make a
|
||||||
// pending focus that the Files page spends once it has looked; what it found is held until the page
|
// pending focus that the Files page spends once it has looked; what it found is held until the page
|
||||||
// is left. Real storage with the tab's account pinned; only the navigation is mocked, its page
|
// is left. Real storage, claimed for the account; only the navigation is mocked, its page
|
||||||
// switchable.
|
// switchable.
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
@@ -16,6 +16,7 @@ vi.mock("./navigationContext", () => ({
|
|||||||
|
|
||||||
import { FilesProvider } from "./FilesProvider";
|
import { FilesProvider } from "./FilesProvider";
|
||||||
import { ALL_FILES, useFiles, useFilesActions } from "./filesContext";
|
import { ALL_FILES, useFiles, useFilesActions } from "./filesContext";
|
||||||
|
import { claimLocalData } from "../lib/storage";
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<FilesProvider>{children}</FilesProvider>
|
<FilesProvider>{children}</FilesProvider>
|
||||||
@@ -24,7 +25,7 @@ const render = () => renderHook(() => ({ ...useFiles(), ...useFilesActions() }),
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "1");
|
claimLocalData(1);
|
||||||
window.history.replaceState(null, "", "/");
|
window.history.replaceState(null, "", "/");
|
||||||
nav.page = "files";
|
nav.page = "files";
|
||||||
nav.setPage.mockClear();
|
nav.setPage.mockClear();
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The account panel of the rail: one account per browser session, so it lists no other accounts. It
|
||||||
|
// offers "Sign in as someone else" (straight to Google's account chooser, whose pick replaces this
|
||||||
|
// account) where the instance has Google sign-in, and "Sign out", which ends the session.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
|
import * as actionErrors from "../lib/actionErrors";
|
||||||
|
import NavSidebar from "./NavSidebar";
|
||||||
|
|
||||||
|
vi.mock("./navigationContext", () => ({
|
||||||
|
useNavigation: () => ({ page: "feed" }),
|
||||||
|
useNavigationActions: () => ({ setPage: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("./prefsContext", () => ({ usePrefs: () => ({ railLabels: false }) }));
|
||||||
|
|
||||||
|
const me = (over: Partial<Me> = {}) =>
|
||||||
|
({
|
||||||
|
id: 5,
|
||||||
|
email: "me@example.com",
|
||||||
|
display_name: "Me",
|
||||||
|
role: "user",
|
||||||
|
avatar_url: null,
|
||||||
|
google_email: null,
|
||||||
|
google_enabled: true,
|
||||||
|
plex_enabled: false,
|
||||||
|
preferences: {},
|
||||||
|
...over,
|
||||||
|
}) as unknown as Me;
|
||||||
|
|
||||||
|
const row = (panel: HTMLElement, text: string) =>
|
||||||
|
[...panel.querySelectorAll("button")].find((b) => b.textContent === text)!;
|
||||||
|
|
||||||
|
let href = "";
|
||||||
|
const reload = vi.fn();
|
||||||
|
|
||||||
|
async function mount(account: Me) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<NavSidebar me={account} onOpenAbout={() => {}} onChangeLanguage={() => {}} language="en" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Me · / }));
|
||||||
|
// Positioned by floating-ui, which never settles in jsdom: the panel stays `visibility: hidden`.
|
||||||
|
return vi.waitFor(() => {
|
||||||
|
const panel = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Me"]');
|
||||||
|
if (!panel) throw new Error("the account panel did not open");
|
||||||
|
return panel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
href = "";
|
||||||
|
reload.mockReset();
|
||||||
|
vi.stubGlobal("location", {
|
||||||
|
...window.location,
|
||||||
|
reload,
|
||||||
|
set href(v: string) {
|
||||||
|
href = v;
|
||||||
|
},
|
||||||
|
get href() {
|
||||||
|
return href;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.spyOn(api, "notificationUnreadCount").mockResolvedValue({ count: 0 } as never);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the account panel", () => {
|
||||||
|
it("offers signing in as someone else, which goes straight to Google's account chooser", async () => {
|
||||||
|
const panel = await mount(me());
|
||||||
|
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
|
||||||
|
expect(rows).toEqual(["Sign in as someone else", "Sign out"]);
|
||||||
|
|
||||||
|
fireEvent.click(row(panel, "Sign in as someone else"));
|
||||||
|
expect(href).toBe("/auth/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers only signing out where the instance has no Google sign-in", async () => {
|
||||||
|
const panel = await mount(me({ google_enabled: false }));
|
||||||
|
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
|
||||||
|
expect(rows).toEqual(["Sign out"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signing out ends the session, then reloads onto the sign-in page", async () => {
|
||||||
|
const logout = vi.spyOn(api, "logout").mockResolvedValue({ ok: true });
|
||||||
|
const panel = await mount(me());
|
||||||
|
|
||||||
|
fireEvent.click(row(panel, "Sign out"));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||||
|
expect(logout).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reloading after a sign-out that failed only brought the session back, with no word of why.
|
||||||
|
it("a sign-out that failed says so and keeps the page", async () => {
|
||||||
|
vi.spyOn(api, "logout").mockRejectedValue(new HttpError(500, "boom"));
|
||||||
|
const report = vi.spyOn(actionErrors, "notifyActionError").mockImplementation(() => {});
|
||||||
|
const panel = await mount(me());
|
||||||
|
|
||||||
|
fireEvent.click(row(panel, "Sign out"));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(report).toHaveBeenCalledOnce());
|
||||||
|
expect(report.mock.calls[0]![1]).toBe("Couldn't sign you out");
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useState, useSyncExternalStore } from "react";
|
import { useState, useSyncExternalStore } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||||
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
|
import { ArrowLeftRight, Info, LogOut, Settings, Shield, type LucideIcon } from "lucide-react";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import { displayName, roleKey } from "../lib/api/account";
|
import { displayName, roleKey } from "../lib/api/account";
|
||||||
import { pinOnNextPage } from "../lib/activeAccount";
|
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
@@ -36,6 +34,7 @@ import {
|
|||||||
} from "./ui/NavRail";
|
} from "./ui/NavRail";
|
||||||
import { usePrefs } from "./prefsContext";
|
import { usePrefs } from "./prefsContext";
|
||||||
import { pageHref } from "../lib/nav";
|
import { pageHref } from "../lib/nav";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
|
||||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||||
@@ -59,8 +58,8 @@ export default function NavSidebar({
|
|||||||
// keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the
|
// keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the
|
||||||
// rail ever growing.
|
// rail ever growing.
|
||||||
const [acctOpen, setAcctOpen] = useState(false);
|
const [acctOpen, setAcctOpen] = useState(false);
|
||||||
// The account panel holds mixed content with its own tab order (profile, account switches, two
|
// The account panel holds mixed content with its own tab order (the profile, then its actions),
|
||||||
// actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
|
// so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
|
||||||
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
|
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
|
||||||
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
|
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
|
||||||
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
|
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
|
||||||
@@ -76,39 +75,16 @@ export default function NavSidebar({
|
|||||||
const { setReference: setAcctAnchor } = acctPop.refs;
|
const { setReference: setAcctAnchor } = acctPop.refs;
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
|
// Ends this browser's session, then reloads onto the sign-in page. A sign-out that failed says
|
||||||
// drops this tab's override and reloads onto whatever account remains the default.
|
// so and stays: the reload would only bring back the session that is still there.
|
||||||
try {
|
const signedOut = await api.logout().then(
|
||||||
await api.logout();
|
() => true,
|
||||||
} catch {
|
(err: unknown) => {
|
||||||
/* clear locally and reload regardless */
|
notifyActionError(err, t("errors.writeFailed.signOut"));
|
||||||
}
|
return false;
|
||||||
pinOnNextPage(null);
|
},
|
||||||
location.reload();
|
);
|
||||||
}
|
if (signedOut) location.reload();
|
||||||
|
|
||||||
// Accounts that have signed in on this browser (loaded only while the popover is open).
|
|
||||||
const accountsQuery = useQuery({
|
|
||||||
queryKey: qk.accounts(),
|
|
||||||
queryFn: api.accounts,
|
|
||||||
enabled: acctOpen,
|
|
||||||
});
|
|
||||||
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
|
|
||||||
|
|
||||||
function switchTo(id: number) {
|
|
||||||
// Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
|
|
||||||
// identity. No server round-trip — req() sends the account header (validated against the
|
|
||||||
// browser wallet server-side), so the cookie's default account is left untouched. The pin moves with
|
|
||||||
// the reload, not before it: the page going away still unloads as the account it was built for.
|
|
||||||
pinOnNextPage(id);
|
|
||||||
// Drop the previous account's in-module sub-view/overlay before the reload (which would
|
|
||||||
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
|
|
||||||
// stale sub-view that belongs to the OLD identity. Start at the module root instead.
|
|
||||||
const st = { ...(window.history.state || {}) };
|
|
||||||
delete st._sub;
|
|
||||||
delete st._ov;
|
|
||||||
window.history.replaceState(st, "");
|
|
||||||
location.reload(); // cleanest way to reload all per-user state for the new account
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
||||||
@@ -270,44 +246,21 @@ export default function NavSidebar({
|
|||||||
{t("header.account.admin")}
|
{t("header.account.admin")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{otherAccounts.length > 0 && (
|
|
||||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
|
||||||
{otherAccounts.map((a) => (
|
|
||||||
<Pressable
|
|
||||||
key={a.id}
|
|
||||||
onClick={() => switchTo(a.id)}
|
|
||||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
|
||||||
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target"
|
|
||||||
>
|
|
||||||
<AvatarImg
|
|
||||||
src={a.avatar_url}
|
|
||||||
fallback={a.display_name ?? a.email}
|
|
||||||
className="w-6 h-6 rounded-full text-3xs shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block text-[0.8125rem] text-fg truncate">
|
|
||||||
{displayName(a)}
|
|
||||||
</span>
|
|
||||||
<span className="block text-2xs text-muted truncate">{a.email}</span>
|
|
||||||
</span>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
||||||
<Pressable
|
{/* Straight to Google's account chooser: the account picked there takes this one's
|
||||||
onClick={() => {
|
place in the browser (one account per session), and a cancel there leaves this
|
||||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
one signed in. A password account signs in from the sign-in page, after Sign out. */}
|
||||||
// return from Google it adopts the freshly-added account (the new default)
|
{me.google_enabled && (
|
||||||
// and pins that. Other tabs keep their own pinned account.
|
<Pressable
|
||||||
pinOnNextPage(null);
|
onClick={() => {
|
||||||
window.location.href = "/auth/login";
|
window.location.href = "/auth/login";
|
||||||
}}
|
}}
|
||||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4" />
|
<ArrowLeftRight className="w-4 h-4" />
|
||||||
{t("header.account.addAccount")}
|
{t("header.account.signInAsOther")}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ import { useBackToClose } from "../lib/history";
|
|||||||
import { PageHeading } from "./ui/Headings";
|
import { PageHeading } from "./ui/Headings";
|
||||||
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { LS, setAccountRaw } from "../lib/storage";
|
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { PAGE_KEY, START_ON_HOME } from "./navigationInit";
|
import { api } from "../lib/api";
|
||||||
|
import { PAGE_KEY } from "./navigationInit";
|
||||||
|
|
||||||
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
||||||
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
|
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
|
||||||
@@ -79,9 +80,10 @@ async function openOverlay() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
qc = new QueryClient();
|
qc = new QueryClient();
|
||||||
qc.setQueryData(qk.me(), { id: 1 });
|
// An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here.
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
claimLocalData(1); // the account's page memory
|
||||||
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||||
});
|
});
|
||||||
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
|
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
|
||||||
@@ -262,7 +264,12 @@ describe("NavigationProvider · Back", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("a remembered retired page opens its tab", () => {
|
it("a remembered retired page opens its tab", () => {
|
||||||
setAccountRaw(LS.startOnHome, START_ON_HOME.off);
|
qc.setQueryData(qk.me(), {
|
||||||
|
id: 1,
|
||||||
|
role: "admin",
|
||||||
|
plex_enabled: true,
|
||||||
|
preferences: { startOnHome: false },
|
||||||
|
});
|
||||||
setAccountRaw(PAGE_KEY, "logs");
|
setAccountRaw(PAGE_KEY, "logs");
|
||||||
window.history.replaceState(null, "", "/");
|
window.history.replaceState(null, "", "/");
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
@@ -270,6 +277,19 @@ describe("NavigationProvider · Back", () => {
|
|||||||
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
|
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start-on-Home is the account's preference. An opt-out this browser kept before the server held it
|
||||||
|
// (the string "0") still counts, and reaches the server for the account's other devices.
|
||||||
|
it("takes this browser's earlier opt-out of Home, and saves it to the account", () => {
|
||||||
|
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true, preferences: {} });
|
||||||
|
setAccountRaw(LS.startOnHome, "0");
|
||||||
|
setAccountRaw(PAGE_KEY, "plex");
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.page).toBe("plex");
|
||||||
|
expect(save).toHaveBeenCalledWith({ startOnHome: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a retired page's tab when Back lands on its entry", () => {
|
it("opens a retired page's tab when Back lands on its entry", () => {
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
act(() => result.current.setPage("plex"));
|
act(() => result.current.setPage("plex"));
|
||||||
@@ -295,6 +315,77 @@ describe("NavigationProvider · Back", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NavigationProvider · a page the account cannot open", () => {
|
||||||
|
const member = { id: 1, role: "user", plex_enabled: true };
|
||||||
|
|
||||||
|
// A member following a link to an admin page saw the feed with no rail row current: the page
|
||||||
|
// stayed "scheduler" while only the content fell back.
|
||||||
|
it("lands on the feed, and the address bar says so", () => {
|
||||||
|
qc.setQueryData(qk.me(), member);
|
||||||
|
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.page).toBe("feed");
|
||||||
|
expect(window.location.search).toBe("?keep=1");
|
||||||
|
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lands on the feed when Back reaches an entry naming one", () => {
|
||||||
|
qc.setQueryData(qk.me(), member);
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.setPage("files"));
|
||||||
|
act(() => {
|
||||||
|
window.history.replaceState({ sfPage: "config" }, "", "/?page=config");
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "config" } }));
|
||||||
|
});
|
||||||
|
expect(result.current.page).toBe("feed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the page it is on once the account loses it", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=users");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.page).toBe("users");
|
||||||
|
act(() => {
|
||||||
|
qc.setQueryData(qk.me(), member);
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(result.current.page).toBe("feed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kept behind the feed, the lost page stayed in the address bar, a click on Feed did nothing (the
|
||||||
|
// feed was already on screen), and a later grant switched the page unasked.
|
||||||
|
it("gives the lost page up: the address bar, the remembered page and a later grant agree", async () => {
|
||||||
|
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.page).toBe("users");
|
||||||
|
act(() => {
|
||||||
|
qc.setQueryData(qk.me(), member);
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||||
|
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
|
||||||
|
act(() => {
|
||||||
|
qc.setQueryData(qk.me(), { ...member, role: "admin" });
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(result.current.page).toBe("feed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a channel page open over the lost page, on screen and in the entry", async () => {
|
||||||
|
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.openChannel("UC1", "One"));
|
||||||
|
expect(window.location.search).toContain("chan=UC1");
|
||||||
|
act(() => {
|
||||||
|
qc.setQueryData(qk.me(), member);
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(result.current.channelView).toBeNull();
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
expect(window.history.state?._chan).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
|
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
|
||||||
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
|
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
|
||||||
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
|
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ import {
|
|||||||
notifyNav,
|
notifyNav,
|
||||||
rememberNavPosition,
|
rememberNavPosition,
|
||||||
} from "../lib/history";
|
} from "../lib/history";
|
||||||
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
import { reachablePage } from "../lib/modules";
|
||||||
|
import {
|
||||||
|
channelViewFrom,
|
||||||
|
loadInitialPage,
|
||||||
|
PAGE_KEY,
|
||||||
|
START_ON_HOME_OPTIONS,
|
||||||
|
START_ON_HOME_PREF,
|
||||||
|
type ChannelView,
|
||||||
|
} from "./navigationInit";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import {
|
import {
|
||||||
NavigationActionsContext,
|
NavigationActionsContext,
|
||||||
NavigationStateContext,
|
NavigationStateContext,
|
||||||
@@ -29,9 +38,35 @@ import {
|
|||||||
//
|
//
|
||||||
// The contexts, their value types and the hooks live in navigationContext.ts.
|
// The contexts, their value types and the hooks live in navigationContext.ts.
|
||||||
|
|
||||||
|
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
|
||||||
|
function pageRootParams(page: Page): Record<string, string | null> {
|
||||||
|
return {
|
||||||
|
page: page === "feed" ? null : page,
|
||||||
|
chan: null,
|
||||||
|
v: null,
|
||||||
|
pl: null,
|
||||||
|
pscope: null,
|
||||||
|
psort: null,
|
||||||
|
pshow: null,
|
||||||
|
pf: null,
|
||||||
|
ctab: null,
|
||||||
|
csort: null,
|
||||||
|
cf: null,
|
||||||
|
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
|
||||||
|
// this browser held reaches the server at once.
|
||||||
|
const [startOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
|
||||||
|
const [chosenPage, setPageState] = useState<Page>(() => loadInitialPage(startOnHome));
|
||||||
|
// The page on screen: the chosen one when this account may open it, else the feed — derived, so a
|
||||||
|
// link, a Back and a role taken away mid-session all land the same way, and the rail, the content
|
||||||
|
// and the address bar (stamped from it below) agree.
|
||||||
|
const me = useMe();
|
||||||
|
const page = reachablePage(chosenPage, me);
|
||||||
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
|
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
|
||||||
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
|
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
|
||||||
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
||||||
@@ -47,6 +82,18 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
||||||
channelViewFrom(window.history.state, window.location.search),
|
channelViewFrom(window.history.state, window.location.search),
|
||||||
);
|
);
|
||||||
|
// A page this account can no longer open (a role taken away, a module switched off mid-session)
|
||||||
|
// is given up, not kept behind the feed: kept, the address bar would go on naming it, a click on
|
||||||
|
// Feed would be a no-op (setPage's same-page guard sees the feed already on screen), and a later
|
||||||
|
// grant would switch the page by itself. A channel page open over it closes with it, as on any
|
||||||
|
// page switch. Adjusted during render; the history entry and the remembered page follow in the
|
||||||
|
// effect below.
|
||||||
|
const [givenUp, setGivenUp] = useState(0);
|
||||||
|
if (page !== chosenPage) {
|
||||||
|
setPageState(page);
|
||||||
|
setChannelView(null);
|
||||||
|
setGivenUp((n) => n + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Latest-value refs so the STABLE action callbacks can read the latest state without depending on
|
// Latest-value refs so the STABLE action callbacks can read the latest state without depending on
|
||||||
// it — a dep would recreate them on every navigation and re-render every actions consumer (e.g.
|
// it — a dep would recreate them on every navigation and re-render every actions consumer (e.g.
|
||||||
@@ -80,20 +127,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
window.history.pushState(
|
window.history.pushState(
|
||||||
{ sfPage: next, sfScroll: nextNavId() },
|
{ sfPage: next, sfScroll: nextNavId() },
|
||||||
"",
|
"",
|
||||||
urlWithParams({
|
urlWithParams(pageRootParams(next)),
|
||||||
page: next === "feed" ? null : next,
|
|
||||||
chan: null,
|
|
||||||
v: null,
|
|
||||||
pl: null,
|
|
||||||
pscope: null,
|
|
||||||
psort: null,
|
|
||||||
pshow: null,
|
|
||||||
pf: null,
|
|
||||||
ctab: null,
|
|
||||||
csort: null,
|
|
||||||
cf: null,
|
|
||||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
notifyNav();
|
notifyNav();
|
||||||
then?.();
|
then?.();
|
||||||
@@ -171,13 +205,27 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same
|
// title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same
|
||||||
// as one opened in the app. Signed out, the page on screen is the public landing whatever `page`
|
// as one opened in the app. Signed out, the page on screen is the public landing whatever `page`
|
||||||
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
|
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
|
||||||
const signedIn = useMe() != null;
|
const signedIn = me != null;
|
||||||
const objectTitle = useDocumentObjectTitle();
|
const objectTitle = useDocumentObjectTitle();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
|
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
|
||||||
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
|
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
|
||||||
}, [signedIn, page, objectTitle, i18n.language, t]);
|
}, [signedIn, page, objectTitle, i18n.language, t]);
|
||||||
|
|
||||||
|
// The page given up above is the feed, and the current entry says so (replaced, not pushed: no
|
||||||
|
// step was taken). Its own keys go with it, and the channel page closed with it, as on any page
|
||||||
|
// switch — left in the entry, a reload would reopen what the screen no longer shows.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!givenUp) return;
|
||||||
|
setAccountRaw(PAGE_KEY, "feed");
|
||||||
|
const { _chan: _closedChan, _chanName: _closedName, ...rest } = window.history.state ?? {};
|
||||||
|
window.history.replaceState(
|
||||||
|
{ ...rest, sfPage: "feed" },
|
||||||
|
"",
|
||||||
|
urlWithParams(pageRootParams("feed")),
|
||||||
|
);
|
||||||
|
}, [givenUp]);
|
||||||
|
|
||||||
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
|
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
|
||||||
// entry: every later page change writes its own entry through setPage.
|
// entry: every later page change writes its own entry through setPage.
|
||||||
const [mountPage] = useState(page);
|
const [mountPage] = useState(page);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("the onboarding wizard's Escape", () => {
|
describe("the onboarding wizard's Escape", () => {
|
||||||
const mount = (onClose: () => void, dialog: boolean) =>
|
const mount = (onClose: (dismiss: boolean) => void, dialog: boolean) =>
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider
|
<QueryClientProvider
|
||||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
@@ -51,7 +51,8 @@ describe("the onboarding wizard's Escape", () => {
|
|||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
mount(onClose, false);
|
mount(onClose, false);
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
// Read was never granted: leaving now stops the wizard opening by itself.
|
||||||
|
expect(onClose).toHaveBeenCalledExactlyOnceWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves the wizard alone while a dialog is open over it", () => {
|
it("leaves the wizard alone while a dialog is open over it", () => {
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ function Dots({ index, total }: { index: number; total: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
export default function OnboardingWizard({
|
||||||
|
me,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
me: Me;
|
||||||
|
/** `dismiss`: the essential read step was skipped, so the wizard stops opening by itself. */
|
||||||
|
onClose: (dismiss: boolean) => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const importTriggered = useRef(false);
|
const importTriggered = useRef(false);
|
||||||
@@ -110,8 +117,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
// Closing without finishing the essential read step suppresses future auto-popups
|
// Closing without finishing the essential read step suppresses future auto-popups
|
||||||
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
||||||
const close = () => {
|
const close = () => {
|
||||||
endOnboarding(!me.can_read);
|
endOnboarding();
|
||||||
onClose();
|
onClose(!me.can_read);
|
||||||
};
|
};
|
||||||
const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster)
|
const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster)
|
||||||
|
|
||||||
@@ -274,8 +281,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
endOnboarding(false);
|
endOnboarding();
|
||||||
onClose();
|
onClose(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("onboarding.write.keepReadOnly")}
|
{t("onboarding.write.keepReadOnly")}
|
||||||
@@ -299,8 +306,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
size="lg"
|
size="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
endOnboarding(false);
|
endOnboarding();
|
||||||
onClose();
|
onClose(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("onboarding.done.done")}
|
{t("onboarding.done.done")}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { compareText } from "../lib/collate";
|
|||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
import { LS } from "../lib/storage";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import { type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
import FilterRail from "./FilterRail";
|
import FilterRail from "./FilterRail";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
@@ -65,7 +66,10 @@ export default function PlaylistsRail({
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
const [plSort, setPlSort] = usePref<PlSort>("plSort", PL_SORT_DEFAULT, {
|
||||||
|
localKey: LS.plSort,
|
||||||
|
parse: (raw) => ({ ...PL_SORT_DEFAULT, ...(raw && typeof raw === "object" ? raw : {}) }),
|
||||||
|
});
|
||||||
const plSortOptions: SortOption[] = [
|
const plSortOptions: SortOption[] = [
|
||||||
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
||||||
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
||||||
@@ -76,9 +80,6 @@ export default function PlaylistsRail({
|
|||||||
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
||||||
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
||||||
];
|
];
|
||||||
useEffect(() => {
|
|
||||||
writeAccount(LS.plSort, plSort);
|
|
||||||
}, [plSort]);
|
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const plActions = usePlaylistActions();
|
const plActions = usePlaylistActions();
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
import PlexPlayer from "./PlexPlayer";
|
import PlexPlayer from "./PlexPlayer";
|
||||||
import { api, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexItemDetail } from "../lib/api";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
|
||||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
import { claimLocalData } from "../lib/storage";
|
||||||
|
|
||||||
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ beforeAll(() => {
|
|||||||
let play: ReturnType<typeof vi.fn>;
|
let play: ReturnType<typeof vi.fn>;
|
||||||
let pause: ReturnType<typeof vi.fn>;
|
let pause: ReturnType<typeof vi.fn>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
claimLocalData(7);
|
||||||
play = vi.fn(function (this: Media) {
|
play = vi.fn(function (this: Media) {
|
||||||
this._paused = false;
|
this._paused = false;
|
||||||
this.dispatchEvent(new Event("play"));
|
this.dispatchEvent(new Event("play"));
|
||||||
@@ -1034,7 +1034,7 @@ describe("PlexPlayer — clip mode", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prefs = (p: object) =>
|
const prefs = (p: object) =>
|
||||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
|
||||||
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
|
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
|
||||||
|
|
||||||
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
|
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ vi.mock("hls.js", async (importOriginal) => {
|
|||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import PlexPlayer from "./PlexPlayer";
|
import PlexPlayer from "./PlexPlayer";
|
||||||
import { api, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexItemDetail } from "../lib/api";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { claimLocalData } from "../lib/storage";
|
||||||
|
|
||||||
type Media = HTMLMediaElement & { _t?: number };
|
type Media = HTMLMediaElement & { _t?: number };
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ beforeAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
claimLocalData(7);
|
||||||
hlsInstances.length = 0;
|
hlsInstances.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import PlexPlayer from "./PlexPlayer";
|
|||||||
import { api, HttpError, type PlexItemDetail } from "../lib/api";
|
import { api, HttpError, type PlexItemDetail } from "../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { claimLocalData } from "../lib/storage";
|
||||||
|
|
||||||
type Media = HTMLMediaElement & { _t?: number };
|
type Media = HTMLMediaElement & { _t?: number };
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ beforeAll(() => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// A signed-in account whose saved subtitle language is English (prefs are per account).
|
// A signed-in account whose saved subtitle language is English (prefs are per account).
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
claimLocalData(7);
|
||||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" }));
|
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -310,7 +310,7 @@ const startsAsked = (id: string) =>
|
|||||||
|
|
||||||
describe("leaving an item that was played through", () => {
|
describe("leaving an item that was played through", () => {
|
||||||
const prefs = (p: object) =>
|
const prefs = (p: object) =>
|
||||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
|
||||||
|
|
||||||
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
|
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
|
||||||
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
|
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -27,7 +27,8 @@ import { notifyActionError } from "../lib/actionErrors";
|
|||||||
import { navItemAttrs, useListNav } from "../lib/listNav";
|
import { navItemAttrs, useListNav } from "../lib/listNav";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { LS } from "../lib/storage";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
@@ -424,12 +425,10 @@ export default function PlexPlaylistView({
|
|||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [layout, setLayout] = useState<Layout>(() =>
|
const [layout, setLayout] = usePref<Layout>("plexPlaylistLayout", "accordion", {
|
||||||
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
|
localKey: LS.plexPlaylistLayout,
|
||||||
);
|
parse: (raw) => (raw === "tree" ? "tree" : "accordion"),
|
||||||
useEffect(() => {
|
});
|
||||||
setAccountRaw(LS.plexPlaylistLayout, layout);
|
|
||||||
}, [layout]);
|
|
||||||
// Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling;
|
// Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling;
|
||||||
// seasons start expanded once their show is opened. Both are just local view state.
|
// seasons start expanded once their show is opened. Both are just local view state.
|
||||||
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
|
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { render, cleanup } from "@testing-library/react";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { NavigationProvider } from "./NavigationProvider";
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
import { PlexProvider } from "./PlexProvider";
|
import { PlexProvider } from "./PlexProvider";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { claimLocalData } from "../lib/storage";
|
||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
render(
|
render(
|
||||||
@@ -25,7 +25,7 @@ function mount() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
claimLocalData(5);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -37,9 +37,9 @@ describe("PlexProvider shared link", () => {
|
|||||||
window.history.replaceState(null, "", "/?page=plex&pscope=movie");
|
window.history.replaceState(null, "", "/?page=plex&pscope=movie");
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
expect(localStorage.getItem("siftlode.plexLibrary.5")).toBe("movie");
|
expect(localStorage.getItem("siftlode.plexLibrary")).toBe("movie");
|
||||||
expect(localStorage.getItem("siftlode.plexFilters.5")).toBeNull();
|
expect(localStorage.getItem("siftlode.plexFilters")).toBeNull();
|
||||||
expect(localStorage.getItem("siftlode.plexSort.5")).toBeNull();
|
expect(localStorage.getItem("siftlode.plexSort")).toBeNull();
|
||||||
expect(localStorage.getItem("siftlode.plexShow.5")).toBeNull();
|
expect(localStorage.getItem("siftlode.plexShow")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||||
//
|
//
|
||||||
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
|
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
|
||||||
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
|
// (BootGate claims the local state for the account first; a different account reloads the tab). `playlistOpen` is an ephemeral
|
||||||
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
|
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
|
||||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||||
// into (and is never greeted by a stale value from) the feed.
|
// into (and is never greeted by a stale value from) the feed.
|
||||||
@@ -84,7 +84,7 @@ function readPlexFilters(): PlexFilters {
|
|||||||
return EMPTY_PLEX_FILTERS;
|
return EMPTY_PLEX_FILTERS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
|
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so a
|
||||||
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
|
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
|
||||||
// `present` records which dimensions the link actually carried: only those are persisted (below).
|
// `present` records which dimensions the link actually carried: only those are persisted (below).
|
||||||
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
||||||
|
|||||||
@@ -2,29 +2,44 @@
|
|||||||
//
|
//
|
||||||
// The Settings prefs are seeded from the account's server prefs in the `me` cache — BootGate mounts the
|
// The Settings prefs are seeded from the account's server prefs in the `me` cache — BootGate mounts the
|
||||||
// tree only once it is there. The adopt effect this replaced corrected them AFTER mounting; with it
|
// tree only once it is there. The adopt effect this replaced corrected them AFTER mounting; with it
|
||||||
// gone, a seed that read only this browser's copy would never show a performance mode or hints setting
|
// gone, a seed that read only this browser's copy would never show a hints setting changed on another
|
||||||
// changed on another device. And the seed is not an edit: it must not be saved straight back.
|
// device. And the seed is not an edit: it must not be saved straight back. Performance mode is the
|
||||||
|
// exception: it is this device's, so the server's word on it is not taken and a change is not sent.
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, render } from "@testing-library/react";
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { PrefsProvider } from "./PrefsProvider";
|
import { PrefsProvider } from "./PrefsProvider";
|
||||||
import { usePrefs } from "./prefsContext";
|
import { usePrefs } from "./prefsContext";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { claimLocalData, LS } from "../lib/storage";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
const seen: { perf: boolean; hints: boolean }[] = [];
|
const seen: { perf: boolean; hints: boolean }[] = [];
|
||||||
|
const controls: ReturnType<typeof usePrefs>[] = [];
|
||||||
function Probe() {
|
function Probe() {
|
||||||
const { perf, hints } = usePrefs();
|
const prefs = usePrefs();
|
||||||
seen.push({ perf, hints });
|
controls.push(prefs);
|
||||||
|
seen.push({ perf: prefs.perf, hints: prefs.hints });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mount(preferences: Record<string, unknown>) {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
qc.setQueryData(qk.me(), { id: 5, preferences });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PrefsProvider>
|
||||||
|
<Probe />
|
||||||
|
</PrefsProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
claimLocalData(5);
|
||||||
seen.length = 0;
|
seen.length = 0;
|
||||||
|
controls.length = 0;
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -35,24 +50,38 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("PrefsProvider seed", () => {
|
describe("PrefsProvider seed", () => {
|
||||||
it("renders the account's server prefs from the first render, and does not save them back", () => {
|
it("renders the account's server prefs from the first render, and does not save them back", () => {
|
||||||
localStorage.setItem(accountKey(LS.perfMode, 5)!, "0"); // this browser last had it off
|
localStorage.setItem(LS.hints, "1");
|
||||||
localStorage.setItem(accountKey(LS.hints, 5)!, "1");
|
|
||||||
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||||
const qc = new QueryClient();
|
mount({ hints: false });
|
||||||
qc.setQueryData(qk.me(), { id: 5, preferences: { performanceMode: true, hints: false } });
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={qc}>
|
|
||||||
<PrefsProvider>
|
|
||||||
<Probe />
|
|
||||||
</PrefsProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(seen[0]).toEqual({ perf: true, hints: false });
|
expect(seen[0]?.hints).toBe(false);
|
||||||
// past the auto-save debounce
|
// past the auto-save debounce
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(1000);
|
vi.advanceTimersByTime(1000);
|
||||||
});
|
});
|
||||||
expect(save).not.toHaveBeenCalled();
|
expect(save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes performance mode from this device, never from the account", () => {
|
||||||
|
localStorage.setItem(LS.perfMode, "1"); // this device has it on
|
||||||
|
mount({ performanceMode: false }); // an older build saved the account's value
|
||||||
|
expect(seen[0]?.perf).toBe(true);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
localStorage.setItem(LS.perfMode, "0");
|
||||||
|
mount({ performanceMode: true });
|
||||||
|
expect(seen[seen.length - 1]?.perf).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a performance mode change on this device: nothing is sent to the server", () => {
|
||||||
|
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||||
|
mount({});
|
||||||
|
act(() => controls[controls.length - 1]?.setPerf(true));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(localStorage.getItem(LS.perfMode)).toBe("1");
|
||||||
|
expect(document.documentElement.dataset.perf).toBe("1");
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ import { PrefsContext } from "./prefsContext";
|
|||||||
|
|
||||||
const PERF_KEY = LS.perfMode;
|
const PERF_KEY = LS.perfMode;
|
||||||
|
|
||||||
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
|
// The Settings page's account preferences. They apply locally for instant preview and auto-save
|
||||||
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
|
// (debounced) to the server — there is no draft or Save button. Performance mode is held here too but
|
||||||
|
// is this device's (a weak GPU wants it, a strong one does not), so it stays in this browser only. This provider owns them (lifted out of the App god
|
||||||
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
|
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
|
||||||
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
|
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
|
||||||
type EditablePrefs = {
|
type EditablePrefs = {
|
||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
performanceMode: boolean;
|
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
railLabels: boolean;
|
railLabels: boolean;
|
||||||
notifications: NotifSettings;
|
notifications: NotifSettings;
|
||||||
@@ -46,10 +46,6 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
|
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
|
||||||
return {
|
return {
|
||||||
theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
|
theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
|
||||||
performanceMode:
|
|
||||||
typeof prefs?.performanceMode === "boolean"
|
|
||||||
? prefs.performanceMode
|
|
||||||
: getAccountRaw(PERF_KEY) === "1",
|
|
||||||
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||||
// B7: off by default — the icon rail with name tooltips is the house look.
|
// B7: off by default — the icon rail with name tooltips is the house look.
|
||||||
railLabels: prefs?.railLabels === true,
|
railLabels: prefs?.railLabels === true,
|
||||||
@@ -59,7 +55,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
|
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
|
||||||
const [perf, setPerf] = useState(seed.performanceMode);
|
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
||||||
const [hints, setHints] = useState(seed.hints);
|
const [hints, setHints] = useState(seed.hints);
|
||||||
const [railLabels, setRailLabels] = useState(seed.railLabels);
|
const [railLabels, setRailLabels] = useState(seed.railLabels);
|
||||||
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
|
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
|
||||||
@@ -92,8 +88,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
|
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
|
||||||
// saved straight back (`/me` merges, so a full payload is fine).
|
// saved straight back (`/me` merges, so a full payload is fine).
|
||||||
const editablePrefs = useMemo<EditablePrefs>(
|
const editablePrefs = useMemo<EditablePrefs>(
|
||||||
() => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }),
|
() => ({ theme, hints, railLabels, notifications: notif }),
|
||||||
[theme, perf, hints, railLabels, notif],
|
[theme, hints, railLabels, notif],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.clearTimeout(saveTimer.current);
|
window.clearTimeout(saveTimer.current);
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
|||||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
import { SavedViewsProvider } from "./SavedViewsProvider";
|
||||||
import { useSavedViews } from "./savedViewsContext";
|
import { useSavedViews } from "./savedViewsContext";
|
||||||
import { api, type SavedView } from "../lib/api";
|
import { api, type SavedView } from "../lib/api";
|
||||||
import { LS, accountKey } from "../lib/storage";
|
import { claimLocalData, LS } from "../lib/storage";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
const ID = 7;
|
const ID = 7;
|
||||||
@@ -95,7 +94,7 @@ async function mountResolved(views: SavedView[]) {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
|
claimLocalData(ID);
|
||||||
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
||||||
// case would suppress seeding in the others.
|
// case would suppress seeding in the others.
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
@@ -118,7 +117,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
|
||||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -177,7 +176,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
|||||||
|
|
||||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
it("plex: does NOT seed when plex filters are already stored", async () => {
|
||||||
h.page = "plex";
|
h.page = "plex";
|
||||||
localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] }));
|
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
||||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
coercePlexFilters,
|
coercePlexFilters,
|
||||||
getActiveAccount,
|
|
||||||
plexFiltersSignature,
|
plexFiltersSignature,
|
||||||
type FeedFilters,
|
type FeedFilters,
|
||||||
type PlexFilters,
|
type PlexFilters,
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
||||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { LS, ownKey } from "../lib/storage";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { useNavigation } from "./navigationContext";
|
import { useNavigation } from "./navigationContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
@@ -55,7 +54,7 @@ const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePle
|
|||||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||||
function storedFiltersPresent(lsKey: string): boolean {
|
function storedFiltersPresent(lsKey: string): boolean {
|
||||||
const key = accountKey(lsKey, getActiveAccount());
|
const key = ownKey(lsKey);
|
||||||
if (!key) return true;
|
if (!key) return true;
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(key) != null;
|
return localStorage.getItem(key) != null;
|
||||||
@@ -147,8 +146,8 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||||
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
||||||
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
|
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
|
||||||
// before this provider mounts.
|
// state to the account (claimLocalData) before this provider mounts.
|
||||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||||
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
||||||
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { withActiveAccount } from "../lib/activeAccount";
|
import { connectGoogle } from "../lib/googleConnect";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
|
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -24,7 +24,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
import { displayName, roleKey } from "../lib/api/account";
|
import { displayName, roleKey } from "../lib/api/account";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage";
|
import { LS, useTabPersistedState } from "../lib/storage";
|
||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
@@ -35,7 +35,8 @@ import { type SaveState } from "./ui/DraftSaveBar";
|
|||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { StateMessage } from "./ui/QueryState";
|
import { StateMessage } from "./ui/QueryState";
|
||||||
import { useWizard } from "./wizardContext";
|
import { useWizard } from "./wizardContext";
|
||||||
import { START_ON_HOME } from "./navigationInit";
|
import { START_ON_HOME_OPTIONS, START_ON_HOME_PREF } from "./navigationInit";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import { usePrefs } from "./prefsContext";
|
import { usePrefs } from "./prefsContext";
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
import { useTabPanel } from "./ui/tabPanel";
|
import { useTabPanel } from "./ui/tabPanel";
|
||||||
@@ -164,10 +165,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
||||||
const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
|
const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
|
||||||
// The landing page. Per-account localStorage, not a server preference like the rest of this
|
// The landing page: read at boot (NavigationProvider), so a change applies from the next start.
|
||||||
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
|
const [startOnHome, setStartOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
|
||||||
// values come from navigationInit, which is what reads them back at boot.
|
|
||||||
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -242,8 +241,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
<SwitchRow
|
<SwitchRow
|
||||||
label={t("settings.appearance.startOnHome")}
|
label={t("settings.appearance.startOnHome")}
|
||||||
hint={t("settings.appearance.startOnHomeHint")}
|
hint={t("settings.appearance.startOnHomeHint")}
|
||||||
checked={startOnHome !== START_ON_HOME.off}
|
checked={startOnHome}
|
||||||
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
|
onChange={setStartOnHome}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -359,7 +358,7 @@ function AccessRow({
|
|||||||
|
|
||||||
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
|
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
|
||||||
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
|
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
|
||||||
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
|
// (auth.py attaches the identity to the signed-in account via /auth/google/connect); the password form posts
|
||||||
// directly with inline errors.
|
// directly with inline errors.
|
||||||
export function SignInMethods({ me }: { me: Me }) {
|
export function SignInMethods({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -451,13 +450,7 @@ export function SignInMethods({ me }: { me: Me }) {
|
|||||||
{me.has_google ? (
|
{me.has_google ? (
|
||||||
<Badge tone="accent">{t("settings.account.googleLink.connected")}</Badge>
|
<Badge tone="accent">{t("settings.account.googleLink.connected")}</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button variant="glass" size="sm" onClick={() => connectGoogle("identity")}>
|
||||||
variant="glass"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = withActiveAccount("/auth/link");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("settings.account.googleLink.connect")}
|
{t("settings.account.googleLink.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -877,18 +870,14 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||||||
granted={me.can_read}
|
granted={me.can_read}
|
||||||
grantedHint={t("settings.account.readGrantedHint")}
|
grantedHint={t("settings.account.readGrantedHint")}
|
||||||
enableHint={t("settings.account.readEnableHint")}
|
enableHint={t("settings.account.readEnableHint")}
|
||||||
onEnable={() => {
|
onEnable={() => connectGoogle("read")}
|
||||||
window.location.href = withActiveAccount("/auth/upgrade?access=read");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<AccessRow
|
<AccessRow
|
||||||
title={t("settings.account.writeTitle")}
|
title={t("settings.account.writeTitle")}
|
||||||
granted={me.can_write}
|
granted={me.can_write}
|
||||||
grantedHint={t("settings.account.writeGrantedHint")}
|
grantedHint={t("settings.account.writeGrantedHint")}
|
||||||
enableHint={t("settings.account.writeEnableHint")}
|
enableHint={t("settings.account.writeEnableHint")}
|
||||||
onEnable={() => {
|
onEnable={() => connectGoogle("write")}
|
||||||
window.location.href = withActiveAccount("/auth/upgrade?access=write");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="link" className="mt-2 text-sm" onClick={onOpenWizard}>
|
<Button variant="link" className="mt-2 text-sm" onClick={onOpenWizard}>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The "updated" banner shows once per release for the ACCOUNT: the version it was last dismissed for
|
||||||
|
// is an account preference, so a banner dismissed on one device does not come back on the next. A
|
||||||
|
// version this browser kept before the server held it (a bare string) still counts.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("../lib/version", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../lib/version")>()),
|
||||||
|
FRONTEND_VERSION: "1.2.3",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import VersionBanner from "./VersionBanner";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { claimLocalData, LS } from "../lib/storage";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
let save: ReturnType<typeof vi.spyOn>;
|
||||||
|
function mount(preferences: Record<string, unknown>) {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
qc.setQueryData(qk.me(), { id: 5, preferences });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<VersionBanner onOpen={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const banner = () => screen.queryByText("header.banner.updated", { exact: false });
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
claimLocalData(5);
|
||||||
|
save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the release banner", () => {
|
||||||
|
it("shows for a release the account has not seen", () => {
|
||||||
|
mount({ seenVersion: "1.2.2" });
|
||||||
|
expect(banner()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays hidden where the account dismissed this release, on another device", () => {
|
||||||
|
mount({ seenVersion: "1.2.3" });
|
||||||
|
expect(banner()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the version this browser kept, and saves it to the account", () => {
|
||||||
|
localStorage.setItem(LS.seenVersion, "1.2.3");
|
||||||
|
mount({});
|
||||||
|
expect(banner()).toBeNull();
|
||||||
|
expect(save).toHaveBeenCalledWith({ seenVersion: "1.2.3" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a dismissal to the account", () => {
|
||||||
|
mount({});
|
||||||
|
act(() => screen.getByTitle("header.banner.dismiss").click());
|
||||||
|
expect(banner()).toBeNull();
|
||||||
|
expect(save).toHaveBeenCalledWith({ seenVersion: "1.2.3" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version";
|
import { FRONTEND_VERSION, SEEN_VERSION_OPTIONS, SEEN_VERSION_PREF } from "../lib/version";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import Banner from "./ui/Banner";
|
import Banner from "./ui/Banner";
|
||||||
|
|
||||||
// Eye-catching, dismissible bar shown once after the running build's version changes
|
// Eye-catching, dismissible bar shown once after the running build's version changes
|
||||||
// (compares the baked frontend version to the last one the user acknowledged).
|
// (compares the baked frontend version to the last one the user acknowledged).
|
||||||
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
|
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [seen, setSeen] = usePref(SEEN_VERSION_PREF, null, SEEN_VERSION_OPTIONS);
|
||||||
const seen = localStorage.getItem(SEEN_VERSION_KEY);
|
|
||||||
// Don't nag in un-stamped dev builds, or once this version has been seen.
|
// Don't nag in un-stamped dev builds, or once this version has been seen.
|
||||||
const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION && !dismissed;
|
const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION;
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
function markSeen() {
|
const markSeen = () => setSeen(FRONTEND_VERSION);
|
||||||
localStorage.setItem(SEEN_VERSION_KEY, FRONTEND_VERSION);
|
|
||||||
setDismissed(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Banner
|
<Banner
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Whether the onboarding wizard opens by itself is the account's preference: dismissed on one device,
|
||||||
|
// it stays shut on the others. A dismissal this browser kept before the server held it (the string
|
||||||
|
// "1") still counts, and reaches the server.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("./OnboardingWizard", () => ({
|
||||||
|
default: ({ onClose }: { onClose: (dismiss: boolean) => void }) => (
|
||||||
|
<button onClick={() => onClose(true)}>skip the wizard</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { WizardProvider } from "./WizardProvider";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { claimLocalData, LS } from "../lib/storage";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
let save: ReturnType<typeof vi.spyOn>;
|
||||||
|
function mount(preferences: Record<string, unknown>) {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
qc.setQueryData(qk.me(), {
|
||||||
|
id: 5,
|
||||||
|
can_read: false,
|
||||||
|
can_write: false,
|
||||||
|
google_enabled: true,
|
||||||
|
preferences,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<WizardProvider>{null}</WizardProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return qc;
|
||||||
|
}
|
||||||
|
const wizard = () => screen.queryByRole("button", { name: "skip the wizard" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
claimLocalData(5);
|
||||||
|
save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the onboarding wizard's auto-open", () => {
|
||||||
|
it("opens for an account that has not connected YouTube nor dismissed it", async () => {
|
||||||
|
mount({});
|
||||||
|
expect(await screen.findByRole("button", { name: "skip the wizard" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays shut where the account dismissed it, on another device", async () => {
|
||||||
|
mount({ onboardingDismissed: true });
|
||||||
|
await act(async () => {});
|
||||||
|
expect(wizard()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes this browser's earlier dismissal and saves it to the account", async () => {
|
||||||
|
localStorage.setItem(LS.onboardingDismissed, "1");
|
||||||
|
mount({});
|
||||||
|
await act(async () => {});
|
||||||
|
expect(wizard()).toBeNull();
|
||||||
|
expect(save).toHaveBeenCalledWith({ onboardingDismissed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a dismissal to the account, and stays shut", async () => {
|
||||||
|
const qc = mount({});
|
||||||
|
const skip = await screen.findByRole("button", { name: "skip the wizard" });
|
||||||
|
await act(async () => skip.click());
|
||||||
|
expect(save).toHaveBeenCalledWith({ onboardingDismissed: true });
|
||||||
|
expect(
|
||||||
|
qc.getQueryData<{ preferences: Record<string, unknown> }>(qk.me())?.preferences,
|
||||||
|
).toMatchObject({ onboardingDismissed: true });
|
||||||
|
expect(wizard()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,12 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNo
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { shouldAutoOpenOnboarding } from "../lib/onboarding";
|
import {
|
||||||
|
ONBOARDING_DISMISSED_OPTIONS,
|
||||||
|
ONBOARDING_DISMISSED_PREF,
|
||||||
|
shouldAutoOpenOnboarding,
|
||||||
|
} from "../lib/onboarding";
|
||||||
|
import { prefValue, usePref } from "../lib/usePref";
|
||||||
import { WizardActionsContext, type WizardActions } from "./wizardContext";
|
import { WizardActionsContext, type WizardActions } from "./wizardContext";
|
||||||
|
|
||||||
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
|
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
|
||||||
@@ -23,8 +28,20 @@ export function WizardProvider({ children }: { children: ReactNode }) {
|
|||||||
// Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update).
|
// Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update).
|
||||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||||
|
|
||||||
|
// The setter only: the check below reads the value from `me`, which the setter keeps current.
|
||||||
|
const [, setDismissed] = usePref(ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS);
|
||||||
|
const dismissed = prefValue(me, ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS);
|
||||||
|
|
||||||
const openWizard = useCallback(() => setOpen(true), []);
|
const openWizard = useCallback(() => setOpen(true), []);
|
||||||
const closeWizard = useCallback(() => setOpen(false), []);
|
const closeWizard = useCallback(() => setOpen(false), []);
|
||||||
|
// Leaving without the essential read step suppresses the auto-popup from then on.
|
||||||
|
const finishWizard = useCallback(
|
||||||
|
(dismiss: boolean) => {
|
||||||
|
if (dismiss) setDismissed(true);
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[setDismissed],
|
||||||
|
);
|
||||||
|
|
||||||
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after each
|
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after each
|
||||||
// consent redirect). Re-checked when the account or its grants change.
|
// consent redirect). Re-checked when the account or its grants change.
|
||||||
@@ -39,8 +56,8 @@ export function WizardProvider({ children }: { children: ReactNode }) {
|
|||||||
if (meId == null || canRead == null) return;
|
if (meId == null || canRead == null) return;
|
||||||
const account = { can_read: canRead, google_enabled: googleEnabled };
|
const account = { can_read: canRead, google_enabled: googleEnabled };
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
|
||||||
if (shouldAutoOpenOnboarding(account)) setOpen(true);
|
if (shouldAutoOpenOnboarding(account, dismissed)) setOpen(true);
|
||||||
}, [meId, canRead, canWrite, googleEnabled]);
|
}, [meId, canRead, canWrite, googleEnabled, dismissed]);
|
||||||
|
|
||||||
const actions = useMemo<WizardActions>(
|
const actions = useMemo<WizardActions>(
|
||||||
() => ({ openWizard, closeWizard }),
|
() => ({ openWizard, closeWizard }),
|
||||||
@@ -52,7 +69,7 @@ export function WizardProvider({ children }: { children: ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
|
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{open && me && <OnboardingWizard me={me} onClose={closeWizard} />}
|
{open && me && <OnboardingWizard me={me} onClose={finishWizard} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WizardActionsContext.Provider>
|
</WizardActionsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-libr
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createElement, type ReactNode } from "react";
|
import { createElement, type ReactNode } from "react";
|
||||||
import { api, type FeedFilters, type ManagedChannel } from "../lib/api";
|
import { api, type FeedFilters, type ManagedChannel } from "../lib/api";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { claimLocalData, LS } from "../lib/storage";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
@@ -28,7 +27,7 @@ const filters: FeedFilters = {
|
|||||||
...clearedFilters({ q: "" } as FeedFilters),
|
...clearedFilters({ q: "" } as FeedFilters),
|
||||||
channelIds: [KNOWN, NEW],
|
channelIds: [KNOWN, NEW],
|
||||||
};
|
};
|
||||||
const namesKey = () => accountKey(LS.channelNames, 5)!;
|
const namesKey = () => LS.channelNames;
|
||||||
|
|
||||||
let resolveChannels: (c: ManagedChannel[]) => void;
|
let resolveChannels: (c: ManagedChannel[]) => void;
|
||||||
let qc: QueryClient;
|
let qc: QueryClient;
|
||||||
@@ -37,7 +36,7 @@ const wrapper = ({ children }: { children: ReactNode }) =>
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
claimLocalData(5);
|
||||||
localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
|
localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
|
||||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
|
|||||||
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
|
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
|
||||||
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
|
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
|
||||||
const remote = job.asset?.thumbnail_url ?? null;
|
const remote = job.asset?.thumbnail_url ?? null;
|
||||||
// `job.poster_url` only SIGNALS a poster exists; the actual <img> src must carry ?account= (see
|
// `job.poster_url` only SIGNALS a poster exists; the <img> src is api.posterImageUrl.
|
||||||
// api.posterImageUrl). A plain <img> can't send the X-Siftlode-Account header, so without it the
|
|
||||||
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
|
|
||||||
// now that the poster is the primary image, reads as a blank Library thumbnail.
|
|
||||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
||||||
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||||
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
|
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
|
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
|
||||||
import { accountKey, LS, readJSON } from "../lib/storage";
|
import { LS, ownKey, readJSON } from "../lib/storage";
|
||||||
|
|
||||||
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
|
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
|
||||||
// (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh).
|
// (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh).
|
||||||
@@ -23,16 +23,16 @@ export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]
|
|||||||
|
|
||||||
export const FILTERS_KEY = LS.filters;
|
export const FILTERS_KEY = LS.filters;
|
||||||
|
|
||||||
// This account's own persisted filters (per-account keys — never another account's). No account
|
// This account's own persisted filters (see ownKey). No account
|
||||||
// (signed out) → defaults.
|
// (signed out) → defaults.
|
||||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
export function loadAccountFilters(): FeedFilters {
|
||||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||||
const fKey = accountKey(FILTERS_KEY, id);
|
const fKey = ownKey(FILTERS_KEY);
|
||||||
let hasStored = false;
|
let hasStored = false;
|
||||||
try {
|
try {
|
||||||
hasStored = !!fKey && localStorage.getItem(fKey) != null;
|
hasStored = !!fKey && localStorage.getItem(fKey) != null;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
// • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the
|
// • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the
|
||||||
// onPop handler: a popstate landing on a URL-only bookmark entry must restore the
|
// onPop handler: a popstate landing on a URL-only bookmark entry must restore the
|
||||||
// channel from ?chan, not close it.
|
// channel from ?chan, not close it.
|
||||||
// Only getAccountRaw is stubbed (the account's stored landing page); everything else is real.
|
// Only getAccountRaw is stubbed (the page the account left open); everything else is real. Whether
|
||||||
|
// to start on Home is the account's preference, handed in.
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
|
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
|
||||||
@@ -15,87 +16,90 @@ vi.mock("../lib/storage", async (importOriginal) => {
|
|||||||
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
|
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { loadInitialPage, channelViewFrom, START_ON_HOME } from "./navigationInit";
|
import { loadInitialPage, channelViewFrom } from "./navigationInit";
|
||||||
import { LS } from "../lib/storage";
|
import { LS } from "../lib/storage";
|
||||||
|
|
||||||
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
||||||
|
|
||||||
|
let startOnHome = true;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setUrl("/");
|
setUrl("/");
|
||||||
|
startOnHome = true;
|
||||||
for (const k of Object.keys(stored)) delete stored[k];
|
for (const k of Object.keys(stored)) delete stored[k];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadInitialPage precedence", () => {
|
describe("loadInitialPage precedence", () => {
|
||||||
it("?page wins and is validated", () => {
|
it("?page wins and is validated", () => {
|
||||||
setUrl("?page=stats");
|
setUrl("?page=stats");
|
||||||
expect(loadInitialPage()).toBe("stats");
|
expect(loadInitialPage(startOnHome)).toBe("stats");
|
||||||
});
|
});
|
||||||
it("an invalid ?page falls back to feed", () => {
|
it("an invalid ?page falls back to feed", () => {
|
||||||
setUrl("?page=bogus");
|
setUrl("?page=bogus");
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("?v implies the plex module", () => {
|
it("?v implies the plex module", () => {
|
||||||
setUrl("?v=show:1");
|
setUrl("?v=show:1");
|
||||||
expect(loadInitialPage()).toBe("plex");
|
expect(loadInitialPage(startOnHome)).toBe("plex");
|
||||||
});
|
});
|
||||||
it("?pl implies playlists", () => {
|
it("?pl implies playlists", () => {
|
||||||
setUrl("?pl=3");
|
setUrl("?pl=3");
|
||||||
expect(loadInitialPage()).toBe("playlists");
|
expect(loadInitialPage(startOnHome)).toBe("playlists");
|
||||||
});
|
});
|
||||||
it.each(["?folder=4", "?item=12", "?folder=all&item=12"])(
|
it.each(["?folder=4", "?item=12", "?folder=all&item=12"])(
|
||||||
"%s implies the Files page (a folder, or a file to arrive at)",
|
"%s implies the Files page (a folder, or a file to arrive at)",
|
||||||
(search) => {
|
(search) => {
|
||||||
setUrl(search);
|
setUrl(search);
|
||||||
expect(loadInitialPage()).toBe("files");
|
expect(loadInitialPage(startOnHome)).toBe("files");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it("a filter param implies the feed", () => {
|
it("a filter param implies the feed", () => {
|
||||||
setUrl("?tags=1,2");
|
setUrl("?tags=1,2");
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("?chan (the channel overlay) implies the feed", () => {
|
it("?chan (the channel overlay) implies the feed", () => {
|
||||||
setUrl("?chan=UC1");
|
setUrl("?chan=UC1");
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("?page beats a lower-precedence param", () => {
|
it("?page beats a lower-precedence param", () => {
|
||||||
setUrl("?page=stats&v=show:1");
|
setUrl("?page=stats&v=show:1");
|
||||||
expect(loadInitialPage()).toBe("stats");
|
expect(loadInitialPage(startOnHome)).toBe("stats");
|
||||||
});
|
});
|
||||||
it("?v beats ?pl and filters", () => {
|
it("?v beats ?pl and filters", () => {
|
||||||
setUrl("?v=show:1&pl=3&tags=1");
|
setUrl("?v=show:1&pl=3&tags=1");
|
||||||
expect(loadInitialPage()).toBe("plex");
|
expect(loadInitialPage(startOnHome)).toBe("plex");
|
||||||
});
|
});
|
||||||
// A bare URL lands on Home, OVER the remembered module. Landing on
|
// A bare URL lands on Home, OVER the remembered module. Landing on
|
||||||
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
|
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
|
||||||
// exists to replace, so the remembered page only wins once the account opts out.
|
// exists to replace, so the remembered page only wins once the account opts out.
|
||||||
it("a bare URL lands on Home even when another module is remembered", () => {
|
it("a bare URL lands on Home even when another module is remembered", () => {
|
||||||
stored[LS.page] = "plex";
|
stored[LS.page] = "plex";
|
||||||
expect(loadInitialPage()).toBe("home");
|
expect(loadInitialPage(startOnHome)).toBe("home");
|
||||||
});
|
});
|
||||||
it("opting out restores the account's stored landing page", () => {
|
it("opting out restores the account's stored landing page", () => {
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
startOnHome = false;
|
||||||
stored[LS.page] = "plex";
|
stored[LS.page] = "plex";
|
||||||
expect(loadInitialPage()).toBe("plex");
|
expect(loadInitialPage(startOnHome)).toBe("plex");
|
||||||
});
|
});
|
||||||
// The Downloads page is gone; what it held is in Files. A bookmark and a remembered page both land
|
// The Downloads page is gone; what it held is in Files. A bookmark and a remembered page both land
|
||||||
// there instead of on the feed.
|
// there instead of on the feed.
|
||||||
it("a retired page lands where its content went — linked or remembered", () => {
|
it("a retired page lands where its content went — linked or remembered", () => {
|
||||||
setUrl("?page=downloads");
|
setUrl("?page=downloads");
|
||||||
expect(loadInitialPage()).toBe("files");
|
expect(loadInitialPage(startOnHome)).toBe("files");
|
||||||
setUrl("/");
|
setUrl("/");
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
startOnHome = false;
|
||||||
stored[LS.page] = "downloads";
|
stored[LS.page] = "downloads";
|
||||||
expect(loadInitialPage()).toBe("files");
|
expect(loadInitialPage(startOnHome)).toBe("files");
|
||||||
});
|
});
|
||||||
it("opted out with no / invalid stored page still defaults to feed", () => {
|
it("opted out with no / invalid stored page still defaults to feed", () => {
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
startOnHome = false;
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
stored[LS.page] = "nope";
|
stored[LS.page] = "nope";
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("a deep link still beats Home", () => {
|
it("a deep link still beats Home", () => {
|
||||||
setUrl("?pl=3");
|
setUrl("?pl=3");
|
||||||
expect(loadInitialPage()).toBe("playlists");
|
expect(loadInitialPage(startOnHome)).toBe("playlists");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,25 @@
|
|||||||
// channelView seed and the onPop handler.
|
// channelView seed and the onPop handler.
|
||||||
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
|
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
|
||||||
import { getAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, LS } from "../lib/storage";
|
||||||
|
import type { UsePrefOptions } from "../lib/usePref";
|
||||||
|
|
||||||
export const PAGE_KEY = LS.page;
|
export const PAGE_KEY = LS.page;
|
||||||
// Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would
|
/** The landing preference — start on Home, or on the module left open — an account preference
|
||||||
// only be a second name for the same key.
|
* (usePref). Settings → Appearance → Startup writes it and NavigationProvider reads it for
|
||||||
const START_HOME_KEY = LS.startOnHome;
|
* `loadInitialPage`, so its key and its reading are defined ONCE. A copy this browser kept before
|
||||||
|
* the server held it is the string "0" (off) or "1". */
|
||||||
/** The two stored values of the landing preference (raw strings, via useAccountPersistedState).
|
export const START_ON_HOME_PREF = "startOnHome";
|
||||||
* Settings → Appearance → Startup writes them and `loadInitialPage` reads them, so they are defined
|
export const START_ON_HOME_OPTIONS: UsePrefOptions<boolean> = {
|
||||||
* ONCE: the same magic string spelled out in a writer and a reader is precisely the drift that has
|
localKey: LS.startOnHome,
|
||||||
* bitten this codebase before (`cancelled` vs `canceled`). */
|
parse: (raw) => raw !== false && raw !== "0" && raw !== 0,
|
||||||
export const START_ON_HOME = { on: "1", off: "0" } as const;
|
};
|
||||||
|
|
||||||
export interface ChannelView {
|
export interface ChannelView {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadInitialPage(): Page {
|
export function loadInitialPage(startOnHome: boolean): Page {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has("page")) return readPage();
|
if (params.has("page")) return readPage();
|
||||||
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
|
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
|
||||||
@@ -35,7 +36,7 @@ export function loadInitialPage(): Page {
|
|||||||
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
|
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
|
||||||
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
|
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
|
||||||
// module back.
|
// module back.
|
||||||
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
|
if (startOnHome) return "home";
|
||||||
return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
|
return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
||||||
],
|
],
|
||||||
"LanguageSwitcher.tsx": [1, "an option row"],
|
"LanguageSwitcher.tsx": [1, "an option row"],
|
||||||
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
"NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||||
"PaletteLauncher.tsx": [2, "launcher controls"],
|
"PaletteLauncher.tsx": [2, "launcher controls"],
|
||||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.109.0",
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"new": [
|
||||||
|
"Accounts: a browser is signed in to one account at a time. The account menu's \"Sign in as someone else\" replaces it; the account list and switcher are gone. After this update everyone signs in once more.",
|
||||||
|
"Settings follow you: the Home start page, the channel manager and Plex playlist layouts, the discovery sorts and a dismissed onboarding or release banner are saved to your account, on every device. Performance mode stays per device.",
|
||||||
|
"Development: every key the browser stores declares its kind, held by a contract test; one Google connect route serves the sign-in and the YouTube access grants."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Accounts: a tab still showing another account than the one signed in now reloads as that one and says why; a failed \"Sign in as someone else\" says what happened.",
|
||||||
|
"A link whose path starts with // no longer opens the error screen.",
|
||||||
|
"A member following a link to an admin page lands on the feed, with the rail and the address bar in step.",
|
||||||
|
"Google: an unknown access request is refused instead of quietly asking for read access, and a YouTube grant on an instance without Google says so."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.108.1",
|
"version": "0.108.1",
|
||||||
"date": "2026-10-20",
|
"date": "2026-10-20",
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.109.0",
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"new": [
|
||||||
|
"Fiókok: egy böngészőben egyszerre egy fiók van bejelentkezve. A fiókmenü „Belépés más fiókkal” sora lecseréli; a fióklista és a fiókváltó megszűnt. A frissítés után mindenki egyszer újra bejelentkezik.",
|
||||||
|
"A beállítások veled jönnek: az indulás a Kezdőlapon, a csatornakezelő és a Plex lejátszási lista elrendezése, a felfedezők rendezése és a bezárt bevezető vagy kiadási sáv a fiókodba mentődik, minden eszközön. A teljesítmény mód eszközönként marad.",
|
||||||
|
"Fejlesztés: a böngészőben tárolt minden kulcs megmondja a fajtáját, ezt egy contract teszt tartja; egyetlen Google-csatlakozási útvonal szolgálja a bejelentkezést és a YouTube-hozzáférést."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Fiókok: ha egy fül még másik fiókot mutat, mint amelyik most be van jelentkezve, újratöltődik azzal, és megmondja, miért; a sikertelen „Belépés más fiókkal” megmondja, mi történt.",
|
||||||
|
"A // kezdetű útvonalú link már nem a hibaképernyőt nyitja meg.",
|
||||||
|
"Ha egy tag egy admin oldalra mutató linket nyit meg, a feedre jut, és a menüsáv meg a címsor is ezt mutatja.",
|
||||||
|
"Google: ismeretlen hozzáférés-kérés elutasítódik ahelyett, hogy csendben olvasási jogot kérne, és Google nélküli példányon a YouTube-engedélyezés ezt jelzi."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.108.1",
|
"version": "0.108.1",
|
||||||
"date": "2026-10-20",
|
"date": "2026-10-20",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
"screenshot": "This screenshot doesn't exist any more",
|
"screenshot": "This screenshot doesn't exist any more",
|
||||||
"link": "This share link doesn't exist any more",
|
"link": "This share link doesn't exist any more",
|
||||||
"notification": "This notification doesn't exist any more",
|
"notification": "This notification doesn't exist any more",
|
||||||
"account": "That account no longer exists",
|
|
||||||
"collection": "This collection doesn't exist any more",
|
"collection": "This collection doesn't exist any more",
|
||||||
"config_key": "There's no such setting",
|
"config_key": "There's no such setting",
|
||||||
"file": "The file is no longer available",
|
"file": "The file is no longer available",
|
||||||
@@ -56,6 +55,7 @@
|
|||||||
},
|
},
|
||||||
"writeFailed": {
|
"writeFailed": {
|
||||||
"hideChannel": "Couldn't change whether the channel is hidden",
|
"hideChannel": "Couldn't change whether the channel is hidden",
|
||||||
|
"signOut": "Couldn't sign you out",
|
||||||
"blockChannel": "Couldn't change the channel's block",
|
"blockChannel": "Couldn't change the channel's block",
|
||||||
"unblockChannel": "Couldn't unblock the channel",
|
"unblockChannel": "Couldn't unblock the channel",
|
||||||
"channelSettings": "Couldn't save the channel's settings",
|
"channelSettings": "Couldn't save the channel's settings",
|
||||||
@@ -135,9 +135,7 @@
|
|||||||
"gone": "This playlist no longer exists on YouTube"
|
"gone": "This playlist no longer exists on YouTube"
|
||||||
},
|
},
|
||||||
"refused": {
|
"refused": {
|
||||||
"account_no_access": "That account no longer has access",
|
"account_changed": "This tab was showing another account than the one signed in now, so it reloads as that one",
|
||||||
"account_signed_out": "That account was signed out elsewhere — sign in again",
|
|
||||||
"account_suspended": "That account is suspended",
|
|
||||||
"address_has_account": "That address already has an account — delete the account instead",
|
"address_has_account": "That address already has an account — delete the account instead",
|
||||||
"admin_first": "Create the admin account first",
|
"admin_first": "Create the admin account first",
|
||||||
"admin_only": "Only an admin can do that",
|
"admin_only": "Only an admin can do that",
|
||||||
@@ -174,7 +172,6 @@
|
|||||||
"not_authenticated": "You're not signed in",
|
"not_authenticated": "You're not signed in",
|
||||||
"not_ready": "Not available yet",
|
"not_ready": "Not available yet",
|
||||||
"not_redownloadable": "This download can't be downloaded again",
|
"not_redownloadable": "This download can't be downloaded again",
|
||||||
"not_your_account": "That's not an account you've signed into here",
|
|
||||||
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account",
|
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account",
|
||||||
"own_delete": "Delete your own account in Settings → Account",
|
"own_delete": "Delete your own account in Settings → Account",
|
||||||
"own_role": "You can't change your own role",
|
"own_role": "You can't change your own role",
|
||||||
|
|||||||
@@ -30,8 +30,11 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
"addAccount": "Add another account",
|
"signInAsOther": "Sign in as someone else",
|
||||||
"switchTo": "Switch to {{name}}",
|
"switchNotApproved": "That Google account isn't approved for this instance yet, so you're still signed in as before.",
|
||||||
|
"switchSuspended": "That account is suspended, so you're still signed in as before.",
|
||||||
|
"switchRequested": "We recorded an access request for {{email}}. Until an admin approves it, you're still signed in as before.",
|
||||||
|
"tabSwitched": "This tab was showing another account, so it reloaded as the one signed in now.",
|
||||||
"googleAddress": "Google account: {{email}}",
|
"googleAddress": "Google account: {{email}}",
|
||||||
"activity": "Activity"
|
"activity": "Activity"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"backgroundImageHint": "Show a subtle per-scheme backdrop image behind the app so the glass surfaces have something to refract. Off = flat colour.",
|
"backgroundImageHint": "Show a subtle per-scheme backdrop image behind the app so the glass surfaces have something to refract. Off = flat colour.",
|
||||||
"backgroundFade": "Image fade",
|
"backgroundFade": "Image fade",
|
||||||
"performanceMode": "Performance mode",
|
"performanceMode": "Performance mode",
|
||||||
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines. On this device only.",
|
||||||
"showHints": "Show hints",
|
"showHints": "Show hints",
|
||||||
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
||||||
"railLabels": "Labels in the side menu",
|
"railLabels": "Labels in the side menu",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
"screenshot": "Ez a képernyőkép már nem létezik",
|
"screenshot": "Ez a képernyőkép már nem létezik",
|
||||||
"link": "Ez a megosztási link már nem létezik",
|
"link": "Ez a megosztási link már nem létezik",
|
||||||
"notification": "Ez az értesítés már nem létezik",
|
"notification": "Ez az értesítés már nem létezik",
|
||||||
"account": "Ez a fiók már nem létezik",
|
|
||||||
"collection": "Ez a kollekció már nem létezik",
|
"collection": "Ez a kollekció már nem létezik",
|
||||||
"config_key": "Nincs ilyen beállítás",
|
"config_key": "Nincs ilyen beállítás",
|
||||||
"file": "A fájl már nem érhető el",
|
"file": "A fájl már nem érhető el",
|
||||||
@@ -56,6 +55,7 @@
|
|||||||
},
|
},
|
||||||
"writeFailed": {
|
"writeFailed": {
|
||||||
"hideChannel": "Nem sikerült módosítani a csatorna elrejtését",
|
"hideChannel": "Nem sikerült módosítani a csatorna elrejtését",
|
||||||
|
"signOut": "Nem sikerült kijelentkeztetni",
|
||||||
"blockChannel": "Nem sikerült módosítani a csatorna tiltását",
|
"blockChannel": "Nem sikerült módosítani a csatorna tiltását",
|
||||||
"unblockChannel": "Nem sikerült feloldani a csatorna tiltását",
|
"unblockChannel": "Nem sikerült feloldani a csatorna tiltását",
|
||||||
"channelSettings": "Nem sikerült menteni a csatorna beállításait",
|
"channelSettings": "Nem sikerült menteni a csatorna beállításait",
|
||||||
@@ -135,9 +135,7 @@
|
|||||||
"gone": "Ez a lejátszási lista már nem létezik a YouTube-on"
|
"gone": "Ez a lejátszási lista már nem létezik a YouTube-on"
|
||||||
},
|
},
|
||||||
"refused": {
|
"refused": {
|
||||||
"account_no_access": "Ennek a fióknak már nincs hozzáférése",
|
"account_changed": "Ez a fül más fiókot mutatott, mint amelyik most be van jelentkezve, ezért azzal töltődik újra",
|
||||||
"account_signed_out": "Ezt a fiókot máshol kijelentkeztették — lépj be újra",
|
|
||||||
"account_suspended": "Ez a fiók fel van függesztve",
|
|
||||||
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette",
|
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette",
|
||||||
"admin_first": "Előbb hozd létre az admin fiókot",
|
"admin_first": "Előbb hozd létre az admin fiókot",
|
||||||
"admin_only": "Ezt csak admin teheti meg",
|
"admin_only": "Ezt csak admin teheti meg",
|
||||||
@@ -174,7 +172,6 @@
|
|||||||
"not_authenticated": "Nem vagy bejelentkezve",
|
"not_authenticated": "Nem vagy bejelentkezve",
|
||||||
"not_ready": "Még nem érhető el",
|
"not_ready": "Még nem érhető el",
|
||||||
"not_redownloadable": "Ez a letöltés nem tölthető le újra",
|
"not_redownloadable": "Ez a letöltés nem tölthető le újra",
|
||||||
"not_your_account": "Ebbe a fiókba itt nem léptél be",
|
|
||||||
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak",
|
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak",
|
||||||
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted",
|
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted",
|
||||||
"own_role": "A saját szerepkörödet nem módosíthatod",
|
"own_role": "A saját szerepkörödet nem módosíthatod",
|
||||||
|
|||||||
@@ -30,8 +30,11 @@
|
|||||||
"settings": "Beállítások",
|
"settings": "Beállítások",
|
||||||
"about": "Névjegy",
|
"about": "Névjegy",
|
||||||
"signOut": "Kijelentkezés",
|
"signOut": "Kijelentkezés",
|
||||||
"addAccount": "Másik fiók hozzáadása",
|
"signInAsOther": "Belépés más fiókkal",
|
||||||
"switchTo": "Váltás erre: {{name}}",
|
"switchNotApproved": "Ez a Google-fiók még nincs jóváhagyva ezen a példányon, ezért továbbra is az eddigi fiókkal vagy belépve.",
|
||||||
|
"switchSuspended": "Az a fiók fel van függesztve, ezért továbbra is az eddigi fiókkal vagy belépve.",
|
||||||
|
"switchRequested": "Rögzítettük a hozzáférési kérelmet ehhez a címhez: {{email}}. Amíg egy admin jóvá nem hagyja, továbbra is az eddigi fiókkal vagy belépve.",
|
||||||
|
"tabSwitched": "Ez a fül egy másik fiókot mutatott, ezért újratöltődött az éppen bejelentkezett fiókkal.",
|
||||||
"googleAddress": "Google-fiók: {{email}}",
|
"googleAddress": "Google-fiók: {{email}}",
|
||||||
"activity": "Napló"
|
"activity": "Napló"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"backgroundImageHint": "Halvány, sémánkénti háttérkép az app mögött, hogy az üveges felületeknek legyen mit megtörniük. Kikapcsolva sima szín.",
|
"backgroundImageHint": "Halvány, sémánkénti háttérkép az app mögött, hogy az üveges felületeknek legyen mit megtörniük. Kikapcsolva sima szín.",
|
||||||
"backgroundFade": "Kép elhalványítása",
|
"backgroundFade": "Kép elhalványítása",
|
||||||
"performanceMode": "Teljesítmény mód",
|
"performanceMode": "Teljesítmény mód",
|
||||||
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
|
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken. Csak ezen az eszközön.",
|
||||||
"showHints": "Tippek megjelenítése",
|
"showHints": "Tippek megjelenítése",
|
||||||
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
||||||
"railLabels": "Feliratok az oldalsó menüben",
|
"railLabels": "Feliratok az oldalsó menüben",
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
//
|
|
||||||
// The notification and hints stores read their per-account localStorage when they are imported. In a new
|
|
||||||
// tab (bookmark, typed URL: no pin in sessionStorage) that is before BootGate pins the account, so they
|
|
||||||
// read as nobody's; they must read again on the pin. The notification store used to keep its empty,
|
|
||||||
// nobody's history, and the tab's first notice replaced the account's stored history with it.
|
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const entry = (id: number, message: string) => ({
|
|
||||||
id,
|
|
||||||
level: "info",
|
|
||||||
message,
|
|
||||||
ts: 1,
|
|
||||||
read: true,
|
|
||||||
dismissed: true,
|
|
||||||
transient: false,
|
|
||||||
repeat: 1,
|
|
||||||
requiresInteraction: false,
|
|
||||||
});
|
|
||||||
const storedMessages = (account: number) =>
|
|
||||||
(
|
|
||||||
JSON.parse(localStorage.getItem(`siftlode.notifications.${account}`) ?? "[]") as {
|
|
||||||
message: string;
|
|
||||||
}[]
|
|
||||||
).map((n) => n.message);
|
|
||||||
|
|
||||||
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
|
|
||||||
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
|
|
||||||
// vi.resetModules, so each case still gets fresh module instances, only faster.
|
|
||||||
beforeAll(async () => {
|
|
||||||
await import("./notifications");
|
|
||||||
await import("./hints");
|
|
||||||
await import("./api/core");
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
|
|
||||||
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
|
|
||||||
// workers share a busy machine.
|
|
||||||
const CASE_BUDGET = 30_000;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
sessionStorage.clear();
|
|
||||||
vi.resetModules(); // both stores load at import: each test needs a fresh one
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("stores read at import, in a tab not yet pinned", { timeout: CASE_BUDGET }, () => {
|
|
||||||
it("keep the account's notification history and settings once the tab is pinned", async () => {
|
|
||||||
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")]));
|
|
||||||
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
|
|
||||||
const notifications = await import("./notifications");
|
|
||||||
const { setActiveAccount } = await import("./api/core");
|
|
||||||
|
|
||||||
notifications.notify({ message: "before the pin" }); // e.g. a failed boot probe
|
|
||||||
setActiveAccount(5);
|
|
||||||
notifications.notify({ message: "after the pin" });
|
|
||||||
|
|
||||||
expect(storedMessages(5)).toEqual(["older", "before the pin", "after the pin"]);
|
|
||||||
expect(notifications.getNotifSettings().durationMs).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("read the account's hints choice once the tab is pinned", async () => {
|
|
||||||
localStorage.setItem("siftlode.hints.5", "0");
|
|
||||||
const hints = await import("./hints");
|
|
||||||
const { setActiveAccount } = await import("./api/core");
|
|
||||||
|
|
||||||
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
|
|
||||||
setActiveAccount(5);
|
|
||||||
expect(hints.hintsEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a pinned tab pinned to another account", { timeout: CASE_BUDGET }, () => {
|
|
||||||
it("leaves the previous account's notices with that account", async () => {
|
|
||||||
// BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as.
|
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "4");
|
|
||||||
localStorage.setItem("siftlode.notifications.4", JSON.stringify([entry(7, "four's")]));
|
|
||||||
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(8, "five's")]));
|
|
||||||
const notifications = await import("./notifications");
|
|
||||||
const { setActiveAccount } = await import("./api/core");
|
|
||||||
|
|
||||||
notifications.notify({ message: "as four" });
|
|
||||||
setActiveAccount(5);
|
|
||||||
notifications.notify({ message: "as five" });
|
|
||||||
|
|
||||||
expect(storedMessages(5)).toEqual(["five's", "as five"]);
|
|
||||||
expect(storedMessages(4)).toEqual(["four's", "as four"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// "Sign in as someone else" with a Google account the server will not sign in (not approved, or
|
||||||
|
// suspended) comes back to the account still signed in here, with the flags the sign-in page reads;
|
||||||
|
// a tab the server refused because another account signed in under it reloads with a flag of its
|
||||||
|
// own. Each says so in the signed-in app's words, not the sign-in page's.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
ACCOUNT_CHANGED_FLAG,
|
||||||
|
accountChangedUrl,
|
||||||
|
accountSwitchNotice,
|
||||||
|
clearEntryUrl,
|
||||||
|
reloadAfterRefusal,
|
||||||
|
} from "./entryUrl";
|
||||||
|
|
||||||
|
const notice = (search: string, hash = "") =>
|
||||||
|
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a switch of account, told to the account signed in", () => {
|
||||||
|
it("names the address a request was recorded for", () => {
|
||||||
|
expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({
|
||||||
|
level: "info",
|
||||||
|
key: "header.account.switchRequested",
|
||||||
|
values: { email: "b+tag@example.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the address the request lives under when that is another spelling", () => {
|
||||||
|
expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({
|
||||||
|
email: "b.ob@x.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the account is not approved, and nothing changed here", () => {
|
||||||
|
expect(notice("", "access=denied")).toEqual({
|
||||||
|
level: "warning",
|
||||||
|
key: "header.account.switchNotApproved",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says that account is suspended, not this one", () => {
|
||||||
|
expect(notice("?login=suspended")).toEqual({
|
||||||
|
level: "warning",
|
||||||
|
key: "header.account.switchSuspended",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the tab reloaded as the account signed in now", () => {
|
||||||
|
expect(notice(`?page=stats&${ACCOUNT_CHANGED_FLAG}`)).toEqual({
|
||||||
|
level: "info",
|
||||||
|
key: "header.account.tabSwitched",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing on an ordinary load", () => {
|
||||||
|
expect(notice("?page=stats", "")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the reload of a tab another account signed in under", () => {
|
||||||
|
it("stays on its page and carries the flag", () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=stats&chan=UC1#x=1");
|
||||||
|
const url = new URL(accountChangedUrl(), "http://h");
|
||||||
|
expect(url.pathname).toBe("/");
|
||||||
|
expect(Object.fromEntries(url.searchParams)).toEqual({
|
||||||
|
page: "stats",
|
||||||
|
chan: "UC1",
|
||||||
|
account: "changed",
|
||||||
|
});
|
||||||
|
expect(url.hash).toBe("#x=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the flag once read, so a later reload does not say it again", () => {
|
||||||
|
window.history.replaceState(null, "", `/?page=stats&${ACCOUNT_CHANGED_FLAG}`);
|
||||||
|
clearEntryUrl();
|
||||||
|
expect(window.location.search).toBe("?page=stats");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays on this origin when the path starts with //", () => {
|
||||||
|
// Glued as `pathname + query`, a path of `//evil.com/x` names another host: history refuses
|
||||||
|
// it, and the boot that strips the flag threw into the crash screen.
|
||||||
|
const origin = window.location.origin;
|
||||||
|
window.history.replaceState(null, "", `${origin}//evil.com/x?page=stats`);
|
||||||
|
window.history.replaceState(null, "", accountChangedUrl());
|
||||||
|
expect(window.location.origin).toBe(origin);
|
||||||
|
expect(window.location.pathname).toBe("//evil.com/x");
|
||||||
|
clearEntryUrl();
|
||||||
|
expect(window.location.origin).toBe(origin);
|
||||||
|
expect(window.location.pathname).toBe("//evil.com/x");
|
||||||
|
expect(window.location.search).toBe("?page=stats");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a tab whose session the server refused", () => {
|
||||||
|
const stubLocation = () => {
|
||||||
|
const loc = {
|
||||||
|
href: "http://h/?page=stats",
|
||||||
|
pathname: "/",
|
||||||
|
search: "?page=stats",
|
||||||
|
hash: "",
|
||||||
|
replace: vi.fn(),
|
||||||
|
reload: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("location", loc);
|
||||||
|
return loc;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reloads as the account signed in now, saying why, when it showed another", () => {
|
||||||
|
const loc = stubLocation();
|
||||||
|
reloadAfterRefusal({ detailData: { code: "refused", reason: "account_changed" } });
|
||||||
|
expect(loc.replace).toHaveBeenCalledWith(`/?page=stats&${ACCOUNT_CHANGED_FLAG}`);
|
||||||
|
expect(loc.reload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads plainly when the session ended", () => {
|
||||||
|
const loc = stubLocation();
|
||||||
|
reloadAfterRefusal({ detailData: { code: "refused", reason: "not_authenticated" } });
|
||||||
|
expect(loc.reload).toHaveBeenCalled();
|
||||||
|
expect(loc.replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
//
|
|
||||||
// A switch or sign-out hands the tab's next page its account instead of moving the pin: the page being left
|
|
||||||
// still unloads as its own account. The players' resume beacons read the pin as they fire on the way out,
|
|
||||||
// and with the pin already moved they saved one account's playback position into the other.
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "3");
|
|
||||||
vi.resetModules(); // the hand-over is taken at import: each import below is a page load
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pinOnNextPage", () => {
|
|
||||||
it("leaves the pin alone until the next page, which takes the account over once", async () => {
|
|
||||||
const leaving = await import("./activeAccount");
|
|
||||||
leaving.pinOnNextPage(7);
|
|
||||||
expect(leaving.activeAccountId()).toBe(3); // the unload still acts as 3
|
|
||||||
|
|
||||||
vi.resetModules();
|
|
||||||
const next = await import("./activeAccount");
|
|
||||||
expect(next.activeAccountId()).toBe(7);
|
|
||||||
|
|
||||||
next.pinActiveAccount(9); // BootGate corrects it
|
|
||||||
vi.resetModules(); // and an F5 keeps the correction
|
|
||||||
expect((await import("./activeAccount")).activeAccountId()).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hands over no account at all, so the next page adopts the session's default", async () => {
|
|
||||||
const leaving = await import("./activeAccount");
|
|
||||||
leaving.pinOnNextPage(null);
|
|
||||||
expect(leaving.activeAccountId()).toBe(3);
|
|
||||||
|
|
||||||
vi.resetModules();
|
|
||||||
expect((await import("./activeAccount")).activeAccountId()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Its OWN module
|
|
||||||
// (imports nothing internal) so it can be shared by `storage.ts`, which keys per-account localStorage
|
|
||||||
// on it, without a circular import — the account id is a lower-level primitive than the storage
|
|
||||||
// helpers that consume it.
|
|
||||||
//
|
|
||||||
// Deliberately NOT reactive: BootGate pins the account before any provider mounts, and a switch reloads
|
|
||||||
// the page (handing the new pin over through `pinOnNextPage`), so nothing mounted ever sees the id change
|
|
||||||
// underneath it. The one exception is state read at
|
|
||||||
// module import (the notification and hints stores), which in a new tab runs before the pin: it reads
|
|
||||||
// again through `onAccountPinned`.
|
|
||||||
//
|
|
||||||
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
|
|
||||||
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
|
|
||||||
|
|
||||||
// Re-readers for module-level per-account state, run each time the tab is pinned.
|
|
||||||
const pinListeners = new Set<() => void>();
|
|
||||||
|
|
||||||
export function onAccountPinned(reload: () => void): void {
|
|
||||||
pinListeners.add(reload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pinActiveAccount(id: number): void {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
pinListeners.forEach((reload) => reload());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The account the tab's NEXT page is pinned to, handed over by a switch or sign-out that is about to leave.
|
|
||||||
// The pin itself stays until the page is gone: the unload still runs as the account the page was built for
|
|
||||||
// (the players' resume beacons read the pin as they fire), and with the pin already moved it would save one
|
|
||||||
// account's position into the other.
|
|
||||||
const NEXT_ACCOUNT_KEY = "siftlode.nextAccount";
|
|
||||||
|
|
||||||
/** Pin the tab's next page to `id` — or to none, so it adopts the session's default account. */
|
|
||||||
export function pinOnNextPage(id: number | null): void {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(NEXT_ACCOUNT_KEY, id == null ? "" : String(id));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taken over at import, before anything reads the pin.
|
|
||||||
try {
|
|
||||||
const next = sessionStorage.getItem(NEXT_ACCOUNT_KEY);
|
|
||||||
if (next != null) {
|
|
||||||
sessionStorage.removeItem(NEXT_ACCOUNT_KEY);
|
|
||||||
if (next) sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, next);
|
|
||||||
else sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeAccountId(): number | null {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const n = Number(raw);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append `?account=<active>` to a top-level navigation URL (SEC-11). XHRs carry the active account in
|
|
||||||
// the X-Siftlode-Account header, but a full-page navigation (window.location) can't, so the OAuth
|
|
||||||
// link/upgrade GETs would resolve to the SESSION DEFAULT account instead of the tab's active one — the
|
|
||||||
// identity/token would then land on the wrong account. This threads the active account through the URL
|
|
||||||
// so resolved_user_id picks it. No-op when there's no distinct active account pinned yet.
|
|
||||||
export function withActiveAccount(path: string): string {
|
|
||||||
const id = activeAccountId();
|
|
||||||
if (id == null) return path;
|
|
||||||
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
// 3. spreads each domain's method slice into the single `api` object consumers call.
|
// 3. spreads each domain's method slice into the single `api` object consumers call.
|
||||||
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
|
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
|
||||||
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
|
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
|
||||||
import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core";
|
import { HttpError, setUnauthorizedHandler } from "./api/core";
|
||||||
|
|
||||||
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
|
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
|
||||||
export { HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
|
export { HttpError, setUnauthorizedHandler };
|
||||||
|
|
||||||
import { accountApi } from "./api/account";
|
import { accountApi } from "./api/account";
|
||||||
export type * from "./api/account";
|
export type * from "./api/account";
|
||||||
|
|||||||
@@ -49,14 +49,6 @@ export function displayName(a: { display_name: string | null; email: string }):
|
|||||||
return a.display_name ?? a.email.split("@")[0]!;
|
return a.display_name ?? a.email.split("@")[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Account {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar_url: string | null;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VersionInfo {
|
export interface VersionInfo {
|
||||||
app_version: string;
|
app_version: string;
|
||||||
git_sha: string;
|
git_sha: string;
|
||||||
@@ -73,14 +65,9 @@ export const accountApi = {
|
|||||||
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
|
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
|
||||||
return r.authenticated ? (r as Me) : null;
|
return r.authenticated ? (r as Me) : null;
|
||||||
},
|
},
|
||||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
|
||||||
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
|
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
|
||||||
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
// Ends this browser's session.
|
||||||
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
logout: (): Promise<{ ok: boolean }> => req("/auth/logout", { method: "POST" }),
|
||||||
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
|
|
||||||
// X-Siftlode-Account header that req() attaches).
|
|
||||||
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
|
|
||||||
req("/auth/logout", { method: "POST" }),
|
|
||||||
version: (): Promise<VersionInfo> => req("/api/version"),
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||||
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
|
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
|
||||||
savePrefs: (p: Record<string, unknown>) =>
|
savePrefs: (p: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
|
|||||||
vi.mock("../errorDialog", () => ({ reportError }));
|
vi.mock("../errorDialog", () => ({ reportError }));
|
||||||
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
|
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
|
||||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||||
vi.mock("../activeAccount", () => ({
|
|
||||||
activeAccountId: () => null,
|
|
||||||
ACTIVE_ACCOUNT_KEY: "k",
|
|
||||||
pinActiveAccount: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { setDebugSession } from "../log";
|
import { setDebugSession } from "../log";
|
||||||
|
import { claimLocalData } from "../storage";
|
||||||
import { downloadsApi } from "./downloads";
|
import { downloadsApi } from "./downloads";
|
||||||
import { filesApi } from "./files";
|
import { filesApi } from "./files";
|
||||||
import {
|
import {
|
||||||
@@ -155,9 +151,9 @@ describe("uploadReq", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("lets its own identity headers win over a caller's", () => {
|
it("lets its own identity headers win over a caller's", () => {
|
||||||
// Who a request is from is this layer's to state, not its caller's. Merged the other way round,
|
// What a request is part of is this layer's to state, not its caller's. Merged the other way
|
||||||
// a caller's header of the same name would silently re-attribute the upload — to another
|
// round, a caller's header of the same name would silently re-attribute the upload into someone
|
||||||
// account, or (here) into someone else's debug session recording.
|
// else's debug session recording.
|
||||||
setDebugSession("0123456789abcdef");
|
setDebugSession("0123456789abcdef");
|
||||||
try {
|
try {
|
||||||
void uploadReq("/u", body(), {
|
void uploadReq("/u", body(), {
|
||||||
@@ -470,6 +466,30 @@ describe("the debug session a tab records into", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A tab built for one account must not act as another: a sign-in as someone else in another tab
|
||||||
|
// replaces the cookie under this one. Every request path names the account the tab was claimed for,
|
||||||
|
// so the server can refuse it — a beacon or an upload that went out without it would land on the
|
||||||
|
// account signed in now.
|
||||||
|
describe("the account a tab was built for", () => {
|
||||||
|
const sentHeaders = () => {
|
||||||
|
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
|
||||||
|
return new Headers(init.headers as HeadersInit);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("rides every request path once the tab is claimed", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve(new Response("{}", { status: 200 })),
|
||||||
|
);
|
||||||
|
claimLocalData(7);
|
||||||
|
await req("/api/x");
|
||||||
|
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
|
||||||
|
beacon("/api/y", {});
|
||||||
|
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
|
||||||
|
void uploadReq("/u", body());
|
||||||
|
expect(FakeXHR.last.headers["X-Siftlode-Expect-Account"]).toBe("7");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED
|
// The upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED
|
||||||
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
|
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
|
||||||
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
|
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
|
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
|
||||||
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing,
|
// error, structured-error localization, the connectivity toast, the identity headers,
|
||||||
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
|
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
|
||||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||||
import { getActiveToasts, notify, remove } from "../notifications";
|
import { getActiveToasts, notify, remove } from "../notifications";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import { formatBytes, formatDecimal } from "../format";
|
import { formatBytes, formatDecimal } from "../format";
|
||||||
import { reportError } from "../errorDialog";
|
import { reportError } from "../errorDialog";
|
||||||
import { activeAccountId, pinActiveAccount } from "../activeAccount";
|
|
||||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||||
import { currentDebugSession, newRequestId } from "../log";
|
import { currentDebugSession, newRequestId } from "../log";
|
||||||
|
import { claimedAccount } from "../storage";
|
||||||
|
|
||||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||||||
@@ -223,10 +223,11 @@ function reportFailure(err: HttpError, quiet: boolean): void {
|
|||||||
if (RETRIABLE_GATEWAY.has(err.status)) {
|
if (RETRIABLE_GATEWAY.has(err.status)) {
|
||||||
markConnectivityLost();
|
markConnectivityLost();
|
||||||
} else if (err.status === 401) {
|
} else if (err.status === 401) {
|
||||||
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the app
|
// Session ended server-side (e.g. the account was suspended or deleted mid-visit), or this tab
|
||||||
// shell decide what to do (drop to the login page if we were signed in). Not a modal — but
|
// shows another account than the one signed in now. Let the app shell decide what to do (drop to
|
||||||
|
// the login page, or reload as the account signed in, if we were signed in). Not a modal — but
|
||||||
// handled all the same, so not the caller's to toast either.
|
// handled all the same, so not the caller's to toast either.
|
||||||
onUnauthorized?.();
|
onUnauthorized?.(err);
|
||||||
reported.add(err);
|
reported.add(err);
|
||||||
} else if (quiet) {
|
} else if (quiet) {
|
||||||
/* caller handles the error inline — no global modal */
|
/* caller handles the error inline — no global modal */
|
||||||
@@ -256,8 +257,8 @@ export interface ReqConfig {
|
|||||||
// page. The handler itself guards against firing when we were never signed in, so it's safe to
|
// page. The handler itself guards against firing when we were never signed in, so it's safe to
|
||||||
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
|
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
|
||||||
// user when logged out — but other protected endpoints still do.)
|
// user when logged out — but other protected endpoints still do.)
|
||||||
let onUnauthorized: (() => void) | null = null;
|
let onUnauthorized: ((err: HttpError) => void) | null = null;
|
||||||
export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
export function setUnauthorizedHandler(fn: ((err: HttpError) => void) | null): void {
|
||||||
onUnauthorized = fn;
|
onUnauthorized = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,28 +269,27 @@ export const setupHeaders = (token: string) => ({
|
|||||||
"X-Setup-Token": token,
|
"X-Setup-Token": token,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Per-tab active account --------------------------------------------------------------
|
|
||||||
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
|
|
||||||
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
|
|
||||||
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
|
|
||||||
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
|
|
||||||
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
|
|
||||||
// The debug session this tab is recording: the server tags its own lines of the request with
|
// The debug session this tab is recording: the server tags its own lines of the request with
|
||||||
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
|
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
|
||||||
const DEBUG_SESSION_HEADER = "X-Debug-Session";
|
const DEBUG_SESSION_HEADER = "X-Debug-Session";
|
||||||
|
// The account this tab was built for. The cookie alone says who is asking, and a sign-in as someone
|
||||||
|
// else in another tab replaces it under this one too: the server refuses (401) a request whose tab
|
||||||
|
// shows another account than the one signed in, and the 401 reloads the tab as the one who is. It
|
||||||
|
// never picks an account — a player checkpoint or a pref save from a tab still showing the previous
|
||||||
|
// account would otherwise land on the new one.
|
||||||
|
const EXPECTED_ACCOUNT_HEADER = "X-Siftlode-Expect-Account";
|
||||||
|
|
||||||
export const getActiveAccount = activeAccountId;
|
/** What this request carries besides the session cookie (which alone says who is asking) — the
|
||||||
|
* account the tab was built for, the debug session it records into — as the headers every request
|
||||||
/** Who this request is from — the tab's account and the debug session it records into — as the
|
* path sends (`req`, `beacon`, `uploadReq`): one definition, so a path cannot quietly go out
|
||||||
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
|
* without them. */
|
||||||
* cannot quietly go out without them. */
|
|
||||||
export function identityHeaders(): Record<string, string> {
|
export function identityHeaders(): Record<string, string> {
|
||||||
const active = getActiveAccount();
|
const headers: Record<string, string> = {};
|
||||||
|
const account = claimedAccount();
|
||||||
|
if (account) headers[EXPECTED_ACCOUNT_HEADER] = account;
|
||||||
const session = currentDebugSession();
|
const session = currentDebugSession();
|
||||||
return {
|
if (session) headers[DEBUG_SESSION_HEADER] = session;
|
||||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
return headers;
|
||||||
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
|
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
|
||||||
@@ -315,8 +315,6 @@ export function beacon(url: string, body: Record<string, unknown>, onSettled?: (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setActiveAccount = pinActiveAccount;
|
|
||||||
|
|
||||||
export async function req<T = unknown>(
|
export async function req<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
opts: RequestInit = {},
|
opts: RequestInit = {},
|
||||||
@@ -335,8 +333,8 @@ export async function req<T = unknown>(
|
|||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
|
// Who the tab is (only setup calls and raw uploads pass their own headers: setup is
|
||||||
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
|
// pre-auth, and an upload spreads `identityHeaders()` into its own).
|
||||||
...identityHeaders(),
|
...identityHeaders(),
|
||||||
},
|
},
|
||||||
...opts,
|
...opts,
|
||||||
@@ -398,7 +396,7 @@ export async function req<T = unknown>(
|
|||||||
|
|
||||||
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
|
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
|
||||||
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
|
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
|
||||||
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header,
|
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the identity headers,
|
||||||
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
|
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
|
||||||
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ vi.mock("../notifications", () => ({
|
|||||||
getActiveToasts: vi.fn((): { id: number }[] => []),
|
getActiveToasts: vi.fn((): { id: number }[] => []),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||||
vi.mock("../activeAccount", () => ({
|
|
||||||
activeAccountId: () => null,
|
|
||||||
ACTIVE_ACCOUNT_KEY: "k",
|
|
||||||
pinActiveAccount: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { debugSessionsApi } from "./diagnostics";
|
import { debugSessionsApi } from "./diagnostics";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
|
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
|
||||||
import type { LogEvent } from "../log";
|
import type { LogEvent } from "../log";
|
||||||
import type { ServerLogRow } from "./admin";
|
import type { ServerLogRow } from "./admin";
|
||||||
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
|
import { beacon, identityHeaders, req } from "./core";
|
||||||
|
|
||||||
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
|
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
|
||||||
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
|
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
|
||||||
@@ -123,12 +123,7 @@ export const debugSessionsApi = {
|
|||||||
req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }),
|
req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
|
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
|
||||||
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
|
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
|
||||||
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
|
/** Where an `<img>` loads a screenshot from. */
|
||||||
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
|
debugScreenshotUrl: (id: string, shotId: number): string =>
|
||||||
* per-tab account's screenshot resolves to the session default and 404s. */
|
`${SESSIONS}/${id}/screenshots/${shotId}`,
|
||||||
debugScreenshotUrl: (id: string, shotId: number): string => {
|
|
||||||
const a = getActiveAccount();
|
|
||||||
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
|
|
||||||
return a != null ? `${url}?account=${a}` : url;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
||||||
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
||||||
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
// reference each other, and its methods only touch the shared `req`/`beacon` from core.
|
||||||
import { beacon, req, getActiveAccount } from "./core";
|
import { beacon, req } from "./core";
|
||||||
|
|
||||||
export interface DownloadSpec {
|
export interface DownloadSpec {
|
||||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||||
@@ -314,22 +314,10 @@ export const downloadsApi = {
|
|||||||
},
|
},
|
||||||
onSettled,
|
onSettled,
|
||||||
),
|
),
|
||||||
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
|
// Filmstrip sprite (an <img>/background src).
|
||||||
storyboardImageUrl: (id: number): string => {
|
storyboardImageUrl: (id: number): string => `/api/downloads/${id}/storyboard.jpg`,
|
||||||
const a = getActiveAccount();
|
// The locally-generated poster (an <img> src).
|
||||||
return a != null
|
posterImageUrl: (id: number): string => `/api/downloads/${id}/poster.jpg`,
|
||||||
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
|
|
||||||
: `/api/downloads/${id}/storyboard.jpg`;
|
|
||||||
},
|
|
||||||
// The locally-generated poster (an <img> src → direct nav, so same ?account= wallet gate). Without
|
|
||||||
// it the poster resolves to the session-default account and 404s for a per-tab account's download —
|
|
||||||
// which reads as a blank Library thumbnail once the poster is the card's primary image.
|
|
||||||
posterImageUrl: (id: number): string => {
|
|
||||||
const a = getActiveAccount();
|
|
||||||
return a != null
|
|
||||||
? `/api/downloads/${id}/poster.jpg?account=${a}`
|
|
||||||
: `/api/downloads/${id}/poster.jpg`;
|
|
||||||
},
|
|
||||||
|
|
||||||
// admin
|
// admin
|
||||||
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// A tab names the account it was built for, and the server refuses a request from a tab showing
|
||||||
|
// another one than the cookie's. Spelled on both sides, so nothing but this test notices a rename:
|
||||||
|
// the server would see no header, and a tab left showing the previous account would act as the new
|
||||||
|
// one without a word.
|
||||||
|
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf-8");
|
||||||
|
const AUTH = read("../../../../backend/app/auth.py");
|
||||||
|
const ROUTES = read("../../../../backend/app/routes/auth.py");
|
||||||
|
const CORE = read("./core.ts");
|
||||||
|
const CONNECT = read("../googleConnect.ts");
|
||||||
|
const ENTRY = read("../entryUrl.ts");
|
||||||
|
|
||||||
|
describe("the account a tab was built for", () => {
|
||||||
|
it("is the header the server checks", () => {
|
||||||
|
const backend = /^EXPECTED_ACCOUNT_HEADER = "([^"]+)"$/m.exec(AUTH)?.[1];
|
||||||
|
const frontend = /^const EXPECTED_ACCOUNT_HEADER = "([^"]+)";$/m.exec(CORE)?.[1];
|
||||||
|
expect(backend).toBeTruthy();
|
||||||
|
expect(backend).toBe(frontend?.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rides a Google connect as the query parameter the route reads", () => {
|
||||||
|
// A navigation carries no headers; a rename on one side would leave the connect unchecked.
|
||||||
|
expect(CONNECT).toMatch(/params\.set\("expect", account\)/);
|
||||||
|
expect(ROUTES).toMatch(/^ {4}expect: str \| None = None,$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lands a refused navigation where the reloaded tab says why", () => {
|
||||||
|
const landing = /^ACCOUNT_CHANGED_LANDING = "\/\?([^"]+)"$/m.exec(ROUTES)?.[1];
|
||||||
|
const flag = /^export const ACCOUNT_CHANGED_FLAG = "([^"]+)";$/m.exec(ENTRY)?.[1];
|
||||||
|
expect(landing).toBeTruthy();
|
||||||
|
expect(landing).toBe(flag);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// The Files library: a user's done files, the folders they sit in, and the file itself
|
// The Files library: a user's done files, the folders they sit in, and the file itself
|
||||||
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
|
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
|
||||||
import { getActiveAccount, req, uploadReq } from "./core";
|
import { req, uploadReq } from "./core";
|
||||||
import type { DownloadJob } from "./downloads";
|
import type { DownloadJob } from "./downloads";
|
||||||
|
|
||||||
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
|
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
|
||||||
@@ -144,19 +144,10 @@ export const filesApi = {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
|
// The file itself, for a plain <a> navigation.
|
||||||
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
|
fileUrl: (id: number): string => `/api/files/${id}/file`,
|
||||||
// file resolves to the session-default account and 404s for a per-tab account's file.
|
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source.
|
||||||
fileUrl: (id: number): string => {
|
previewUrl: (id: number): string => `/api/files/${id}/preview`,
|
||||||
const a = getActiveAccount();
|
|
||||||
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
|
|
||||||
},
|
|
||||||
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
|
|
||||||
// carries the account the same way.
|
|
||||||
previewUrl: (id: number): string => {
|
|
||||||
const a = getActiveAccount();
|
|
||||||
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
|
|
||||||
},
|
|
||||||
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
|
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
|
||||||
previewText: async (id: number): Promise<FilePreviewText> => {
|
previewText: async (id: number): Promise<FilePreviewText> => {
|
||||||
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
|
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The notification and hints stores read the account's localStorage when they are imported, which is
|
||||||
|
// before BootGate knows the account, so they read as nobody's; they must read again on the claim. The
|
||||||
|
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
|
||||||
|
// account's stored history with it.
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const entry = (id: number, message: string) => ({
|
||||||
|
id,
|
||||||
|
level: "info",
|
||||||
|
message,
|
||||||
|
ts: 1,
|
||||||
|
read: true,
|
||||||
|
dismissed: true,
|
||||||
|
transient: false,
|
||||||
|
repeat: 1,
|
||||||
|
requiresInteraction: false,
|
||||||
|
});
|
||||||
|
const storedMessages = () =>
|
||||||
|
(
|
||||||
|
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
|
||||||
|
message: string;
|
||||||
|
}[]
|
||||||
|
).map((n) => n.message);
|
||||||
|
|
||||||
|
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
|
||||||
|
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
|
||||||
|
// vi.resetModules, so each case still gets fresh module instances, only faster.
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import("./notifications");
|
||||||
|
await import("./hints");
|
||||||
|
await import("./storage");
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
|
||||||
|
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
|
||||||
|
// workers share a busy machine.
|
||||||
|
const CASE_BUDGET = 30_000;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.resetModules(); // both stores load at import: each test needs a fresh one
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => {
|
||||||
|
it("keep the account's notification history and settings once it is claimed", async () => {
|
||||||
|
localStorage.setItem("siftlode.owner", "5");
|
||||||
|
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")]));
|
||||||
|
localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 }));
|
||||||
|
const notifications = await import("./notifications");
|
||||||
|
const { claimLocalData } = await import("./storage");
|
||||||
|
|
||||||
|
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe
|
||||||
|
claimLocalData(5);
|
||||||
|
notifications.notify({ message: "after the claim" });
|
||||||
|
|
||||||
|
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
|
||||||
|
expect(notifications.getNotifSettings().durationMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read the account's hints choice once it is claimed", async () => {
|
||||||
|
localStorage.setItem("siftlode.owner", "5");
|
||||||
|
localStorage.setItem("siftlode.hints", "0");
|
||||||
|
const hints = await import("./hints");
|
||||||
|
const { claimLocalData } = await import("./storage");
|
||||||
|
|
||||||
|
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
|
||||||
|
claimLocalData(5);
|
||||||
|
expect(hints.hintsEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => {
|
||||||
|
it("starts the new account's notices from an empty history", async () => {
|
||||||
|
localStorage.setItem("siftlode.owner", "4");
|
||||||
|
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")]));
|
||||||
|
const notifications = await import("./notifications");
|
||||||
|
const { claimLocalData } = await import("./storage");
|
||||||
|
|
||||||
|
claimLocalData(5);
|
||||||
|
notifications.notify({ message: "as five" });
|
||||||
|
|
||||||
|
expect(storedMessages()).toEqual(["as five"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LS } from "./storage";
|
||||||
import { createStore } from "./store";
|
import { createStore } from "./store";
|
||||||
|
|
||||||
// The debug recorder's app-wide state — what several surfaces open or read, held outside any
|
// The debug recorder's app-wide state — what several surfaces open or read, held outside any
|
||||||
@@ -47,7 +48,7 @@ export const useComposerSession = (): string | null => composing.useValue();
|
|||||||
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
|
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
|
||||||
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
|
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
|
||||||
// the list the issue is drafted from contradict itself.
|
// the list the issue is drafted from contradict itself.
|
||||||
const STEP_KEY = "siftlode.debugSteps";
|
const STEP_KEY = LS.debugSteps;
|
||||||
|
|
||||||
function readSteps(): Record<string, number> {
|
function readSteps(): Record<string, number> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
// Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a
|
// Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a
|
||||||
// Referer header (SB3); so does the Google access-request notice (`access`, and the addresses it
|
// Referer header (SB3); so does the Google access-request notice (`access`, and the addresses it
|
||||||
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
|
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
|
||||||
// with nothing personal in them (login, deleted) ride the query string.
|
// with nothing personal in them (login, deleted, account) ride the query string.
|
||||||
|
|
||||||
|
import type { NotifLevel } from "./notifications";
|
||||||
|
import { hrefHere } from "./urlState";
|
||||||
|
|
||||||
export const entrySearch = new URLSearchParams(window.location.search);
|
export const entrySearch = new URLSearchParams(window.location.search);
|
||||||
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||||
@@ -22,7 +25,31 @@ export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/,
|
|||||||
* in its old shape — it rides the fragment now). Deliberately NOT `link`,
|
* in its old shape — it rides the fragment now). Deliberately NOT `link`,
|
||||||
* `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup
|
* `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup
|
||||||
* wizard, the lazily-mounted module views) do not run until several commits later. */
|
* wizard, the lazily-mounted module views) do not run until several commits later. */
|
||||||
const ONE_TIME_QUERY = ["login", "deleted", "access"] as const;
|
const ONE_TIME_QUERY = ["login", "deleted", "access", "account"] as const;
|
||||||
|
|
||||||
|
/** The flag a tab reloads with when it was showing another account than the one signed in now (the
|
||||||
|
* server refused it: `account_changed`), and where a navigation the server refused for that lands
|
||||||
|
* (backend `ACCOUNT_CHANGED_LANDING`). A reload wipes any toast, so the reason rides the URL. */
|
||||||
|
export const ACCOUNT_CHANGED_FLAG = "account=changed";
|
||||||
|
|
||||||
|
/** This page again, carrying `ACCOUNT_CHANGED_FLAG`: the tab boots as the account signed in now and
|
||||||
|
* says why, on the page it was on. */
|
||||||
|
export function accountChangedUrl(): string {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const [key, value] = ACCOUNT_CHANGED_FLAG.split("=") as [string, string];
|
||||||
|
params.set(key, value);
|
||||||
|
return hrefHere(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload a tab whose session the server refused (a 401). Showing another account than the one
|
||||||
|
* signed in now (`account_changed`: a sign-in as someone else in another tab), it boots as that one
|
||||||
|
* and says why — a Sign out that silently left someone else signed in would read as broken. Any
|
||||||
|
* other refusal means the session ended: a plain reload lands on the sign-in page. */
|
||||||
|
export function reloadAfterRefusal(err: { detailData?: unknown }): void {
|
||||||
|
if ((err.detailData as { reason?: string } | undefined)?.reason === "account_changed")
|
||||||
|
window.location.replace(accountChangedUrl());
|
||||||
|
else window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
/** Strip the one-time entry state from the address bar, so a token doesn't linger in history or get
|
/** Strip the one-time entry state from the address bar, so a token doesn't linger in history or get
|
||||||
* re-sent on a reload. ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a
|
* re-sent on a reload. ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a
|
||||||
@@ -50,11 +77,41 @@ export function clearEntryUrl(): void {
|
|||||||
const keep = hash.has("reset") ? window.location.hash : "";
|
const keep = hash.has("reset") ? window.location.hash : "";
|
||||||
if (!dropped.length && !window.location.hash) return;
|
if (!dropped.length && !window.location.hash) return;
|
||||||
if (!dropped.length && keep) return;
|
if (!dropped.length && keep) return;
|
||||||
const qs = params.toString();
|
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
// Preserved, not nulled: it carries scroll restoration and the channel entry.
|
// Preserved, not nulled: it carries scroll restoration and the channel entry.
|
||||||
window.history.state,
|
window.history.state,
|
||||||
"",
|
"",
|
||||||
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`,
|
hrefHere(params, keep),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountNotice {
|
||||||
|
level: NotifLevel;
|
||||||
|
key: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the entry URL says to the account signed in here about a switch of account. A sign-in as
|
||||||
|
* someone else that did not happen: the callback answers a Google account that is not approved, or
|
||||||
|
* is suspended, without replacing the session, and with the flags the sign-in page reads — the
|
||||||
|
* signed-in app read none of them, so the person came back as before without a word. Its own copy,
|
||||||
|
* not the sign-in page's: beside this account's avatar, "this account is suspended" would read as
|
||||||
|
* this one. A request recorded on the way names the address it lives under (`linked`, when that is
|
||||||
|
* not the one Google returned). And a tab that reloaded because another account signed in under it
|
||||||
|
* says that it did. */
|
||||||
|
export function accountSwitchNotice(
|
||||||
|
search: URLSearchParams = entrySearch,
|
||||||
|
hash: URLSearchParams = entryHash,
|
||||||
|
): AccountNotice | null {
|
||||||
|
if (search.get("account") === "changed")
|
||||||
|
return { level: "info", key: "header.account.tabSwitched" };
|
||||||
|
if (search.get("login") === "suspended")
|
||||||
|
return { level: "warning", key: "header.account.switchSuspended" };
|
||||||
|
const access = hash.get("access");
|
||||||
|
const email = hash.get("linked") || hash.get("email");
|
||||||
|
if (access === "requested" && email)
|
||||||
|
return { level: "info", key: "header.account.switchRequested", values: { email } };
|
||||||
|
if (access === "requested" || access === "denied")
|
||||||
|
return { level: "warning", key: "header.account.switchNotApproved" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -412,6 +412,22 @@ describe("the gate register (docs/gates.md)", () => {
|
|||||||
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
|
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
|
// A spec left out of the webkit and ipad projects runs on one engine only.
|
||||||
|
it("lists every e2e spec that runs on one engine only", () => {
|
||||||
|
const cfg = read("frontend/playwright.config.ts");
|
||||||
|
const alternatives = /const CHROMIUM_ONLY = \/\(([^)]+)\)\\.spec\\.ts\//.exec(cfg)?.[1];
|
||||||
|
expect(alternatives, "CHROMIUM_ONLY").toBeTruthy();
|
||||||
|
const found = alternatives!
|
||||||
|
.split("|")
|
||||||
|
.map((name): [string, string] => ["e2e", `${name}.spec.ts`]);
|
||||||
|
expect(missingFrom(found)).toEqual([]);
|
||||||
|
// Any other way of leaving a spec out of a project would be a narrowing this does not read.
|
||||||
|
expect([...cfg.matchAll(/testIgnore:\s*([^,\n]+)/g)].map((m) => m[1])).toEqual([
|
||||||
|
"CHROMIUM_ONLY",
|
||||||
|
"CHROMIUM_ONLY",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => {
|
it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => {
|
||||||
const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
|
const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
|
||||||
expect(pkg.scripts.lint).toBe("eslint . --max-warnings 0");
|
expect(pkg.scripts.lint).toBe("eslint . --max-warnings 0");
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { GOOGLE_ACCESS, googleConnectUrl } from "./googleConnect";
|
||||||
|
|
||||||
|
// The frontend builds the Google connect URL by hand, so nothing but this test notices when the
|
||||||
|
// backend route moves or its `access` values change: the click would land on a 404 or a 422 page.
|
||||||
|
const AUTH = readFileSync(new URL("../../../backend/app/routes/auth.py", import.meta.url), "utf-8");
|
||||||
|
|
||||||
|
describe("the Google connect URL", () => {
|
||||||
|
it("names the backend route", () => {
|
||||||
|
const prefix = /APIRouter\(prefix="([^"]+)"/.exec(AUTH)?.[1];
|
||||||
|
const path = /@router\.get\("([^"]+)"\)\nasync def google_connect\(/.exec(AUTH)?.[1];
|
||||||
|
expect(googleConnectUrl("read").split("?")[0]).toBe(`${prefix}${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers exactly the access values the backend accepts", () => {
|
||||||
|
const literal = /access: Literal\[([^\]]+)\]/.exec(AUTH)?.[1] ?? "";
|
||||||
|
const accepted = [...literal.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
expect(accepted).toEqual([...GOOGLE_ACCESS]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Every in-app Google round-trip starts at one backend route (auth.py `google_connect`): attaching a
|
||||||
|
// Google sign-in to the account (`identity`), or granting YouTube access on top (`read`, `write`).
|
||||||
|
// The page navigates away to Google and comes back through the shared callback, which lands on
|
||||||
|
// `?link=…` (App.tsx) for `identity` and on the app itself for a grant. A navigation carries no
|
||||||
|
// headers, so it names the account the tab was built for in the query (`expect`) — the check every
|
||||||
|
// request gets (`identityHeaders`): a tab still showing the account signed in before would otherwise
|
||||||
|
// attach Google to the one signed in now.
|
||||||
|
|
||||||
|
import { claimedAccount } from "./storage";
|
||||||
|
|
||||||
|
export const GOOGLE_ACCESS = ["identity", "read", "write"] as const;
|
||||||
|
export type GoogleAccess = (typeof GOOGLE_ACCESS)[number];
|
||||||
|
|
||||||
|
export function googleConnectUrl(access: GoogleAccess): string {
|
||||||
|
const params = new URLSearchParams({ access });
|
||||||
|
const account = claimedAccount();
|
||||||
|
if (account) params.set("expect", account);
|
||||||
|
return `/auth/google/connect?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave for Google's sign-in or consent screen. */
|
||||||
|
export function connectGoogle(access: GoogleAccess): void {
|
||||||
|
window.location.href = googleConnectUrl(access);
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
||||||
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
||||||
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||||
import { onAccountPinned } from "./activeAccount";
|
|
||||||
import { createStore } from "./store";
|
import { createStore } from "./store";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "./storage";
|
import { getAccountRaw, LS, onLocalDataClaimed, setAccountRaw } from "./storage";
|
||||||
|
|
||||||
function load(): boolean {
|
function load(): boolean {
|
||||||
return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
|
return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = createStore<boolean>(load());
|
const store = createStore<boolean>(load());
|
||||||
// Read at import — in a new tab, before BootGate pins the account — so read again once it is pinned.
|
// Read at import — before BootGate knows the account — so read again once it is claimed.
|
||||||
onAccountPinned(() => store.set(load()));
|
onLocalDataClaimed(() => store.set(load()));
|
||||||
|
|
||||||
export const hintsEnabled = store.get;
|
export const hintsEnabled = store.get;
|
||||||
export const subscribeHints = store.subscribe;
|
export const subscribeHints = store.subscribe;
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A browser holds ONE account's local state: the keys carry no account id, and the browser records
|
||||||
|
// whose they are. These pin the boot-time claim — the move of the old per-account keys onto the plain
|
||||||
|
// ones, the wipe when another account starts here — and the guard that keeps a tab from reading or
|
||||||
|
// writing them before its account is known, or after another tab has claimed them for someone else.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.resetModules(); // the claim lives in module state: each case starts unclaimed
|
||||||
|
});
|
||||||
|
|
||||||
|
const storage = () => import("./storage");
|
||||||
|
const keys = (s: Storage) => Object.keys(s).sort();
|
||||||
|
|
||||||
|
describe("claiming the browser's local data", () => {
|
||||||
|
it("moves the account's old per-account copies onto the plain keys and drops other accounts' copies", async () => {
|
||||||
|
localStorage.setItem("siftlode.filters.1", '{"q":"mine"}');
|
||||||
|
localStorage.setItem("siftlode.filters.7", '{"q":"theirs"}');
|
||||||
|
localStorage.setItem("siftlode.onboarding.dismissed.1", "1");
|
||||||
|
localStorage.setItem("siftlode.notifications.7", "[]");
|
||||||
|
sessionStorage.setItem("siftlode.settingsTab.1", "account");
|
||||||
|
sessionStorage.setItem("siftlode.settingsTab.7", "plex");
|
||||||
|
const { claimLocalData, LS } = await storage();
|
||||||
|
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
expect(localStorage.getItem(LS.filters)).toBe('{"q":"mine"}');
|
||||||
|
expect(localStorage.getItem(LS.onboardingDismissed)).toBe("1");
|
||||||
|
expect(sessionStorage.getItem(LS.settingsTab)).toBe("account");
|
||||||
|
expect(keys(localStorage)).toEqual(
|
||||||
|
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(),
|
||||||
|
);
|
||||||
|
expect(keys(sessionStorage)).toEqual(["siftlode.owner", "siftlode.settingsTab"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an old per-account copy wins over a plain key already there", async () => {
|
||||||
|
localStorage.setItem("siftlode.hints", "1");
|
||||||
|
localStorage.setItem("siftlode.hints.1", "0");
|
||||||
|
const { claimLocalData } = await storage();
|
||||||
|
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
expect(localStorage.getItem("siftlode.hints")).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a key that only looks like an account copy alone", async () => {
|
||||||
|
localStorage.setItem("siftlode.somethingElse.3", "x");
|
||||||
|
const { claimLocalData } = await storage();
|
||||||
|
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
expect(localStorage.getItem("siftlode.somethingElse.3")).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the per-tab account pin the multi-account build kept", async () => {
|
||||||
|
sessionStorage.setItem("siftlode.activeAccount", "1");
|
||||||
|
sessionStorage.setItem("siftlode.nextAccount", "7");
|
||||||
|
const { claimLocalData } = await storage();
|
||||||
|
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem("siftlode.activeAccount")).toBeNull();
|
||||||
|
expect(sessionStorage.getItem("siftlode.nextAccount")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("another account starting here wipes the previous one's state, but not the browser's own", async () => {
|
||||||
|
localStorage.setItem("siftlode.owner", "7");
|
||||||
|
localStorage.setItem("siftlode.filters", '{"q":"theirs"}');
|
||||||
|
localStorage.setItem("siftlode.theme", "dark");
|
||||||
|
localStorage.setItem("siftlode.watchResume", '{"tok":{"at":5,"t":1}}');
|
||||||
|
localStorage.setItem("siftlode.watchRepeat", "false");
|
||||||
|
localStorage.setItem("unrelated.key", "kept");
|
||||||
|
sessionStorage.setItem("siftlode.settingsTab", "plex");
|
||||||
|
const { claimLocalData } = await storage();
|
||||||
|
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
expect(keys(localStorage)).toEqual(
|
||||||
|
["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
|
||||||
|
);
|
||||||
|
expect(localStorage.getItem("siftlode.owner")).toBe("1");
|
||||||
|
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a tab reloading for the account another tab claimed the browser for drops its own view state", async () => {
|
||||||
|
// Tab 2 signed in as 7 and claimed the browser; this tab still holds 1's sub-tab and the
|
||||||
|
// onboarding flow 1 was in. Its sessionStorage is its own, so the browser's owner says nothing
|
||||||
|
// about it.
|
||||||
|
localStorage.setItem("siftlode.owner", "7");
|
||||||
|
sessionStorage.setItem("siftlode.owner", "1");
|
||||||
|
sessionStorage.setItem("siftlode.settingsTab", "plex");
|
||||||
|
sessionStorage.setItem("siftlode.onboarding.active", "1");
|
||||||
|
const { claimLocalData } = await storage();
|
||||||
|
|
||||||
|
claimLocalData(7);
|
||||||
|
|
||||||
|
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
|
||||||
|
expect(sessionStorage.getItem("siftlode.owner")).toBe("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the same account starting again keeps everything", async () => {
|
||||||
|
localStorage.setItem("siftlode.owner", "1");
|
||||||
|
localStorage.setItem("siftlode.filters", '{"q":"mine"}');
|
||||||
|
sessionStorage.setItem("siftlode.settingsTab", "account");
|
||||||
|
const { claimLocalData } = await storage();
|
||||||
|
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
expect(localStorage.getItem("siftlode.filters")).toBe('{"q":"mine"}');
|
||||||
|
expect(sessionStorage.getItem("siftlode.settingsTab")).toBe("account");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the stores read at import to read again", async () => {
|
||||||
|
const { claimLocalData, onLocalDataClaimed } = await storage();
|
||||||
|
const reread = vi.fn();
|
||||||
|
onLocalDataClaimed(reread);
|
||||||
|
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
expect(reread).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the account's local state", () => {
|
||||||
|
it("is neither read nor written before the account is known", async () => {
|
||||||
|
localStorage.setItem("siftlode.hints", "0");
|
||||||
|
const { getAccountRaw, setAccountRaw, readAccount, writeAccount, ownKey } = await storage();
|
||||||
|
|
||||||
|
expect(getAccountRaw("siftlode.hints")).toBeNull();
|
||||||
|
expect(readAccount("siftlode.filters", "fallback")).toBe("fallback");
|
||||||
|
expect(ownKey("siftlode.filters")).toBeNull();
|
||||||
|
setAccountRaw("siftlode.page", "plex");
|
||||||
|
writeAccount("siftlode.filters", { q: "x" });
|
||||||
|
|
||||||
|
expect(localStorage.getItem("siftlode.page")).toBeNull();
|
||||||
|
expect(localStorage.getItem("siftlode.filters")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is read and written under the plain key once claimed", async () => {
|
||||||
|
const { claimLocalData, getAccountRaw, setAccountRaw, ownKey } = await storage();
|
||||||
|
claimLocalData(1);
|
||||||
|
|
||||||
|
setAccountRaw("siftlode.page", "plex");
|
||||||
|
|
||||||
|
expect(localStorage.getItem("siftlode.page")).toBe("plex");
|
||||||
|
expect(getAccountRaw("siftlode.page")).toBe("plex");
|
||||||
|
expect(ownKey("siftlode.filters")).toBe("siftlode.filters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops being touched once another tab has claimed the browser for someone else", async () => {
|
||||||
|
const { claimLocalData, getAccountRaw, setAccountRaw } = await storage();
|
||||||
|
claimLocalData(1);
|
||||||
|
setAccountRaw("siftlode.page", "plex");
|
||||||
|
|
||||||
|
// Another tab signed in as account 6: its boot wiped the state and recorded the new owner.
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("siftlode.owner", "6");
|
||||||
|
localStorage.setItem("siftlode.hints", "0"); // account 6's
|
||||||
|
setAccountRaw("siftlode.page", "feed");
|
||||||
|
|
||||||
|
expect(localStorage.getItem("siftlode.page")).toBeNull();
|
||||||
|
expect(getAccountRaw("siftlode.hints")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,8 +15,11 @@
|
|||||||
// addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the
|
// addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the
|
||||||
// call site asking `enabled(level)` before it builds the attribute.
|
// call site asking `enabled(level)` before it builds the attribute.
|
||||||
//
|
//
|
||||||
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
|
// Nearly dependency-free on purpose (the build stamp and the storage key registry, which import
|
||||||
// the error boundary all log through here, so it must not import any of them.
|
// nothing of the app at runtime): the request layer, the notification store and the error boundary
|
||||||
|
// all log through here, so it must not import any of them, not even through another module
|
||||||
|
// (logImports.contract.test.ts follows the runtime imports).
|
||||||
|
import { LS } from "./storage";
|
||||||
import { FRONTEND_VERSION } from "./version";
|
import { FRONTEND_VERSION } from "./version";
|
||||||
|
|
||||||
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
||||||
@@ -125,7 +128,7 @@ export function newRequestId(): string {
|
|||||||
|
|
||||||
// Per TAB, not per page load: a reload loop is one tab's story, and sessionStorage is exactly that
|
// Per TAB, not per page load: a reload loop is one tab's story, and sessionStorage is exactly that
|
||||||
// scope. Storage that throws (a locked-down browser) just means a fresh id per load.
|
// scope. Storage that throws (a locked-down browser) just means a fresh id per load.
|
||||||
const TAB_KEY = "siftlode.logTab";
|
const TAB_KEY = LS.logTab;
|
||||||
function tabId(): string {
|
function tabId(): string {
|
||||||
try {
|
try {
|
||||||
const known = sessionStorage.getItem(TAB_KEY);
|
const known = sessionStorage.getItem(TAB_KEY);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// The request layer, the notification store and the error boundary all log through lib/log.ts, so
|
||||||
|
// log.ts must not reach any of them at runtime: the import would be a cycle, and the module that
|
||||||
|
// logs a failure would load the one that failed. A type-only import is erased, so it does not count.
|
||||||
|
// The comment in log.ts said this for a long time while nothing checked it.
|
||||||
|
|
||||||
|
const LIB = new URL("./", import.meta.url);
|
||||||
|
const ALLOWED = ["log.ts", "storage.ts", "version.ts"];
|
||||||
|
|
||||||
|
/** The app modules `file` imports at runtime (relative, not `import type`), as lib/ file names. */
|
||||||
|
function runtimeImports(file: string): string[] {
|
||||||
|
const text = readFileSync(new URL(file, LIB), "utf-8");
|
||||||
|
return [...text.matchAll(/^import\s+(?!type\b)[^;]*?from\s+"(\.[^"]+)";/gms)].map(
|
||||||
|
(m) => `${m[1]!.replace(/^\.\//, "")}.ts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lib/log.ts", () => {
|
||||||
|
it("reaches only the build stamp and the key registry at runtime", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const queue = ["log.ts"];
|
||||||
|
while (queue.length) {
|
||||||
|
const file = queue.pop()!;
|
||||||
|
if (seen.has(file)) continue;
|
||||||
|
seen.add(file);
|
||||||
|
if (ALLOWED.includes(file)) queue.push(...runtimeImports(file));
|
||||||
|
}
|
||||||
|
expect([...seen].sort()).toEqual([...ALLOWED].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -183,3 +183,11 @@ export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map
|
|||||||
export function moduleOrder(me: Me): Page[] {
|
export function moduleOrder(me: Me): Page[] {
|
||||||
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
|
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The page this account is shown for `page`: itself when its gate lets the account in, else the
|
||||||
|
* feed (a link to an admin page, a Back onto one, an admin role taken away mid-session). Signed
|
||||||
|
* out, no page renders, so nothing is moved. */
|
||||||
|
export function reachablePage(page: Page, me: Me | null | undefined): Page {
|
||||||
|
const gate = moduleDef(page).gate;
|
||||||
|
return !me || !gate || gate(me) ? page : "feed";
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||||
// errors). History is persisted to localStorage so it survives reloads; action
|
// errors). History is persisted to localStorage so it survives reloads; action
|
||||||
// callbacks are live-only and dropped on reload.
|
// callbacks are live-only and dropped on reload.
|
||||||
import { activeAccountId, onAccountPinned } from "./activeAccount";
|
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
|
||||||
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
|
|
||||||
import { log, type Level } from "./log";
|
import { log, type Level } from "./log";
|
||||||
|
|
||||||
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
|
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
|
||||||
@@ -226,18 +225,16 @@ function emit() {
|
|||||||
|
|
||||||
recompute();
|
recompute();
|
||||||
|
|
||||||
// The history and settings above are read at import, which in a new tab is before BootGate pins its
|
// The history and settings above are read at import, before BootGate knows the account — so they
|
||||||
// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
|
// read as nobody's. Read them again once the account is claimed (once per page: a different account
|
||||||
// tab's first write would replace the account's stored history with its own empty one. A notice raised
|
// reloads it): otherwise the first write would replace the account's stored history with an empty one.
|
||||||
// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
|
// A notice raised before the claim (a failed boot probe) belongs to no account, so it stays, after the
|
||||||
let loadedFor = activeAccountId();
|
// history.
|
||||||
onAccountPinned(() => {
|
onLocalDataClaimed(() => {
|
||||||
const unpinned = loadedFor == null ? items : [];
|
|
||||||
loadedFor = activeAccountId();
|
|
||||||
config = loadSettings();
|
config = loadSettings();
|
||||||
// Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
|
// Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
|
||||||
const history = load().map((n) => ({ ...n, id: counter++ }));
|
const history = load().map((n) => ({ ...n, id: counter++ }));
|
||||||
items = [...history, ...unpinned].slice(-MAX_HISTORY);
|
items = [...history, ...items].slice(-MAX_HISTORY);
|
||||||
recompute();
|
recompute();
|
||||||
listeners.forEach((l) => l());
|
listeners.forEach((l) => l());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,37 +8,41 @@
|
|||||||
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
|
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
|
||||||
// "Connect"); makes the wizard reopen after the
|
// "Connect"); makes the wizard reopen after the
|
||||||
// redirect so they land on the next step.
|
// redirect so they land on the next step.
|
||||||
// ONBOARD_DISMISSED (localStorage) — set when the user skips the essential read step;
|
// ONBOARDING_DISMISSED_PREF (an — set when the user skips the essential read step;
|
||||||
// suppresses the auto-popup on future logins (they
|
// account preference, usePref) suppresses the auto-popup on future logins, on every
|
||||||
// can still reopen it from Settings → Account).
|
// device (they can still reopen it from Settings → Account).
|
||||||
|
|
||||||
import { withActiveAccount } from "./activeAccount";
|
import { connectGoogle } from "./googleConnect";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "./storage";
|
import { LS } from "./storage";
|
||||||
|
import type { UsePrefOptions } from "./usePref";
|
||||||
|
|
||||||
const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
|
const ONBOARD_ACTIVE = LS.onboardingActive; // sessionStorage, this tab only
|
||||||
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
|
// The account's, so a fresh account still gets the nudge where another one dismissed it. A copy this
|
||||||
// different account in the same browser dismissed it.
|
// browser kept before the server held it is the string "1".
|
||||||
const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
export const ONBOARDING_DISMISSED_PREF = "onboardingDismissed";
|
||||||
|
export const ONBOARDING_DISMISSED_OPTIONS: UsePrefOptions<boolean> = {
|
||||||
|
localKey: LS.onboardingDismissed,
|
||||||
|
parse: (raw) => raw === true || raw === "1" || raw === 1,
|
||||||
|
};
|
||||||
|
|
||||||
export function shouldAutoOpenOnboarding(me: {
|
export function shouldAutoOpenOnboarding(
|
||||||
can_read: boolean;
|
me: { can_read: boolean; google_enabled?: boolean },
|
||||||
google_enabled?: boolean;
|
dismissed: boolean,
|
||||||
}): boolean {
|
): boolean {
|
||||||
// YouTube connect needs Google OAuth configured on this instance. A self-host instance may run
|
// YouTube connect needs Google OAuth configured on this instance. A self-host instance may run
|
||||||
// email+password only — then there's nothing to connect, so don't nudge.
|
// email+password only — then there's nothing to connect, so don't nudge.
|
||||||
if (me.google_enabled === false) return false;
|
if (me.google_enabled === false) return false;
|
||||||
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||||
return !me.can_read && !getAccountRaw(ONBOARD_DISMISSED);
|
return !me.can_read && !dismissed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a grant as in progress, then send the user to Google's consent screen. */
|
/** Mark a grant as in progress, then send the user to Google's consent screen. */
|
||||||
export function beginGrant(access: "read" | "write"): void {
|
export function beginGrant(access: "read" | "write"): void {
|
||||||
sessionStorage.setItem(ONBOARD_ACTIVE, "1");
|
sessionStorage.setItem(ONBOARD_ACTIVE, "1");
|
||||||
window.location.href = withActiveAccount(`/auth/upgrade?access=${access}`);
|
connectGoogle(access);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
|
/** Leave the wizard's flow (its dismissal, if any, is the caller's preference to set). */
|
||||||
export function endOnboarding(dismiss: boolean): void {
|
export function endOnboarding(): void {
|
||||||
sessionStorage.removeItem(ONBOARD_ACTIVE);
|
sessionStorage.removeItem(ONBOARD_ACTIVE);
|
||||||
if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,14 @@
|
|||||||
// never show a layout changed on another device.
|
// never show a layout changed on another device.
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { seedLayout } from "./panelLayout";
|
import { seedLayout } from "./panelLayout";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
|
import { claimLocalData, LS } from "./storage";
|
||||||
import { accountKey, LS } from "./storage";
|
|
||||||
|
|
||||||
const local = { order: [], collapsed: { genre: true }, hidden: {} };
|
const local = { order: [], collapsed: { genre: true }, hidden: {} };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
claimLocalData(5);
|
||||||
localStorage.setItem(accountKey(LS.plexLayout, 5)!, JSON.stringify(local));
|
localStorage.setItem(LS.plexLayout, JSON.stringify(local));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("seedLayout", () => {
|
describe("seedLayout", () => {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const qk = {
|
|||||||
savedViews: (...rest: unknown[]) => ["saved-views", ...rest],
|
savedViews: (...rest: unknown[]) => ["saved-views", ...rest],
|
||||||
|
|
||||||
// --- account / misc --- (me/myStatus/tags already declared as shared roots above)
|
// --- account / misc --- (me/myStatus/tags already declared as shared roots above)
|
||||||
accounts: () => ["accounts"] as const,
|
|
||||||
version: () => ["version"] as const,
|
version: () => ["version"] as const,
|
||||||
// Static text bundled with the app (lib/releaseNotes), one file per language: never stale.
|
// Static text bundled with the app (lib/releaseNotes), one file per language: never stale.
|
||||||
releaseNotes: (lang: string) => ["release-notes", lang] as const,
|
releaseNotes: (lang: string) => ["release-notes", lang] as const,
|
||||||
|
|||||||
@@ -49,4 +49,28 @@ describe("removed features stay removed", () => {
|
|||||||
);
|
);
|
||||||
expect(hits).toEqual([]);
|
expect(hits).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One account per browser session (2026-10-20): the per-tab account pin, the header and `?account=`
|
||||||
|
// that carried it, and the account list and switch went. A request naming an account would now
|
||||||
|
// be ignored by the server, so a pin left behind reads as working while it does nothing. Only the
|
||||||
|
// claim that clears the old pin keys (storage.ts) and its test may name them. (The tab's
|
||||||
|
// X-Siftlode-Expect-Account is not a pin: it never picks an account, the server refuses a request
|
||||||
|
// from a tab showing another one than the cookie's.)
|
||||||
|
it("leaves no per-tab account pin, header or switch", { timeout: 30_000 }, () => {
|
||||||
|
const PIN =
|
||||||
|
/X-Siftlode-Account|activeAccount|nextAccount|[?&]account=|switchAccount|\/api\/me\/(accounts|switch)/i;
|
||||||
|
const allowed = new Set([
|
||||||
|
"frontend/src/lib/storage.ts",
|
||||||
|
"frontend/src/lib/localData.dom.test.ts",
|
||||||
|
]);
|
||||||
|
const hits = ["frontend/src", "frontend/e2e"]
|
||||||
|
.flatMap(files)
|
||||||
|
.filter((f) => !allowed.has(f) && f !== "frontend/src/lib/removedFeatures.contract.test.ts")
|
||||||
|
.flatMap((f) =>
|
||||||
|
readFileSync(new URL(f, REPO), "utf-8")
|
||||||
|
.split("\n")
|
||||||
|
.flatMap((line, i) => (PIN.test(line) ? [`${f}:${i + 1}: ${line.trim()}`] : [])),
|
||||||
|
);
|
||||||
|
expect(hits).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user