For invited users
+View the dashboard, inspect legislator profiles, and compare issue scoring without sharing a local password.
+diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d97b9eb
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+.git
+.pytest_cache
+.ruff_cache
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.venv
+venv
+env
+ENV
+.env
+dist
+build
+htmlcov
+coverage.xml
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0edaf0b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,21 @@
+# Postgres used by the FastAPI app
+DATA_SCIENCE_DEV_DB=your_existing_database
+DATA_SCIENCE_DEV_HOST=your_existing_postgres_host
+DATA_SCIENCE_DEV_PORT=5432
+DATA_SCIENCE_DEV_USER=your_existing_postgres_user
+DATA_SCIENCE_DEV_PASSWORD=your_existing_postgres_password
+
+# WorkOS AuthKit
+WORKOS_API_KEY=sk_test_your_workos_api_key
+WORKOS_CLIENT_ID=client_your_workos_client_id
+WORKOS_COOKIE_PASSWORD=replace_with_a_long_random_secret_at_least_32_chars
+WORKOS_ORGANIZATION_ID=org_your_workspace_org_id
+WORKOS_REDIRECT_URI=http://localhost:8000/callback
+WORKOS_LOGOUT_REDIRECT_URI=http://localhost:8000/
+WORKOS_SESSION_COOKIE_NAME=workos_session
+
+# Optional local port overrides for Docker Compose
+WEB_PUBLISHED_PORT=8000
+
+# Only used if you explicitly start the optional local Postgres profile
+POSTGRES_PUBLISHED_PORT=5432
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..066a5e8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libpq5 \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY pyproject.toml /app/pyproject.toml
+COPY __init__.py /app/__init__.py
+COPY alembic /app/alembic
+COPY database_cli.py /app/database_cli.py
+COPY pipelines /app/pipelines
+COPY docker /app/docker
+
+RUN pip install --no-cache-dir .
+
+RUN chmod +x /app/docker/web-entrypoint.sh
+
+EXPOSE 8000
+
+CMD ["/app/docker/web-entrypoint.sh"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..29e3cae
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,52 @@
+services:
+ db:
+ image: postgres:16
+ profiles: ["localdb"]
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${DATA_SCIENCE_DEV_DB:-nornsight}
+ POSTGRES_USER: ${DATA_SCIENCE_DEV_USER:-nornsight}
+ POSTGRES_PASSWORD: ${DATA_SCIENCE_DEV_PASSWORD:-nornsight}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "pg_isready -U ${DATA_SCIENCE_DEV_USER:-nornsight} -d ${DATA_SCIENCE_DEV_DB:-nornsight}",
+ ]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+ start_period: 5s
+ ports:
+ - "${POSTGRES_PUBLISHED_PORT:-5432}:5432"
+
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ dns:
+ - ${WEB_DNS_1:-1.1.1.1}
+ - ${WEB_DNS_2:-8.8.8.8}
+ environment:
+ DATA_SCIENCE_DEV_DB: ${DATA_SCIENCE_DEV_DB}
+ DATA_SCIENCE_DEV_HOST: ${DATA_SCIENCE_DEV_HOST}
+ DATA_SCIENCE_DEV_PORT: ${DATA_SCIENCE_DEV_PORT}
+ DATA_SCIENCE_DEV_USER: ${DATA_SCIENCE_DEV_USER}
+ DATA_SCIENCE_DEV_PASSWORD: ${DATA_SCIENCE_DEV_PASSWORD}
+ WORKOS_API_KEY: ${WORKOS_API_KEY}
+ WORKOS_CLIENT_ID: ${WORKOS_CLIENT_ID}
+ WORKOS_COOKIE_PASSWORD: ${WORKOS_COOKIE_PASSWORD}
+ WORKOS_ORGANIZATION_ID: ${WORKOS_ORGANIZATION_ID}
+ WORKOS_REDIRECT_URI: ${WORKOS_REDIRECT_URI:-http://localhost:8000/callback}
+ WORKOS_LOGOUT_REDIRECT_URI: ${WORKOS_LOGOUT_REDIRECT_URI:-http://localhost:8000/}
+ WORKOS_SESSION_COOKIE_NAME: ${WORKOS_SESSION_COOKIE_NAME:-workos_session}
+ UVICORN_HOST: 0.0.0.0
+ UVICORN_PORT: 8000
+ ports:
+ - "${WEB_PUBLISHED_PORT:-8000}:8000"
+
+volumes:
+ postgres_data:
diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh
new file mode 100644
index 0000000..9fca8ee
--- /dev/null
+++ b/docker/web-entrypoint.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env sh
+set -eu
+
+python - <<'PY'
+import os
+import time
+
+import psycopg
+
+db = os.environ["DATA_SCIENCE_DEV_DB"]
+host = os.environ["DATA_SCIENCE_DEV_HOST"]
+port = os.environ["DATA_SCIENCE_DEV_PORT"]
+user = os.environ["DATA_SCIENCE_DEV_USER"]
+password = os.environ.get("DATA_SCIENCE_DEV_PASSWORD", "")
+
+dsn = f"dbname={db} host={host} port={port} user={user} password={password}"
+
+for attempt in range(60):
+ try:
+ with psycopg.connect(dsn) as conn:
+ with conn.cursor() as cur:
+ cur.execute("CREATE SCHEMA IF NOT EXISTS main")
+ conn.commit()
+ break
+ except psycopg.OperationalError:
+ if attempt == 59:
+ raise
+ time.sleep(1)
+PY
+
+python /app/database_cli.py data_science_dev upgrade head
+
+exec uvicorn pipelines.web.main:app --host "${UVICORN_HOST:-0.0.0.0}" --port "${UVICORN_PORT:-8000}"
diff --git a/pipelines/common.py b/pipelines/common.py
index 1e717dc..af0482b 100644
--- a/pipelines/common.py
+++ b/pipelines/common.py
@@ -5,10 +5,8 @@ from __future__ import annotations
import logging
import sys
from datetime import UTC, datetime
-from os import getenv
from subprocess import PIPE, Popen
-from apprise import Apprise
logger = logging.getLogger(__name__)
@@ -47,25 +45,6 @@ def bash_wrapper(command: str) -> tuple[str, int]:
return output.decode(), process.returncode
-def signal_alert(body: str, title: str = "") -> None:
- """Send a signal alert.
-
- Args:
- body (str): The body of the alert.
- title (str, optional): The title of the alert. Defaults to "".
- """
- apprise_client = Apprise()
-
- from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
- to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
- if not from_phone or not to_phone:
- logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
- return
-
- apprise_client.add(f"signal://localhost:8989/{from_phone}/{to_phone}")
-
- apprise_client.notify(title=title, body=body)
-
def utcnow() -> datetime:
"""Get the current UTC time."""
diff --git a/pipelines/containers/web.py b/pipelines/containers/web.py
new file mode 100644
index 0000000..762ad18
--- /dev/null
+++ b/pipelines/containers/web.py
@@ -0,0 +1,197 @@
+"""Docker container lifecycle management for the web app stack."""
+
+from __future__ import annotations
+
+import logging
+import os
+import subprocess
+from pathlib import Path
+from typing import Annotated, Literal
+
+import typer
+
+logger = logging.getLogger(__name__)
+
+REPO_DIR = Path(__file__).resolve().parents[2]
+COMPOSE_FILE = REPO_DIR / "docker-compose.yml"
+EnvTarget = Literal["all", "web", "db"]
+REQUIRED_WORKOS_ENV_VARS = (
+ "WORKOS_API_KEY",
+ "WORKOS_CLIENT_ID",
+ "WORKOS_COOKIE_PASSWORD",
+ "WORKOS_ORGANIZATION_ID",
+)
+
+app = typer.Typer(help="Web stack container management.")
+
+
+def _compose_command(*args: str) -> list[str]:
+ """Build a docker compose command for the repo-local stack."""
+ return ["docker", "compose", "-f", str(COMPOSE_FILE), *args]
+
+
+def _run_compose(
+ *args: str,
+ capture_output: bool = False,
+ check: bool = True,
+) -> subprocess.CompletedProcess[str]:
+ """Run docker compose in the repository root."""
+ result = subprocess.run(
+ _compose_command(*args),
+ cwd=REPO_DIR,
+ text=True,
+ capture_output=capture_output,
+ check=False,
+ )
+ if check and result.returncode != 0:
+ detail = result.stderr.strip() if result.stderr else f"exit code {result.returncode}"
+ raise RuntimeError(f"docker compose {' '.join(args)} failed: {detail}")
+ return result
+
+
+def _validate_workos_env() -> None:
+ """Ensure the web app has the WorkOS env vars it needs before startup."""
+ missing = [name for name in REQUIRED_WORKOS_ENV_VARS if not os.getenv(name)]
+ if missing:
+ message = (
+ "Missing required WorkOS environment variables: "
+ + ", ".join(missing)
+ + ". Populate .env before running the web stack."
+ )
+ raise RuntimeError(message)
+
+ cookie_password = os.getenv("WORKOS_COOKIE_PASSWORD", "")
+ if len(cookie_password) < 32:
+ raise RuntimeError("WORKOS_COOKIE_PASSWORD must be at least 32 characters long.")
+
+
+def build_stack() -> None:
+ """Build the web app image."""
+ logger.info("Building web image from %s", COMPOSE_FILE)
+ _run_compose("build", "web", capture_output=False)
+ logger.info("Web image built")
+
+
+def _validate_database_env() -> None:
+ """Ensure the web app has the database env vars it needs before startup."""
+ required = (
+ "DATA_SCIENCE_DEV_DB",
+ "DATA_SCIENCE_DEV_HOST",
+ "DATA_SCIENCE_DEV_PORT",
+ "DATA_SCIENCE_DEV_USER",
+ )
+ missing = [name for name in required if not os.getenv(name)]
+ if missing:
+ message = (
+ "Missing required database environment variables: "
+ + ", ".join(missing)
+ + ". Populate .env before running the web stack."
+ )
+ raise RuntimeError(message)
+
+
+def start_stack(
+ *, build: bool = False, detach: bool = False, with_local_db: bool = False
+) -> None:
+ """Start the web stack, using the existing DB by default."""
+ _validate_workos_env()
+ _validate_database_env()
+ command = ["up"]
+ if build:
+ command.append("--build")
+ if detach:
+ command.append("-d")
+ if with_local_db:
+ command.extend(["--profile", "localdb", "db", "web"])
+ else:
+ command.append("web")
+ logger.info(
+ "Starting web stack%s",
+ " with local Postgres" if with_local_db else " against existing Postgres",
+ )
+ _run_compose(*command, capture_output=False)
+
+
+def stop_stack(*, drop_volumes: bool = False) -> None:
+ """Stop and remove the web stack."""
+ logger.info("Stopping web stack")
+ command = ["down"]
+ if drop_volumes:
+ command.append("--volumes")
+ _run_compose(*command, capture_output=False)
+
+
+def logs_stack(*, target: EnvTarget = "all", follow: bool = False, tail: int = 100) -> None:
+ """Show docker compose logs for the web stack."""
+ command = ["logs", "--tail", str(tail)]
+ if follow:
+ command.append("--follow")
+ if target != "all":
+ command.append(target)
+ _run_compose(*command, capture_output=False)
+
+
+@app.command()
+def build(
+ log_level: Annotated[str, typer.Option(help="Log level")] = "INFO",
+) -> None:
+ """Build the web Docker image."""
+ logging.basicConfig(
+ level=log_level,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ )
+ build_stack()
+
+
+@app.command()
+def run(
+ build: Annotated[
+ bool, typer.Option(help="Rebuild the web image before starting the stack")
+ ] = False,
+ detach: Annotated[
+ bool, typer.Option(help="Start the stack in the background")
+ ] = False,
+ with_local_db: Annotated[
+ bool, typer.Option(help="Also start the optional local Postgres container")
+ ] = False,
+ log_level: Annotated[str, typer.Option(help="Log level")] = "INFO",
+) -> None:
+ """Run the web + Postgres stack."""
+ logging.basicConfig(
+ level=log_level,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ )
+ start_stack(build=build, detach=detach, with_local_db=with_local_db)
+
+
+@app.command()
+def stop(
+ drop_volumes: Annotated[
+ bool, typer.Option(help="Also delete the Postgres volume")
+ ] = False,
+) -> None:
+ """Stop and remove the web stack."""
+ stop_stack(drop_volumes=drop_volumes)
+
+
+@app.command()
+def logs(
+ target: Annotated[
+ EnvTarget, typer.Option(help="Which service logs to show")
+ ] = "all",
+ follow: Annotated[
+ bool, typer.Option(help="Follow logs until interrupted")
+ ] = False,
+ tail: Annotated[int, typer.Option(help="How many recent lines to show")] = 100,
+) -> None:
+ """Show recent logs from the web stack."""
+ logs_stack(target=target, follow=follow, tail=tail)
+
+
+def cli() -> None:
+ """Typer entry point."""
+ app()
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/pipelines/web/auth.py b/pipelines/web/auth.py
new file mode 100644
index 0000000..092427c
--- /dev/null
+++ b/pipelines/web/auth.py
@@ -0,0 +1,202 @@
+"""WorkOS AuthKit helpers for the FastAPI web app."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+from os import getenv
+from typing import Any
+
+from fastapi import Request
+from workos import WorkOSClient
+from workos.session import seal_session_from_auth_response
+
+
+@dataclass(frozen=True)
+class AuthConfig:
+ """Runtime configuration for WorkOS AuthKit."""
+
+ api_key: str
+ client_id: str
+ cookie_password: str
+ redirect_uri: str
+ logout_redirect_uri: str
+ session_cookie_name: str
+ organization_id: str
+
+ @property
+ def secure_cookies(self) -> bool:
+ return self.redirect_uri.startswith("https://")
+
+
+@dataclass(frozen=True)
+class AuthSession:
+ """Normalized auth session passed through the app."""
+
+ user_id: str
+ email: str
+ first_name: str | None
+ last_name: str | None
+ role_slugs: set[str]
+ organization_id: str | None
+ raw_user: Any
+ raw_session: Any
+
+ @property
+ def display_name(self) -> str:
+ parts = [part for part in (self.first_name, self.last_name) if part]
+ return " ".join(parts) if parts else self.email
+
+ @property
+ def is_admin(self) -> bool:
+ return "admin" in self.role_slugs
+
+
+@dataclass(frozen=True)
+class CallbackResult:
+ """Result of exchanging a WorkOS callback code."""
+
+ sealed_session: str
+ next_path: str
+
+
+def safe_next_path(value: str | None, default: str = "/dashboard") -> str:
+ """Allow only local relative redirect targets."""
+ if value and value.startswith("/") and not value.startswith("//"):
+ return value
+ return default
+
+
+def build_authorization_url(next_path: str) -> str:
+ """Build the WorkOS hosted login URL."""
+ config = get_auth_config()
+ return get_workos_client().user_management.get_authorization_url(
+ provider="authkit",
+ redirect_uri=config.redirect_uri,
+ state=safe_next_path(next_path),
+ organization_id=config.organization_id,
+ )
+
+
+def exchange_code(request: Request) -> CallbackResult:
+ """Exchange a WorkOS callback code for a sealed session cookie value."""
+ code = request.query_params.get("code")
+ if not code:
+ raise ValueError("Missing authentication code.")
+
+ config = get_auth_config()
+ auth_response = get_workos_client().user_management.authenticate_with_code(
+ code=code,
+ ip_address=_request_ip(request),
+ user_agent=request.headers.get("user-agent"),
+ )
+ sealed_session = seal_session_from_auth_response(
+ access_token=auth_response.access_token,
+ refresh_token=auth_response.refresh_token,
+ user=auth_response.user.to_dict(),
+ impersonator=auth_response.impersonator.to_dict()
+ if auth_response.impersonator is not None
+ else None,
+ cookie_password=config.cookie_password,
+ )
+
+ return CallbackResult(
+ sealed_session=sealed_session,
+ next_path=safe_next_path(request.query_params.get("state")),
+ )
+
+
+def get_current_session(request: Request) -> AuthSession | None:
+ """Load the current signed-in WorkOS session from the sealed cookie."""
+ cookie_name = getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session")
+ sealed_session = request.cookies.get(cookie_name)
+ if not sealed_session:
+ return None
+
+ config = get_auth_config()
+ session = get_workos_client().user_management.load_sealed_session(
+ session_data=sealed_session,
+ cookie_password=config.cookie_password,
+ )
+ auth_response = session.authenticate()
+ if not getattr(auth_response, "authenticated", False):
+ return None
+
+ user = auth_response.user or {}
+ organization_id = getattr(auth_response, "organization_id", None)
+ if config.organization_id and organization_id != config.organization_id:
+ return None
+ role_slugs = set(getattr(auth_response, "roles", None) or [])
+ role = getattr(auth_response, "role", None)
+ if role:
+ role_slugs.add(role)
+
+ return AuthSession(
+ user_id=_user_field(user, "id") or "",
+ email=_user_field(user, "email") or "",
+ first_name=_user_field(user, "first_name"),
+ last_name=_user_field(user, "last_name"),
+ role_slugs=role_slugs,
+ organization_id=organization_id,
+ raw_user=user,
+ raw_session=auth_response,
+ )
+
+
+def get_logout_url(request: Request) -> str:
+ """Return the WorkOS logout URL for the current sealed session."""
+ config = get_auth_config()
+ sealed_session = request.cookies.get(config.session_cookie_name)
+ if not sealed_session:
+ return config.logout_redirect_uri
+
+ session = get_workos_client().user_management.load_sealed_session(
+ session_data=sealed_session,
+ cookie_password=config.cookie_password,
+ )
+ return session.get_logout_url(return_to=config.logout_redirect_uri)
+
+
+@lru_cache(maxsize=1)
+def get_auth_config() -> AuthConfig:
+ """Load and validate WorkOS environment configuration."""
+ values = {
+ "WORKOS_API_KEY": getenv("WORKOS_API_KEY"),
+ "WORKOS_CLIENT_ID": getenv("WORKOS_CLIENT_ID"),
+ "WORKOS_COOKIE_PASSWORD": getenv("WORKOS_COOKIE_PASSWORD"),
+ "WORKOS_ORGANIZATION_ID": getenv("WORKOS_ORGANIZATION_ID"),
+ }
+ missing = [name for name, value in values.items() if not value]
+ if missing:
+ raise RuntimeError(
+ "Missing WorkOS configuration: " + ", ".join(sorted(missing))
+ )
+
+ return AuthConfig(
+ api_key=values["WORKOS_API_KEY"] or "",
+ client_id=values["WORKOS_CLIENT_ID"] or "",
+ cookie_password=values["WORKOS_COOKIE_PASSWORD"] or "",
+ redirect_uri=getenv("WORKOS_REDIRECT_URI", "http://localhost:8000/callback"),
+ logout_redirect_uri=getenv("WORKOS_LOGOUT_REDIRECT_URI", "http://localhost:8000/"),
+ session_cookie_name=getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session"),
+ organization_id=values["WORKOS_ORGANIZATION_ID"] or "",
+ )
+
+
+@lru_cache(maxsize=1)
+def get_workos_client():
+ """Create and cache the WorkOS SDK client."""
+ config = get_auth_config()
+ return WorkOSClient(api_key=config.api_key, client_id=config.client_id)
+
+
+def _request_ip(request: Request) -> str | None:
+ if request.client is None:
+ return None
+ return request.client.host
+
+
+def _user_field(user: Any, key: str) -> Any:
+ if isinstance(user, dict):
+ return user.get(key)
+ return getattr(user, key, None)
diff --git a/pipelines/web/main.py b/pipelines/web/main.py
index 1944a54..8d51841 100644
--- a/pipelines/web/main.py
+++ b/pipelines/web/main.py
@@ -4,20 +4,17 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from dataclasses import dataclass
-import hashlib
-import hmac
+import logging
from os import getenv
from pathlib import Path
-import secrets
from typing import Any
-from urllib.parse import parse_qs
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
-from pipelines.web import repository
+from pipelines.web import auth, repository
from pipelines.web.db import session_scope, validate_database_connection
from pipelines.web.repository import Chamber, RankingResult
from pipelines.web.scoring import normalize_issues
@@ -28,10 +25,7 @@ TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
templates = Jinja2Templates(directory=TEMPLATES_DIR)
-ADMIN_USERNAME = "admin"
-ADMIN_PASSWORD = "admin"
-SESSION_COOKIE = "nornsight_admin"
-SESSION_SECRET = "nornsight-local-dev-session-secret"
+logger = logging.getLogger(__name__)
@asynccontextmanager
@@ -62,72 +56,69 @@ def healthz() -> str:
return "ok"
-@app.get("/login", response_class=HTMLResponse)
-def login(request: Request) -> Response:
- """Render the integrated login page."""
- next_path = _safe_next_path(request.query_params.get("next"))
- if _authenticated_user(request) is not None:
- return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
+@app.get("/", response_class=HTMLResponse)
+def home(request: Request) -> Response:
+ """Render the public home page."""
+ current_user = auth.get_current_session(request)
return templates.TemplateResponse(
request,
- "login.html",
+ "home.html",
{
- "error": "",
- "is_authenticated": False,
- "show_primary_nav": False,
- "next_path": next_path,
- "username": "",
+ **_auth_context(current_user),
+ "auth_error": request.query_params.get("auth_error") == "1",
},
)
-@app.post("/login", response_class=HTMLResponse)
-async def login_submit(request: Request) -> Response:
- """Authenticate the hard-coded admin user and set a session cookie."""
- form = parse_qs((await request.body()).decode())
- username = form.get("username", [""])[0]
- password = form.get("password", [""])[0]
- next_path = _safe_next_path(form.get("next", [request.query_params.get("next")])[0])
+@app.get("/login")
+def login(request: Request) -> Response:
+ """Start the WorkOS hosted login flow."""
+ next_path = auth.safe_next_path(request.query_params.get("next"))
+ current_user = auth.get_current_session(request)
+ if current_user is not None:
+ return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
+ return RedirectResponse(
+ auth.build_authorization_url(next_path),
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
- username_ok = secrets.compare_digest(username, ADMIN_USERNAME)
- password_ok = secrets.compare_digest(password, ADMIN_PASSWORD)
- if not (username_ok and password_ok):
- return templates.TemplateResponse(
- request,
- "login.html",
- {
- "error": "Invalid username or password.",
- "is_authenticated": False,
- "show_primary_nav": False,
- "next_path": next_path,
- "username": username,
- },
- status_code=status.HTTP_401_UNAUTHORIZED,
- )
- response = RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
+@app.get("/callback")
+def callback(request: Request) -> Response:
+ """Exchange the WorkOS code for a sealed session cookie."""
+ try:
+ result = auth.exchange_code(request)
+ except Exception:
+ logger.exception("WorkOS callback exchange failed.")
+ response = RedirectResponse("/?auth_error=1", status_code=status.HTTP_303_SEE_OTHER)
+ _delete_auth_cookie(response)
+ return response
+
+ config = auth.get_auth_config()
+ response = RedirectResponse(result.next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
- SESSION_COOKIE,
- _sign_session(username),
+ config.session_cookie_name,
+ result.sealed_session,
httponly=True,
samesite="lax",
+ secure=config.secure_cookies,
)
return response
-@app.get("/logout")
-def logout() -> Response:
- """Clear the local admin session."""
- response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
- response.delete_cookie(SESSION_COOKIE)
+@app.post("/logout")
+def logout(request: Request) -> Response:
+ """End the WorkOS session and clear the local sealed session cookie."""
+ response = RedirectResponse(auth.get_logout_url(request), status_code=status.HTTP_303_SEE_OTHER)
+ _delete_auth_cookie(response)
return response
-def require_admin(request: Request) -> str:
- """Redirect unauthenticated users to the in-site login page."""
- username = _authenticated_user(request)
- if username is not None:
- return username
+def require_user(request: Request) -> auth.AuthSession:
+ """Redirect unauthenticated users to the WorkOS sign-in flow."""
+ current_user = auth.get_current_session(request)
+ if current_user is not None:
+ return current_user
next_path = request.url.path
if request.url.query:
next_path = f"{next_path}?{request.url.query}"
@@ -138,87 +129,64 @@ def require_admin(request: Request) -> str:
)
-def _authenticated_user(request: Request) -> str | None:
- token = request.cookies.get(SESSION_COOKIE)
- if token is None:
- return None
- try:
- username, signature = token.split(":", 1)
- except ValueError:
- return None
- if username != ADMIN_USERNAME:
- return None
- expected = _session_signature(username)
- if secrets.compare_digest(signature, expected):
- return username
- return None
+def require_admin(current_user: auth.AuthSession = Depends(require_user)) -> auth.AuthSession:
+ """Restrict a route to WorkOS users with the admin role."""
+ if current_user.is_admin:
+ return current_user
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
-def _sign_session(username: str) -> str:
- return f"{username}:{_session_signature(username)}"
-
-
-def _session_signature(username: str) -> str:
- return hmac.new(
- SESSION_SECRET.encode(),
- username.encode(),
- hashlib.sha256,
- ).hexdigest()
-
-
-def _safe_next_path(value: str | None) -> str:
- if value and value.startswith("/") and not value.startswith("//"):
- return value
- return "/"
-
-
-@app.get("/", response_class=HTMLResponse)
-def dashboard(request: Request, _: str = Depends(require_admin)) -> Response:
+@app.get("/dashboard", response_class=HTMLResponse)
+def dashboard(
+ request: Request, current_user: auth.AuthSession = Depends(require_user)
+) -> Response:
"""Render the full dashboard page."""
- context = _dashboard_context(request)
+ context = {**_auth_context(current_user), **_dashboard_context(request)}
if request.headers.get("hx-request") == "true":
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
return templates.TemplateResponse(request, "dashboard.html", context)
@app.get("/partials/dashboard", response_class=HTMLResponse)
-def dashboard_partial(request: Request, _: str = Depends(require_admin)) -> Response:
+def dashboard_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render the filter-dependent dashboard body."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
@app.get("/partials/issues", response_class=HTMLResponse)
-def issues_partial(request: Request, _: str = Depends(require_admin)) -> Response:
+def issues_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render only issue filters."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_issue_filters.html", context)
@app.get("/partials/rankings", response_class=HTMLResponse)
-def rankings_partial(request: Request, _: str = Depends(require_admin)) -> Response:
+def rankings_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render only ranking panels."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_rankings.html", context)
@app.get("/partials/chart", response_class=HTMLResponse)
-def chart_partial(request: Request, _: str = Depends(require_admin)) -> Response:
+def chart_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
"""Render only the SVG chart panel."""
context = _dashboard_context(request)
return templates.TemplateResponse(request, "partials/_chart.html", context)
@app.get("/legislators", response_class=HTMLResponse)
-def legislators(request: Request, _: str = Depends(require_admin)) -> Response:
+def legislators(
+ request: Request, current_user: auth.AuthSession = Depends(require_user)
+) -> Response:
"""Render the legislator profile/search page."""
- context = _legislators_context(request)
+ context = {**_auth_context(current_user), **_legislators_context(request)}
return templates.TemplateResponse(request, "legislators.html", context)
@app.get("/partials/legislator-suggestions", response_class=HTMLResponse)
def legislator_suggestions_partial(
- request: Request, _: str = Depends(require_admin)
+ request: Request, _: auth.AuthSession = Depends(require_user)
) -> Response:
"""Render legislator search suggestions for the HTMX typeahead."""
query = request.query_params.get("q", "").strip()
@@ -238,12 +206,29 @@ def legislator_suggestions_partial(
@app.get("/compare", response_class=HTMLResponse)
-def compare(request: Request, _: str = Depends(require_admin)) -> Response:
+def compare(
+ request: Request, current_user: auth.AuthSession = Depends(require_user)
+) -> Response:
"""Render the legislator radar comparison page."""
- context = _compare_context(request)
+ context = {**_auth_context(current_user), **_compare_context(request)}
return templates.TemplateResponse(request, "compare.html", context)
+@app.get("/admin", response_class=HTMLResponse)
+def admin_page(
+ request: Request, current_user: auth.AuthSession = Depends(require_admin)
+) -> Response:
+ """Render the admin-only placeholder page."""
+ return templates.TemplateResponse(
+ request,
+ "admin.html",
+ {
+ **_auth_context(current_user),
+ "organization_id": auth.get_auth_config().organization_id,
+ },
+ )
+
+
def _dashboard_context(request: Request) -> dict[str, Any]:
state = _parse_state(request)
base_context: dict[str, Any] = {
@@ -263,6 +248,7 @@ def _dashboard_context(request: Request) -> dict[str, Any]:
"has_scores": False,
"empty_message": "",
"build_url": _build_url,
+ "build_dashboard_partial_url": _build_dashboard_partial_url,
"toggle_compare": _toggle_compare,
}
with session_scope() as session:
@@ -520,10 +506,29 @@ def _build_url(
for legislator_id in chosen_compare:
params.append(("compare", str(legislator_id)))
if not params:
- return "/"
+ return "/dashboard"
from urllib.parse import urlencode
- return f"/?{urlencode(params, doseq=True)}"
+ return f"/dashboard?{urlencode(params, doseq=True)}"
+
+
+def _build_dashboard_partial_url(
+ request: Request,
+ *,
+ issues: list[str] | None = None,
+ chamber: str | None = None,
+ congress: int | None = None,
+ compare: list[int] | None = None,
+) -> str:
+ """Return the HTMX endpoint matching the current dashboard query state."""
+ dashboard_url = _build_url(
+ request,
+ issues=issues,
+ chamber=chamber,
+ congress=congress,
+ compare=compare,
+ )
+ return dashboard_url.replace("/dashboard", "/partials/dashboard", 1)
def _toggle_compare(compare: list[int], legislator_id: int) -> list[int]:
@@ -587,3 +592,18 @@ def _build_compare_url(
if q:
params.append(("q", q))
return f"/compare?{urlencode(params, doseq=True)}" if params else "/compare"
+
+
+def _auth_context(current_user: auth.AuthSession | None) -> dict[str, Any]:
+ """Shared template context for auth-aware navigation."""
+ return {
+ "is_authenticated": current_user is not None,
+ "is_admin": current_user.is_admin if current_user is not None else False,
+ "current_user_name": current_user.display_name if current_user is not None else "",
+ "current_user_email": current_user.email if current_user is not None else "",
+ }
+
+
+def _delete_auth_cookie(response: Response) -> None:
+ """Delete the sealed WorkOS session cookie."""
+ response.delete_cookie(getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session"))
diff --git a/pipelines/web/static/styles.css b/pipelines/web/static/styles.css
index a41d6f5..3df6488 100644
--- a/pipelines/web/static/styles.css
+++ b/pipelines/web/static/styles.css
@@ -152,16 +152,35 @@ a {
text-align: left;
}
+.account-menu-panel form {
+ margin: 0.2rem 0 0;
+}
+
+.account-email {
+ color: var(--muted);
+ display: block;
+ font-size: 0.84rem;
+ padding: 0.3rem 0.1rem 0.55rem;
+}
+
.account-menu-panel .sign-out {
background: #0d5f53;
border-color: #16806f;
color: white;
}
+.account-menu-panel .sign-out,
.account-nav .sign-in {
background: #0d5f53;
border-color: #16806f;
+ border-radius: 7px;
color: white;
+ cursor: pointer;
+ display: block;
+ font: inherit;
+ min-width: 100%;
+ padding: 0.55rem 0.8rem;
+ text-align: left;
}
.shell {
@@ -170,6 +189,140 @@ a {
padding: 1.25rem 1.5rem 2rem;
}
+.home-shell {
+ padding-top: 2rem;
+}
+
+.hero-panel,
+.home-grid,
+.admin-meta,
+.admin-actions {
+ display: grid;
+}
+
+.hero-panel {
+ gap: 1.4rem;
+ grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.85fr);
+}
+
+.hero-panel,
+.home-card,
+.admin-card {
+ background: color-mix(in srgb, var(--panel) 88%, transparent);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+}
+
+.hero-copy,
+.hero-card,
+.home-card,
+.admin-card {
+ padding: 1.5rem 1.6rem;
+}
+
+.hero-copy h1 {
+ font-size: clamp(2.3rem, 4vw, 4.15rem);
+ letter-spacing: -0.04em;
+ line-height: 0.96;
+ margin: 0.2rem 0 0.9rem;
+ max-width: 12ch;
+}
+
+.hero-text {
+ color: #c0d3cd;
+ font-size: 1.02rem;
+ line-height: 1.65;
+ margin: 0;
+ max-width: 60ch;
+}
+
+.hero-actions,
+.admin-actions {
+ gap: 0.75rem;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 1.35rem;
+}
+
+.hero-primary,
+.hero-secondary,
+.admin-actions a {
+ border-radius: 999px;
+ font-weight: 760;
+ min-height: 2.8rem;
+ padding: 0.78rem 1.15rem;
+}
+
+.hero-primary {
+ background: linear-gradient(120deg, #0d5f53, #2fbd9f);
+ color: white;
+}
+
+.hero-secondary,
+.admin-actions a {
+ border: 1px solid #1d554c;
+ color: #d0dfdb;
+}
+
+.hero-card h2,
+.home-card h2,
+.admin-card h2 {
+ margin-bottom: 0.65rem;
+}
+
+.hero-card ul {
+ color: #c0d3cd;
+ line-height: 1.6;
+ margin: 0;
+ padding-left: 1.1rem;
+}
+
+.home-grid {
+ gap: 1rem;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ margin-top: 1.25rem;
+}
+
+.home-card p,
+.admin-card p,
+.admin-meta dt {
+ color: var(--muted);
+}
+
+.auth-notice {
+ margin-bottom: 1rem;
+}
+
+.admin-card {
+ margin-top: 1.25rem;
+}
+
+.admin-meta {
+ gap: 0.85rem;
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
+ margin: 1.2rem 0 1.1rem;
+}
+
+.admin-meta div {
+ background: rgba(12, 38, 33, 0.78);
+ border: 1px solid rgba(44, 123, 109, 0.28);
+ border-radius: 10px;
+ padding: 0.9rem 1rem;
+}
+
+.admin-meta dt {
+ font-size: 0.84rem;
+ margin-bottom: 0.35rem;
+ text-transform: uppercase;
+}
+
+.admin-meta dd {
+ margin: 0;
+}
+
.login-shell {
align-items: center;
display: flex;
@@ -1122,7 +1275,9 @@ h2 {
.rankings-grid,
.topic-panels,
.compare-card,
- .login-panel {
+ .login-panel,
+ .hero-panel,
+ .home-grid {
display: block;
}
@@ -1212,4 +1367,9 @@ h2 {
margin-bottom: 1rem;
padding: 0 0 1rem;
}
+
+ .hero-card,
+ .home-card + .home-card {
+ margin-top: 1rem;
+ }
}
diff --git a/pipelines/web/templates/admin.html b/pipelines/web/templates/admin.html
new file mode 100644
index 0000000..19fcefa
--- /dev/null
+++ b/pipelines/web/templates/admin.html
@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+
+{% block title %}Admin Settings{% endblock %}
+
+{% block body %}
+ Admin-only operational controls for the Nornsight workspace.
+ Invitations, Google access, and role assignments are managed in the WorkOS dashboard.
+ This page confirms that app-level admin gating is active.
+ Admin settings
+ WorkOS-managed access
+
US legislative accountability · precomputed legislator topic scores{% if latest_score_year %} through {{ latest_score_year }}{% endif %}
Invite-only access
++ Nornsight turns roll-call data into issue-level accountability views for your invited team. + Use the public home page as the front door, then move signed-in users into the dashboard, + legislator search, and comparison tools. +
+View the dashboard, inspect legislator profiles, and compare issue scoring without sharing a local password.
+Manage invitations and role assignments in WorkOS while the app enforces role-based route access.
+Authentication is centralized, sessions are sealed, and the old hard-coded admin login is removed.
+