Merge onboarding wizard, scope split, hardening, legal pages, and deploy setup
This commit is contained in:
commit
cbd0234a8d
20 changed files with 808 additions and 52 deletions
|
|
@ -9,6 +9,8 @@ POSTGRES_DB=subfeed
|
|||
APP_PORT=8080
|
||||
|
||||
# Session signing key. Generate with: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||
# In production (an https OAUTH_REDIRECT_URL) the app refuses to start with this placeholder
|
||||
# or any key shorter than 32 chars — a known signing key would let anyone forge a session.
|
||||
SECRET_KEY=change-me-session-key
|
||||
|
||||
# Fernet key for encrypting stored OAuth refresh tokens. Generate with:
|
||||
|
|
|
|||
35
.forgejo/workflows/ci.yml
Normal file
35
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: CI
|
||||
# Lightweight validation on every push to main: type-check + build the frontend and
|
||||
# byte-compile the backend. The image build and rollout happen on the asgard host
|
||||
# (see deploy/README.md); this just catches breakage early.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: frontend
|
||||
- run: npm run build
|
||||
working-directory: frontend
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -r requirements.txt
|
||||
working-directory: backend
|
||||
- run: python -m compileall -q app
|
||||
working-directory: backend
|
||||
|
|
@ -16,12 +16,19 @@ from app.security import encrypt
|
|||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
# YouTube's full scope (read + write: unsubscribe, playlist export) and its read-only
|
||||
# counterpart. Default login asks for read-only; write is an explicit opt-in via
|
||||
# /auth/upgrade (incremental consent), so friends who won't grant write can still browse.
|
||||
# Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do
|
||||
# NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new
|
||||
# user gets a clean, familiar consent. YouTube access is granted later, one step at a
|
||||
# time, by the onboarding wizard via /auth/upgrade (incremental authorization):
|
||||
# - read -> youtube.readonly (read subscriptions, build the feed)
|
||||
# - write -> youtube (full: unsubscribe / playlist export)
|
||||
# Both YouTube scopes are "sensitive"; the wizard explains the Google warning before
|
||||
# sending the user there, so the extra permission never feels like a surprise.
|
||||
WRITE_SCOPE = "https://www.googleapis.com/auth/youtube"
|
||||
READ_SCOPES = f"openid email profile {WRITE_SCOPE}.readonly"
|
||||
WRITE_SCOPES = f"openid email profile {WRITE_SCOPE}"
|
||||
READ_SCOPE = f"{WRITE_SCOPE}.readonly"
|
||||
BASE_SCOPES = "openid email profile"
|
||||
READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
|
||||
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
|
||||
|
||||
log = logging.getLogger("subfeed.auth")
|
||||
|
||||
|
|
@ -33,10 +40,20 @@ oauth.register(
|
|||
client_id=settings.google_client_id,
|
||||
client_secret=settings.google_client_secret,
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": READ_SCOPES},
|
||||
client_kwargs={"scope": BASE_SCOPES},
|
||||
)
|
||||
|
||||
|
||||
def has_read_scope(user: User) -> bool:
|
||||
"""Whether the user's stored grant lets us read their YouTube data (build the feed).
|
||||
The full write scope is a superset, so it implies read."""
|
||||
tok = user.token
|
||||
if tok is None or not tok.scopes:
|
||||
return False
|
||||
granted = tok.scopes.split()
|
||||
return READ_SCOPE in granted or WRITE_SCOPE in granted
|
||||
|
||||
|
||||
def has_write_scope(user: User) -> bool:
|
||||
"""Whether the user's stored grant includes YouTube write (unsubscribe / export)."""
|
||||
tok = user.token
|
||||
|
|
@ -91,7 +108,7 @@ async def login(request: Request):
|
|||
access_type="offline",
|
||||
prompt="select_account",
|
||||
include_granted_scopes="true",
|
||||
scope=READ_SCOPES,
|
||||
scope=BASE_SCOPES,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -145,7 +162,9 @@ async def callback(
|
|||
tok.expiry = (
|
||||
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
||||
)
|
||||
tok.scopes = token.get("scope") or READ_SCOPES
|
||||
# include_granted_scopes=true means Google returns the union of all scopes the user
|
||||
# has ever granted this app, so this correctly reflects read/write upgrades too.
|
||||
tok.scopes = token.get("scope") or BASE_SCOPES
|
||||
db.add(tok)
|
||||
db.commit()
|
||||
|
||||
|
|
@ -193,16 +212,21 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|||
|
||||
|
||||
@router.get("/upgrade")
|
||||
async def upgrade(request: Request, user: User = Depends(current_user)):
|
||||
"""Incremental consent: re-authorize with the full YouTube (write) scope. The shared
|
||||
callback stores the new scope set, so `can_write` flips on once Google grants it."""
|
||||
async def upgrade(
|
||||
request: Request, access: str = "write", user: User = Depends(current_user)
|
||||
):
|
||||
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
||||
read-only (enough to build the feed); `access=write` additionally grants management
|
||||
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
|
||||
`can_write` flip on once granted."""
|
||||
scope = READ_SCOPES if access == "read" else WRITE_SCOPES
|
||||
return await oauth.google.authorize_redirect(
|
||||
request,
|
||||
settings.oauth_redirect_url,
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
include_granted_scopes="true",
|
||||
scope=WRITE_SCOPES,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Shipped placeholder; production must override it. Used by the startup guard below.
|
||||
_DEFAULT_SECRET_KEY = "change-me-session-key"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
|
@ -9,7 +13,7 @@ class Settings(BaseSettings):
|
|||
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
|
||||
|
||||
# Session cookie signing key.
|
||||
secret_key: str = "change-me-session-key"
|
||||
secret_key: str = _DEFAULT_SECRET_KEY
|
||||
# Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens.
|
||||
token_encryption_key: str = ""
|
||||
|
||||
|
|
@ -77,5 +81,32 @@ class Settings(BaseSettings):
|
|||
def admin_email_set(self) -> set[str]:
|
||||
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
|
||||
|
||||
@property
|
||||
def session_https_only(self) -> bool:
|
||||
"""Mark the session cookie Secure when we're served over HTTPS. We treat an
|
||||
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
|
||||
return self.oauth_redirect_url.lower().startswith("https://")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_production_secrets(self) -> "Settings":
|
||||
"""Fail fast rather than boot a public instance with the shipped placeholder
|
||||
signing key (which would let anyone forge an admin session) or without token
|
||||
encryption. Local dev (http redirect URL) keeps the convenient defaults."""
|
||||
if not self.session_https_only:
|
||||
return self # local/dev: allow the placeholder defaults
|
||||
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < 32:
|
||||
raise ValueError(
|
||||
"SECRET_KEY must be a unique random value of at least 32 chars in "
|
||||
"production (an https OAUTH_REDIRECT_URL was detected). Generate one "
|
||||
'with: python -c "import secrets; print(secrets.token_urlsafe(48))"'
|
||||
)
|
||||
if not self.token_encryption_key:
|
||||
raise ValueError(
|
||||
"TOKEN_ENCRYPTION_KEY must be set in production so refresh tokens are "
|
||||
"encrypted at rest. Generate one with: "
|
||||
'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
|||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
same_site="lax", # required so the cookie rides the OAuth redirect back from Google
|
||||
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
|
||||
)
|
||||
|
||||
if settings.frontend_origin:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user, has_write_scope
|
||||
from app.auth import current_user, has_read_scope, has_write_scope
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ def get_me(
|
|||
"display_name": user.display_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"can_read": has_read_scope(user),
|
||||
"can_write": has_write_scope(user),
|
||||
"pending_invites": pending_invites,
|
||||
"preferences": user.preferences or {},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota, state
|
||||
from app.auth import current_user
|
||||
from app.auth import current_user, has_read_scope
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.sync.runner import (
|
||||
|
|
@ -33,6 +33,12 @@ def _user_channels(db: Session, user: User) -> list[Channel]:
|
|||
def sync_subscriptions(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
if not has_read_scope(user):
|
||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||
)
|
||||
before = quota.units_used_today(db)
|
||||
with quota.attribute(user.id, "sync_subscriptions"):
|
||||
result = import_subscriptions(db, user)
|
||||
|
|
|
|||
49
deploy/README.md
Normal file
49
deploy/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Deploying Subfeed to the public VPS (asgard)
|
||||
|
||||
The public test instance runs on the asgard VPS at **https://subfeed.b1fr0st.eu**, behind
|
||||
Caddy (which terminates TLS and reverse-proxies to the app on `127.0.0.1:8080`).
|
||||
|
||||
## Layout on asgard
|
||||
|
||||
```
|
||||
/srv/subfeed/
|
||||
src/ git clone of this repo (build context + compose file)
|
||||
.env secrets, mode 0600 — NEVER committed
|
||||
```
|
||||
|
||||
The hardened compose is [`docker-compose.prod.yml`](../docker-compose.prod.yml): Postgres is
|
||||
never published, the app binds to localhost only, and every service is memory/CPU-capped for
|
||||
the small VPS.
|
||||
|
||||
## First-time setup
|
||||
|
||||
1. DNS `A`/`AAAA` for `subfeed` → asgard (done via the porkbun CLI).
|
||||
2. Caddy vhost block `subfeed.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }` (+ the shared
|
||||
security-headers snippet), then reload Caddy.
|
||||
3. `git clone` this repo to `/srv/subfeed/src`.
|
||||
4. Create `/srv/subfeed/.env` (mode 0600) with the required keys — see
|
||||
[`.env.example`](../.env.example). Generate the secrets:
|
||||
- `SECRET_KEY`: `python -c "import secrets; print(secrets.token_urlsafe(48))"`
|
||||
- `TOKEN_ENCRYPTION_KEY`: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
|
||||
- `POSTGRES_PASSWORD`: any long random string.
|
||||
- `OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback`
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `YOUTUBE_API_KEY` from Google Cloud Console.
|
||||
- `ALLOWED_EMAILS` / `ADMIN_EMAILS` = the invited Google accounts.
|
||||
|
||||
> The app refuses to start in production (an `https` redirect URL) if `SECRET_KEY` is the
|
||||
> placeholder/too short or `TOKEN_ENCRYPTION_KEY` is missing — by design.
|
||||
|
||||
## Rolling out a new version
|
||||
|
||||
```bash
|
||||
/srv/subfeed/src/deploy/deploy.sh
|
||||
```
|
||||
|
||||
It pulls `main`, rebuilds the image on the host, and restarts the stack (migrations run
|
||||
automatically via the entrypoint's `alembic upgrade head`).
|
||||
|
||||
## CI
|
||||
|
||||
`.forgejo/workflows/ci.yml` type-checks/builds the frontend and byte-compiles the backend on
|
||||
every push to `main`. It does not auto-deploy; rollout is the script above. (A future
|
||||
improvement: a Forgejo-triggered or watchtower-based auto-rollout.)
|
||||
20
deploy/deploy.sh
Executable file
20
deploy/deploy.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
# Roll out the latest main branch to the public asgard instance.
|
||||
#
|
||||
# Layout on asgard:
|
||||
# /srv/subfeed/src git clone of this repo (build context + compose file)
|
||||
# /srv/subfeed/.env secrets, mode 0600 (never in git)
|
||||
#
|
||||
# Build runs on the host Docker daemon, so it isn't constrained by the CI runner's memory.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=/srv/subfeed
|
||||
cd "$ROOT/src"
|
||||
|
||||
git fetch --quiet origin
|
||||
git checkout --quiet main
|
||||
git pull --ff-only --quiet
|
||||
|
||||
docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" up -d --build
|
||||
docker image prune -f >/dev/null || true
|
||||
docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" ps
|
||||
76
docker-compose.prod.yml
Normal file
76
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Public VPS (asgard) deployment — hardened, single-host, behind Caddy.
|
||||
#
|
||||
# Differs from docker-compose.server.yml (the home/LXC stack): the database is NEVER
|
||||
# published, the app binds to 127.0.0.1 only (Caddy terminates TLS and reverse-proxies it),
|
||||
# there is no apparmor:unconfined (this is a bare VPS, not Docker-in-LXC), and every service
|
||||
# has a hard memory/CPU cap so the stack can't exhaust the small VPS.
|
||||
#
|
||||
# Secrets live OUTSIDE the repo, in an env file on the host. Deploy with:
|
||||
# docker compose -f docker-compose.prod.yml --env-file /srv/subfeed/.env up -d --build
|
||||
#
|
||||
# Required keys in that env file: POSTGRES_PASSWORD, SECRET_KEY (>=32 chars),
|
||||
# TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
|
||||
# OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS.
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-subfeed}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in the env file}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-subfeed}
|
||||
volumes:
|
||||
- subfeed_pgdata:/var/lib/postgresql/data
|
||||
networks: [internal]
|
||||
mem_limit: 512m
|
||||
cpus: 0.75
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-subfeed} -d ${POSTGRES_DB:-subfeed}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: subfeed-api:local
|
||||
env_file:
|
||||
- /srv/subfeed/.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-subfeed}
|
||||
# The public instance owns the background scheduler (single writer).
|
||||
SCHEDULER_ENABLED: "true"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks: [internal]
|
||||
ports:
|
||||
# Localhost only — Caddy (host network) reverse-proxies 443 -> here. Never public.
|
||||
- "127.0.0.1:8080:8000"
|
||||
mem_limit: 768m
|
||||
cpus: 1.0
|
||||
pids_limit: 256
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
subfeed_pgdata:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
|
@ -24,6 +24,8 @@ import Feed from "./components/Feed";
|
|||
import Channels from "./components/Channels";
|
||||
import Stats from "./components/Stats";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import OnboardingWizard from "./components/OnboardingWizard";
|
||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||
import Toaster from "./components/Toaster";
|
||||
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
|
|
@ -61,6 +63,7 @@ export default function App() {
|
|||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||
const [page, setPageState] = useState<Page>(readPage);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
|
||||
function setFilters(next: FeedFilters) {
|
||||
setFiltersState(next);
|
||||
|
|
@ -86,6 +89,13 @@ export default function App() {
|
|||
|
||||
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||
|
||||
// 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
|
||||
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
|
||||
useEffect(() => {
|
||||
if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true);
|
||||
}, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]);
|
||||
|
||||
// On login, adopt server-stored preferences.
|
||||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
|
|
@ -181,8 +191,15 @@ export default function App() {
|
|||
view={view}
|
||||
setView={changeView}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onOpenWizard={() => {
|
||||
setSettingsOpen(false);
|
||||
setWizardOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{wizardOpen && meQuery.data && (
|
||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||
)}
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ export default function Login() {
|
|||
Sub<span className="text-accent">feed</span>
|
||||
</div>
|
||||
<p className="text-muted my-5 leading-relaxed">
|
||||
Your subscriptions, filtered your way.
|
||||
<br />
|
||||
Sign in with your Google account.
|
||||
A self-hosted, filterable feed of your YouTube subscriptions — sort, tag, and
|
||||
tune out the noise. Sign in with Google to get started; YouTube access is an
|
||||
optional, separate step you control.
|
||||
</p>
|
||||
<a
|
||||
href="/auth/login"
|
||||
|
|
@ -87,6 +87,10 @@ export default function Login() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="mt-5 flex gap-4 text-xs text-muted">
|
||||
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
|
||||
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
176
frontend/src/components/OnboardingWizard.tsx
Normal file
176
frontend/src/components/OnboardingWizard.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useEffect } from "react";
|
||||
import { AlertTriangle, Check, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import type { Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||
// app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is
|
||||
// always an explicit, informed opt-in here.
|
||||
//
|
||||
// The visible step is derived from what's already granted, so the wizard resumes correctly
|
||||
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
|
||||
|
||||
// A small reusable heads-up so the Google consent warning never feels like a surprise.
|
||||
function ConsentHeadsUp() {
|
||||
return (
|
||||
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
||||
<p className="text-xs leading-relaxed text-fg/80">
|
||||
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
|
||||
screen — that's expected, because Subfeed hasn't gone through Google's full review.
|
||||
It's safe to continue: click <span className="font-medium">Advanced</span> →{" "}
|
||||
<span className="font-medium">Go to Subfeed</span>. You can revoke access anytime from
|
||||
your{" "}
|
||||
<a
|
||||
href="https://myaccount.google.com/permissions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Google Account
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dots({ index, total }: { index: number; total: number }) {
|
||||
return (
|
||||
<div className="flex justify-center gap-1.5">
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
||||
// Closing without finishing the essential read step suppresses future auto-popups
|
||||
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
||||
const close = () => {
|
||||
endOnboarding(!me.can_read);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me.can_read]);
|
||||
|
||||
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
|
||||
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
|
||||
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{step === "read" && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Youtube className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||
You're signed in. To build your feed, Subfeed needs{" "}
|
||||
<span className="text-fg/90">read-only</span> access to the channels you're
|
||||
subscribed to on YouTube. It never posts, deletes, or changes anything with this.
|
||||
</p>
|
||||
<ConsentHeadsUp />
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => beginGrant("read")}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
<Youtube className="w-4 h-4" /> Connect (read-only)
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "write" && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">You're connected</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||
Your feed is ready. Optionally, you can let Subfeed{" "}
|
||||
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you —
|
||||
this needs an extra write permission. Skip it to stay read-only; you can always
|
||||
enable it later in Settings.
|
||||
</p>
|
||||
<ConsentHeadsUp />
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => beginGrant("write")}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" /> Enable unsubscribe
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
No thanks — keep it read-only
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">All set</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
||||
Read and unsubscribe access are both granted. You can review or revoke either one
|
||||
anytime in Settings → Account, or from your Google Account.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Dots index={stepIndex} total={3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ export default function SettingsPanel({
|
|||
view,
|
||||
setView,
|
||||
onClose,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
me: Me;
|
||||
theme: ThemePrefs;
|
||||
|
|
@ -36,6 +37,7 @@ export default function SettingsPanel({
|
|||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
onClose: () => void;
|
||||
onOpenWizard: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabId>("appearance");
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
|
@ -115,7 +117,7 @@ export default function SettingsPanel({
|
|||
)}
|
||||
{t.id === "notifications" && <Notifications />}
|
||||
{t.id === "sync" && <Sync me={me} />}
|
||||
{t.id === "account" && <Account me={me} />}
|
||||
{t.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -464,7 +466,46 @@ function Sync({ me }: { me: Me }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Account({ me }: { me: Me }) {
|
||||
function AccessRow({
|
||||
title,
|
||||
granted,
|
||||
grantedHint,
|
||||
enableHint,
|
||||
onEnable,
|
||||
}: {
|
||||
title: string;
|
||||
granted: boolean;
|
||||
grantedHint: string;
|
||||
enableHint: string;
|
||||
onEnable: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
{granted ? grantedHint : enableHint}
|
||||
</p>
|
||||
</div>
|
||||
{granted ? (
|
||||
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
Granted
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Subfeed.">
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Section title="Account">
|
||||
|
|
@ -480,34 +521,39 @@ function Account({ me }: { me: Me }) {
|
|||
<div className="text-xs text-muted capitalize">{me.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 pt-3 border-t border-border">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Playlist editing & YouTube export</div>
|
||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
{me.can_write
|
||||
? "Granted. You can unsubscribe from channels on YouTube (and export playlists once that ships). Subfeed only writes when you ask it to."
|
||||
: "Subfeed is read-only by default — it can browse your subscriptions but not change your YouTube account. Enable this to unsubscribe from channels (and, later, export playlists). You'll re-consent with Google."}
|
||||
</Section>
|
||||
|
||||
<Section title="YouTube access">
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
Sign-in only shares your name and email. YouTube access is granted separately, below —
|
||||
each is optional and revocable anytime in your Google Account.
|
||||
</p>
|
||||
</div>
|
||||
{me.can_write ? (
|
||||
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint="Redirects to Google to grant YouTube write access (unsubscribe / export). You can keep using Subfeed read-only if you skip it.">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = "/auth/upgrade";
|
||||
<div className="divide-y divide-border">
|
||||
<AccessRow
|
||||
title="Read subscriptions (your feed)"
|
||||
granted={me.can_read}
|
||||
grantedHint="Granted. Subfeed reads the channels you follow to build your feed. It never changes your YouTube account with this."
|
||||
enableHint="Read-only access to your subscriptions — required to build your feed. Subfeed can't modify anything with it."
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=read";
|
||||
}}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||
/>
|
||||
<AccessRow
|
||||
title="Unsubscribe (write)"
|
||||
granted={me.can_write}
|
||||
grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Subfeed. It only writes when you ask it to."
|
||||
enableHint="Lets Subfeed unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=write";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
Enable
|
||||
Walk me through setup
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
{me.role === "admin" && <AdminInvites />}
|
||||
</>
|
||||
|
|
|
|||
47
frontend/src/components/legal/LegalLayout.tsx
Normal file
47
frontend/src/components/legal/LegalLayout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Shared shell for the public, login-free legal pages (/privacy, /terms). These must be
|
||||
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
|
||||
// fetch them directly — so they render outside the authenticated App tree (see main.tsx).
|
||||
|
||||
export const CONTACT_EMAIL = "b1fr0stmailops@gmail.com";
|
||||
export const LAST_UPDATED = "June 14, 2026";
|
||||
|
||||
export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LegalLayout({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg text-fg">
|
||||
<div className="mx-auto w-[min(92vw,720px)] py-12">
|
||||
<a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8">
|
||||
Sub<span className="text-accent">feed</span>
|
||||
</a>
|
||||
<div className="glass rounded-2xl p-8">
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
<p className="text-xs text-muted mt-1 mb-6">Last updated: {LAST_UPDATED}</p>
|
||||
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
|
||||
</div>
|
||||
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
|
||||
<a href="/" className="hover:text-fg transition">Home</a>
|
||||
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
|
||||
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="hover:text-fg transition">Contact</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function H2({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="text-base font-semibold text-fg pt-2">{children}</h2>;
|
||||
}
|
||||
111
frontend/src/components/legal/PrivacyPolicy.tsx
Normal file
111
frontend/src/components/legal/PrivacyPolicy.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout";
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<LegalLayout title="Privacy Policy">
|
||||
<p>
|
||||
Subfeed is a personal, non-commercial project operated by an individual in Hungary
|
||||
("we", "us"). It lets you view and organize your own YouTube subscriptions as a
|
||||
filterable feed. This policy explains what data Subfeed accesses, how it is used, and
|
||||
the choices you have. By using Subfeed you agree to this policy.
|
||||
</p>
|
||||
|
||||
<H2>What we access</H2>
|
||||
<p>Subfeed only accesses data you explicitly authorize, in two separate steps:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>
|
||||
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google
|
||||
profile — name, email address, profile picture, and Google account identifier — used
|
||||
solely to create and identify your account.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-fg font-medium">YouTube read (optional, opt-in):</span> with the{" "}
|
||||
<code className="text-xs">youtube.readonly</code> scope, your list of YouTube
|
||||
subscriptions and the public metadata of those channels and their videos (titles,
|
||||
thumbnails, durations, view counts, publish dates), used to build your feed.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with
|
||||
the <code className="text-xs">youtube</code> scope, the ability to unsubscribe from a
|
||||
channel on YouTube — performed only when you click to do so.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
You can use Subfeed with sign-in alone, and grant or decline each YouTube permission
|
||||
independently.
|
||||
</p>
|
||||
|
||||
<H2>How we use it</H2>
|
||||
<p>
|
||||
Your data is used exclusively to provide Subfeed's features to you: building and
|
||||
displaying your personalized subscription feed, and carrying out actions (such as
|
||||
unsubscribing) that you initiate. We do not use it for advertising, profiling, or any
|
||||
purpose unrelated to the app.
|
||||
</p>
|
||||
|
||||
<H2>Limited Use disclosure</H2>
|
||||
<p>
|
||||
Subfeed's use and transfer of information received from Google APIs to any other app
|
||||
will adhere to the{" "}
|
||||
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
|
||||
Google API Services User Data Policy
|
||||
</LegalLink>
|
||||
, including the Limited Use requirements. We do not sell your data, do not transfer it
|
||||
to third parties (except as needed to operate the app, e.g. the hosting infrastructure
|
||||
we control), and do not use it for advertising.
|
||||
</p>
|
||||
|
||||
<H2>Storage & security</H2>
|
||||
<p>
|
||||
Data is stored in a private PostgreSQL database on a server we control, accessible only
|
||||
to the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted
|
||||
to the emails explicitly invited to the app. No system is perfectly secure, but we take
|
||||
reasonable measures to protect your data.
|
||||
</p>
|
||||
|
||||
<H2>Retention & deletion</H2>
|
||||
<p>
|
||||
We keep your data while your account is active. You can revoke Subfeed's access to your
|
||||
Google data at any time at{" "}
|
||||
<LegalLink href="https://myaccount.google.com/permissions">
|
||||
myaccount.google.com/permissions
|
||||
</LegalLink>
|
||||
; once revoked, the stored tokens can no longer be used. To have your account and
|
||||
associated data deleted, email{" "}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<H2>Cookies</H2>
|
||||
<p>
|
||||
Subfeed sets a single, signed session cookie to keep you logged in. It is not used for
|
||||
tracking or advertising, and there are no third-party analytics cookies.
|
||||
</p>
|
||||
|
||||
<H2>YouTube & Google</H2>
|
||||
<p>
|
||||
Subfeed uses YouTube API Services. By using Subfeed you also agree to the{" "}
|
||||
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>,
|
||||
and your use of Google data is subject to the{" "}
|
||||
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
||||
</p>
|
||||
|
||||
<H2>Changes</H2>
|
||||
<p>
|
||||
We may update this policy; material changes will be reflected by the "Last updated" date
|
||||
above. Continued use after a change constitutes acceptance.
|
||||
</p>
|
||||
|
||||
<H2>Contact</H2>
|
||||
<p>
|
||||
Questions about this policy or your data:{" "}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/legal/Terms.tsx
Normal file
67
frontend/src/components/legal/Terms.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout";
|
||||
|
||||
export default function Terms() {
|
||||
return (
|
||||
<LegalLayout title="Terms of Service">
|
||||
<p>
|
||||
Subfeed is a personal, non-commercial project operated by an individual in Hungary. It
|
||||
provides a filterable view of your own YouTube subscriptions. By accessing or using
|
||||
Subfeed, you agree to these Terms. If you do not agree, do not use the service.
|
||||
</p>
|
||||
|
||||
<H2>The service</H2>
|
||||
<p>
|
||||
Subfeed is offered on an invite-only basis, free of charge, and "as is", without
|
||||
warranties of any kind. It is a hobby project: features may change, and the service may
|
||||
be slowed, interrupted, or discontinued at any time without notice.
|
||||
</p>
|
||||
|
||||
<H2>Your account & acceptable use</H2>
|
||||
<p>
|
||||
You sign in with your Google account and may only access Subfeed with an email that has
|
||||
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
|
||||
overload it, and not to attempt to access other users' data. We may suspend or revoke
|
||||
access at our discretion, for example in case of misuse.
|
||||
</p>
|
||||
|
||||
<H2>Third-party services</H2>
|
||||
<p>
|
||||
Subfeed uses YouTube API Services. By using Subfeed you agree to be bound by the{" "}
|
||||
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>.
|
||||
Your data is handled as described in our{" "}
|
||||
<a href="/privacy" className="text-accent hover:underline">Privacy Policy</a> and, for
|
||||
Google data, the{" "}
|
||||
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
||||
</p>
|
||||
|
||||
<H2>No warranty & limitation of liability</H2>
|
||||
<p>
|
||||
To the maximum extent permitted by law, the service is provided without warranty, and
|
||||
the operator is not liable for any indirect, incidental, or consequential damages, or
|
||||
for any loss of data, arising from your use of Subfeed. Your sole remedy if you are
|
||||
dissatisfied is to stop using the service and revoke its access to your account.
|
||||
</p>
|
||||
|
||||
<H2>Governing law</H2>
|
||||
<p>
|
||||
These Terms are governed by the laws of Hungary and applicable European Union law,
|
||||
without regard to conflict-of-law rules.
|
||||
</p>
|
||||
|
||||
<H2>Changes</H2>
|
||||
<p>
|
||||
We may update these Terms; the "Last updated" date above reflects the latest version.
|
||||
Continued use after a change constitutes acceptance.
|
||||
</p>
|
||||
|
||||
<H2>Contact</H2>
|
||||
<p>
|
||||
Questions:{" "}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export interface Me {
|
|||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
preferences: Record<string, any>;
|
||||
|
|
|
|||
33
frontend/src/lib/onboarding.ts
Normal file
33
frontend/src/lib/onboarding.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Onboarding wizard visibility, kept stable across the OAuth redirect round-trip.
|
||||
//
|
||||
// Granting a YouTube scope navigates away to Google's consent screen and back, which
|
||||
// fully reloads the SPA — so we can't hold the wizard's progress in React state. Instead
|
||||
// we derive the current step from the granted scopes (me.can_read / me.can_write) and use
|
||||
// two storage flags to decide whether to auto-open:
|
||||
//
|
||||
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
|
||||
// "Connect"); makes the wizard reopen after the
|
||||
// redirect so they land on the next step.
|
||||
// ONBOARD_DISMISSED (localStorage) — set when the user skips the essential read step;
|
||||
// suppresses the auto-popup on future logins (they
|
||||
// can still reopen it from Settings → Account).
|
||||
|
||||
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
||||
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
|
||||
|
||||
export function shouldAutoOpenOnboarding(me: { can_read: boolean }): boolean {
|
||||
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
||||
}
|
||||
|
||||
/** Mark a grant as in progress, then send the user to Google's consent screen. */
|
||||
export function beginGrant(access: "read" | "write"): void {
|
||||
sessionStorage.setItem(ONBOARD_ACTIVE, "1");
|
||||
window.location.href = `/auth/upgrade?access=${access}`;
|
||||
}
|
||||
|
||||
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
|
||||
export function endOnboarding(dismiss: boolean): void {
|
||||
sessionStorage.removeItem(ONBOARD_ACTIVE);
|
||||
if (dismiss) localStorage.setItem(ONBOARD_DISMISSED, "1");
|
||||
}
|
||||
|
|
@ -3,18 +3,28 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||
import Terms from "./components/legal/Terms";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
// Public, login-free legal pages. They live outside the authenticated App tree (no /api/me)
|
||||
// so Google's consent screen and reviewers can fetch them directly. Reached by full-page
|
||||
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
|
||||
const path = window.location.pathname;
|
||||
const root =
|
||||
path === "/privacy" ? <PrivacyPolicy /> :
|
||||
path === "/terms" ? <Terms /> : (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>{root}</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue