Merge: promote dev to prod
This commit is contained in:
commit
9c6acd0b16
6 changed files with 127 additions and 64 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.39.0
|
0.39.1
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ import httpx
|
||||||
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
|
||||||
|
from starlette.requests import HTTPConnection
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.security import decrypt, encrypt, hash_password, hash_token, verify_password
|
from app.security import decrypt, encrypt, hash_password, hash_token, verify_password
|
||||||
|
|
@ -72,6 +73,8 @@ READ_SCOPE = f"{WRITE_SCOPE}.readonly"
|
||||||
BASE_SCOPES = "openid email profile"
|
BASE_SCOPES = "openid email profile"
|
||||||
READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
|
READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
|
||||||
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
|
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
|
||||||
|
# The YouTube grants (read ⊂ write). A sign-in must never silently drop one of these — see _store_token.
|
||||||
|
_YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE})
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.auth")
|
log = logging.getLogger("siftlode.auth")
|
||||||
|
|
||||||
|
|
@ -131,6 +134,20 @@ def has_write_scope(user: User) -> bool:
|
||||||
return WRITE_SCOPE in tok.scopes.split()
|
return WRITE_SCOPE in tok.scopes.split()
|
||||||
|
|
||||||
|
|
||||||
|
def admin_notify_emails(db: Session) -> list[str]:
|
||||||
|
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
|
||||||
|
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
|
||||||
|
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
|
||||||
|
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
|
||||||
|
keeps a freshly-seeded instance working before any DB admin exists."""
|
||||||
|
db_admins = db.execute(
|
||||||
|
select(User.email).where(
|
||||||
|
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
|
||||||
|
|
||||||
|
|
||||||
def is_allowed(db: Session, email: str) -> bool:
|
def is_allowed(db: Session, email: str) -> bool:
|
||||||
"""May this email sign in? An approved Invite is the source of truth; the env
|
"""May this email sign in? An approved Invite is the source of truth; the env
|
||||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
|
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
|
||||||
|
|
@ -281,12 +298,9 @@ async def callback(
|
||||||
# land the user on a friendly "request received" screen instead of a raw 403.
|
# land the user on a friendly "request received" screen instead of a raw 403.
|
||||||
if email:
|
if email:
|
||||||
inv = upsert_pending_invite(db, email)
|
inv = upsert_pending_invite(db, email)
|
||||||
if inv is not None and settings.admin_email_set:
|
admins = admin_notify_emails(db)
|
||||||
background.add_task(
|
if inv is not None and admins:
|
||||||
email_mod.send_admin_new_request,
|
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||||
sorted(settings.admin_email_set),
|
|
||||||
email,
|
|
||||||
)
|
|
||||||
return RedirectResponse(url="/?access=requested")
|
return RedirectResponse(url="/?access=requested")
|
||||||
return RedirectResponse(url="/?access=denied")
|
return RedirectResponse(url="/?access=denied")
|
||||||
|
|
||||||
|
|
@ -375,6 +389,18 @@ def _store_token(db: Session, user: User, token: dict) -> None:
|
||||||
include_granted_scopes=true means `scope` is the union of everything granted, so this
|
include_granted_scopes=true means `scope` is the union of everything granted, so this
|
||||||
reflects read/write upgrades correctly."""
|
reflects read/write upgrades correctly."""
|
||||||
tok = user.token or OAuthToken(user=user)
|
tok = user.token or OAuthToken(user=user)
|
||||||
|
old = set((tok.scopes or "").split())
|
||||||
|
new = set((token.get("scope") or "").split())
|
||||||
|
# A plain re-sign-in requests only BASE_SCOPES, and Google can hand back a FRESH base-only
|
||||||
|
# access+refresh token. Adopting it would DESTROY a previously-granted YouTube read/write grant —
|
||||||
|
# can_read/can_write flip to false and the feed demands a reconnect (the exact bug users hit on a
|
||||||
|
# logout→login). The OLD refresh token stays valid on Google's side, so when this exchange would
|
||||||
|
# NARROW our YouTube scopes, keep the WHOLE existing grant (refresh token, access token, scopes)
|
||||||
|
# untouched. The grant only shrinks on a full disconnect (purge_user deletes the token row); an
|
||||||
|
# externally-revoked scope surfaces later as a YouTube API 403 → reconnect, not through this write.
|
||||||
|
if (old & _YOUTUBE_SCOPES) - new:
|
||||||
|
db.add(tok)
|
||||||
|
return
|
||||||
if token.get("refresh_token"):
|
if token.get("refresh_token"):
|
||||||
tok.refresh_token_enc = encrypt(token["refresh_token"])
|
tok.refresh_token_enc = encrypt(token["refresh_token"])
|
||||||
# Encrypt the access token at rest too (it's a live ~1h bearer credential), matching the
|
# Encrypt the access token at rest too (it's a live ~1h bearer credential), matching the
|
||||||
|
|
@ -382,7 +408,8 @@ def _store_token(db: Session, user: User, token: dict) -> None:
|
||||||
tok.access_token = encrypt(token.get("access_token"))
|
tok.access_token = encrypt(token.get("access_token"))
|
||||||
expires_at = token.get("expires_at")
|
expires_at = token.get("expires_at")
|
||||||
tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
||||||
tok.scopes = token.get("scope") or BASE_SCOPES
|
# Union (never narrow): a broader-or-equal exchange may still list only the newly-added scope.
|
||||||
|
tok.scopes = " ".join(sorted(old | new)) or BASE_SCOPES
|
||||||
db.add(tok)
|
db.add(tok)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -413,7 +440,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||||
email = user.email.lower()
|
email = user.email.lower()
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
tok = user.token
|
tok = user.token
|
||||||
google_token = (decrypt(tok.refresh_token_enc) or tok.access_token) if tok else None
|
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
|
||||||
|
# ciphertext, so a token row with no refresh token silently failed to revoke).
|
||||||
|
google_token = (decrypt(tok.refresh_token_enc) or decrypt(tok.access_token)) if tok else None
|
||||||
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
||||||
db.execute(delete(User).where(User.id == user_id))
|
db.execute(delete(User).where(User.id == user_id))
|
||||||
db.execute(delete(Invite).where(Invite.email == email))
|
db.execute(delete(Invite).where(Invite.email == email))
|
||||||
|
|
@ -494,10 +523,9 @@ async def request_access(
|
||||||
if is_allowed(db, email):
|
if is_allowed(db, email):
|
||||||
return {"status": "approved"} # already allowed — just sign in
|
return {"status": "approved"} # already allowed — just sign in
|
||||||
inv = upsert_pending_invite(db, email)
|
inv = upsert_pending_invite(db, email)
|
||||||
if inv is not None and settings.admin_email_set:
|
admins = admin_notify_emails(db)
|
||||||
background.add_task(
|
if inv is not None and admins:
|
||||||
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
|
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||||
)
|
|
||||||
return {"status": "pending"}
|
return {"status": "pending"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -596,11 +624,24 @@ def register(
|
||||||
if not _register_limiter.allow(_client_ip(request)):
|
if not _register_limiter.allow(_client_ip(request)):
|
||||||
return {"status": "ok"} # silently throttle; uniform response
|
return {"status": "ok"} # silently throttle; uniform response
|
||||||
|
|
||||||
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
# Do the existence check + account creation OFF the response path (a background task with its own
|
||||||
if existing is None:
|
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||||
# Without working SMTP we can't deliver a verification link, so email ownership can't
|
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||||
# gate sign-in. Admin approval stays the real gate (is_active=False); mark the account
|
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||||
# verified so the flow still completes on a no-SMTP self-host. See email.email_enabled().
|
background.add_task(_register_account, email, password)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_account(email: str, password: str) -> None:
|
||||||
|
"""Background worker for /register: create the pending account (+ verification email + admin
|
||||||
|
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
|
||||||
|
with its own DB session, so the request's timing never reveals whether the email already exists."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None:
|
||||||
|
return # already registered — nothing to do (the uniform "ok" was already returned)
|
||||||
|
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
|
||||||
|
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
|
||||||
|
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
|
||||||
email_ok = email_mod.email_enabled()
|
email_ok = email_mod.email_enabled()
|
||||||
user = User(
|
user = User(
|
||||||
email=email,
|
email=email,
|
||||||
|
|
@ -610,20 +651,14 @@ def register(
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.flush()
|
db.flush()
|
||||||
upsert_pending_invite(db, email) # admin-approval gate
|
upsert_pending_invite(db, email) # admin-approval gate (commits)
|
||||||
if email_ok:
|
if email_ok:
|
||||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||||
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer.
|
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
|
||||||
# The SPA reads the fragment and POSTs it to /auth/verify (SB3).
|
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||||
background.add_task(
|
admins = admin_notify_emails(db)
|
||||||
email_mod.send_verify_email, email, f"{_app_base()}/#verify={raw}"
|
if admins:
|
||||||
)
|
email_mod.send_admin_new_request(admins, email)
|
||||||
if settings.admin_email_set:
|
|
||||||
background.add_task(
|
|
||||||
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
|
|
||||||
)
|
|
||||||
# Existing email → do nothing visible (no enumeration). Uniform success either way.
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify")
|
@router.post("/verify")
|
||||||
|
|
@ -692,21 +727,30 @@ def password_reset_request(
|
||||||
payload: dict,
|
payload: dict,
|
||||||
request: Request,
|
request: Request,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Request a password-reset link. Uniform response regardless of whether the email has a
|
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
||||||
password account, so it can't probe for registered emails."""
|
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||||
if not _reset_limiter.allow(_client_ip(request)):
|
if not _reset_limiter.allow(_client_ip(request)):
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
|
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
||||||
|
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
||||||
|
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
|
background.add_task(_send_reset_if_eligible, email)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def _send_reset_if_eligible(email: str) -> None:
|
||||||
|
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
|
||||||
|
for a real (non-demo) password account. Runs after the response with its own DB session, so the
|
||||||
|
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
|
||||||
|
can't leak into proxy/access logs or a Referer (SB3)."""
|
||||||
|
with SessionLocal() as db:
|
||||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||||
if user is not None and user.password_hash and not user.is_demo:
|
if user is not None and user.password_hash and not user.is_demo:
|
||||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||||
# Token in the URL FRAGMENT (#), not the query (?): a fragment is never sent to the
|
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
|
||||||
# server, so it can't leak into proxy/access logs or a Referer header (SB3).
|
|
||||||
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/#reset={raw}")
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password-reset/confirm")
|
@router.post("/password-reset/confirm")
|
||||||
|
|
@ -741,21 +785,22 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
|
||||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||||
|
|
||||||
|
|
||||||
def resolved_user_id(request: Request) -> tuple[int | None, bool]:
|
def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]:
|
||||||
"""Which account this request acts as, and whether that's the session's default account.
|
"""Which account this connection acts as, and whether that's the session's default account.
|
||||||
|
Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this
|
||||||
|
exact per-tab resolution instead of re-implementing it.
|
||||||
|
|
||||||
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
||||||
plus a default `user_id`. A request may override the default with the X-Siftlode-Account
|
plus a default `user_id`. A caller may override the default with the X-Siftlode-Account header,
|
||||||
header, but ONLY for an account already in the wallet — so a tab can't impersonate an account
|
but ONLY for an account already in the wallet — so a tab can't impersonate an account that never
|
||||||
that never authenticated here. Everything else (WebSocket, plain navigations) keeps using the
|
authenticated here. Everything else (WebSocket, plain navigations) keeps using the cookie default.
|
||||||
cookie default.
|
|
||||||
"""
|
"""
|
||||||
default_id = request.session.get("user_id")
|
default_id = conn.session.get("user_id")
|
||||||
wallet = request.session.get("account_ids") or []
|
wallet = conn.session.get("account_ids") or []
|
||||||
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a
|
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a
|
||||||
# plain <a> file download). Both are wallet-gated below, so neither can impersonate an
|
# plain <a> file download). Both are wallet-gated below, so neither can impersonate an
|
||||||
# account that never signed into this browser.
|
# account that never signed into this browser.
|
||||||
hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER) or request.query_params.get("account")
|
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
||||||
if hdr:
|
if hdr:
|
||||||
try:
|
try:
|
||||||
hid = int(hdr)
|
hid = int(hdr)
|
||||||
|
|
|
||||||
|
|
@ -142,10 +142,17 @@ def send_account_suspended(to: str, operator: str | None) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||||
|
# The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
||||||
|
# requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
||||||
|
# reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
||||||
|
# sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
||||||
|
approve_url = f"{settings.app_base}/?admin=access-requests"
|
||||||
body = (
|
body = (
|
||||||
f"{requester} just requested access to Siftlode.\n\n"
|
f"{requester} just requested access to Siftlode.\n\n"
|
||||||
"Open Siftlode → Settings → Account → Access requests to approve or deny it.\n\n"
|
f"Approve or deny it here:\n{approve_url}\n"
|
||||||
"(Reply to this email to reach the requester directly.)\n"
|
f"(or in Siftlode: open Users → Access requests)\n\n"
|
||||||
|
f"Requester's email: {requester}\n"
|
||||||
|
f"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
||||||
)
|
)
|
||||||
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_human
|
from app.auth import require_human, resolved_user_id
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import Message, MessageKey, User
|
from app.models import Message, MessageKey, User
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
|
|
@ -363,19 +363,10 @@ async def messages_ws(ws: WebSocket) -> None:
|
||||||
"""Live push channel: authenticated by the session cookie, registers the connection so
|
"""Live push channel: authenticated by the session cookie, registers the connection so
|
||||||
sent messages reach this user's open tabs instantly. We don't consume client→server frames
|
sent messages reach this user's open tabs instantly. We don't consume client→server frames
|
||||||
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
|
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
|
||||||
# A browser WebSocket can't send custom headers, so the per-tab account (see
|
# Which signed-in account this socket acts as — the SHARED per-tab resolution (wallet-gated
|
||||||
# X-Siftlode-Account on HTTP) rides in the ?account= query param instead — honoured only for
|
# ?account= override, session default otherwise). A browser WebSocket can't send the
|
||||||
# an account already in this browser's wallet. Falls back to the session default.
|
# X-Siftlode-Account header, so resolved_user_id falls through to the ?account= query param.
|
||||||
sess = ws.session if "session" in ws.scope else {}
|
uid, _ = resolved_user_id(ws)
|
||||||
uid = sess.get("user_id")
|
|
||||||
q = ws.query_params.get("account")
|
|
||||||
if q:
|
|
||||||
try:
|
|
||||||
qid = int(q)
|
|
||||||
except ValueError:
|
|
||||||
qid = None
|
|
||||||
if qid is not None and qid in (sess.get("account_ids") or []):
|
|
||||||
uid = qid
|
|
||||||
if not uid:
|
if not uid:
|
||||||
await ws.close(code=1008)
|
await ws.close(code=1008)
|
||||||
return
|
return
|
||||||
|
|
@ -385,7 +376,7 @@ async def messages_ws(ws: WebSocket) -> None:
|
||||||
# SA4: honour server-side session revocation on the live channel too — reject a cookie whose
|
# SA4: honour server-side session revocation on the live channel too — reject a cookie whose
|
||||||
# recorded epoch is behind the account's current one (mirrors current_user). Without this a
|
# recorded epoch is behind the account's current one (mirrors current_user). Without this a
|
||||||
# copied cookie could keep receiving pushes after a password reset / "log out everywhere".
|
# copied cookie could keep receiving pushes after a password reset / "log out everywhere".
|
||||||
epochs = sess.get("epochs") or {}
|
epochs = ws.session.get("epochs") or {}
|
||||||
epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0)
|
epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0)
|
||||||
ok = epoch_ok and is_messageable_user(user)
|
ok = epoch_ok and is_messageable_user(user)
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -528,6 +528,19 @@ export default function App() {
|
||||||
stripUrlParams();
|
stripUrlParams();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||||
|
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
|
||||||
|
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
|
||||||
|
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
||||||
|
if (meQuery.data.role === "admin") {
|
||||||
|
focusAccessRequestsTab();
|
||||||
|
setPage("users");
|
||||||
|
}
|
||||||
|
stripUrlParams();
|
||||||
|
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
|
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
|
||||||
// each consent redirect). Derived from granted scopes + storage flags so it's stable
|
// each consent redirect). Derived from granted scopes + storage flags so it's stable
|
||||||
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
|
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.39.1",
|
||||||
|
date: "2026-07-12",
|
||||||
|
fixes: [
|
||||||
|
"Signing out and back in no longer disconnects your YouTube access.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.39.0",
|
version: "0.39.0",
|
||||||
date: "2026-07-12",
|
date: "2026-07-12",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue