setup workos

This commit was merged in pull request #10.
This commit is contained in:
2026-05-02 20:57:09 -04:00
committed by Richie
parent de9e59b5f4
commit 7f2b388e7a
20 changed files with 1286 additions and 142 deletions
-21
View File
@@ -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."""
+197
View File
@@ -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()
+202
View File
@@ -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)
+122 -102
View File
@@ -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"))
+161 -1
View File
@@ -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;
}
}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Admin Settings{% endblock %}
{% block body %}
<main class="shell">
<section class="page-heading stacked-heading">
<div>
<h1>Admin settings</h1>
<p>Admin-only operational controls for the Nornsight workspace.</p>
</div>
</section>
<section class="admin-card">
<h2>WorkOS-managed access</h2>
<p>
Invitations, Google access, and role assignments are managed in the WorkOS dashboard.
This page confirms that app-level admin gating is active.
</p>
<dl class="admin-meta">
<div>
<dt>Workspace organization</dt>
<dd><code>{{ organization_id }}</code></dd>
</div>
<div>
<dt>Current administrator</dt>
<dd>{{ current_user_email }}</dd>
</div>
</dl>
<div class="admin-actions">
<a href="/dashboard">Return to dashboard</a>
<a href="https://dashboard.workos.com/" rel="noreferrer" target="_blank">Open WorkOS dashboard</a>
</div>
</section>
</main>
{% endblock %}
+15 -5
View File
@@ -15,23 +15,33 @@
</a>
{% if show_primary_nav|default(true) %}
<nav class="primary-nav" aria-label="Primary">
<a href="/">Issues</a>
{% if is_authenticated|default(false) %}
<a href="/dashboard">Dashboard</a>
<a href="/legislators">Legislators</a>
<a href="/compare">Compare</a>
{% if is_admin|default(false) %}
<a href="/admin">Admin</a>
{% endif %}
{% else %}
<a href="/">Overview</a>
{% endif %}
</nav>
{% endif %}
<nav class="account-nav" aria-label="Account">
<a href="#" aria-disabled="true">Help</a>
{% if is_authenticated|default(true) %}
{% if is_authenticated|default(false) %}
<details class="account-menu">
<summary>My account</summary>
<summary>{{ current_user_name or "My account" }}</summary>
<div class="account-menu-panel">
<span class="account-email">{{ current_user_email }}</span>
<a href="#" aria-disabled="true">Account settings</a>
<a class="sign-out" href="/logout">Sign out</a>
<form action="/logout" method="post">
<button class="sign-out" type="submit">Sign out</button>
</form>
</div>
</details>
{% else %}
<a class="sign-in" href="/login">Sign in</a>
<a class="sign-in" href="/login?next=/dashboard">Sign in</a>
{% endif %}
</nav>
</header>
+1
View File
@@ -10,6 +10,7 @@
<p>US legislative accountability · precomputed legislator topic scores{% if latest_score_year %} through {{ latest_score_year }}{% endif %}</p>
</div>
<div class="heading-actions">
<span>{{ current_user_email }}</span>
<a href="#" aria-disabled="true">Methodology</a>
<a href="#" aria-disabled="true">Data sources</a>
<span>Last updated: {{ last_updated.strftime("%b %Y") if last_updated else "Unavailable" }}</span>
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Nornsight | Legislative Accountability{% endblock %}
{% block body %}
<main class="shell home-shell">
{% if auth_error %}
<div class="notice auth-notice">Authentication failed. Try signing in again.</div>
{% endif %}
<section class="hero-panel">
<div class="hero-copy">
<p class="eyebrow">Invite-only access</p>
<h1>Track legislative behavior with role-aware access and shared WorkOS sign-in.</h1>
<p class="hero-text">
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.
</p>
<div class="hero-actions">
{% if is_authenticated %}
<a class="hero-primary" href="/dashboard">Open dashboard</a>
{% if is_admin %}
<a class="hero-secondary" href="/admin">Admin settings</a>
{% endif %}
{% else %}
<a class="hero-primary" href="/login?next=/dashboard">Sign in</a>
<a class="hero-secondary" href="#access-model">How access works</a>
{% endif %}
</div>
</div>
<aside class="hero-card">
<h2>Launch access model</h2>
<ul>
<li>Public landing page at <code>/</code></li>
<li>Invite-only AuthKit login with Email + Password and Google</li>
<li><code>viewer</code> role for dashboard, legislators, and compare</li>
<li><code>admin</code> role for settings and account administration</li>
</ul>
</aside>
</section>
<section id="access-model" class="home-grid">
<article class="home-card">
<h2>For invited users</h2>
<p>View the dashboard, inspect legislator profiles, and compare issue scoring without sharing a local password.</p>
</article>
<article class="home-card">
<h2>For admins</h2>
<p>Manage invitations and role assignments in WorkOS while the app enforces role-based route access.</p>
</article>
<article class="home-card">
<h2>For rollout</h2>
<p>Authentication is centralized, sessions are sealed, and the old hard-coded admin login is removed.</p>
</article>
</section>
</main>
{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
<header>
<h2>Score history{% if selected_issue_label %} — {{ selected_issue_label }}{% endif %}</h2>
<a href="{{ build_url(request, compare=[]) }}"
hx-get="/partials/dashboard{{ build_url(request, compare=[])|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, compare=[]) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, compare=[]) }}">Clear comparison</a>
</header>
@@ -3,17 +3,17 @@
<div class="chamber-card">
<a class="segment {{ 'active' if chamber == 'house' else '' }}"
href="{{ build_url(request, chamber='house') }}"
hx-get="/partials/dashboard{{ build_url(request, chamber='house')|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, chamber='house') }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, chamber='house') }}">House</a>
<a class="segment {{ 'active' if chamber == 'senate' else '' }}"
href="{{ build_url(request, chamber='senate') }}"
hx-get="/partials/dashboard{{ build_url(request, chamber='senate')|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, chamber='senate') }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, chamber='senate') }}">Senate</a>
<a class="segment {{ 'active' if chamber == 'all' else '' }}"
href="{{ build_url(request, chamber='all') }}"
hx-get="/partials/dashboard{{ build_url(request, chamber='all')|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, chamber='all') }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, chamber='all') }}">All</a>
</div>
@@ -2,10 +2,10 @@
<h2>Issue filters</h2>
<form class="issue-form"
method="get"
action="/"
hx-get="/"
action="/dashboard"
hx-get="/partials/dashboard"
hx-target="#dashboard-body"
hx-push-url="true">
hx-push-url="/dashboard">
<input type="hidden" name="chamber" value="{{ chamber }}">
{% if congress %}
<input type="hidden" name="congress" value="{{ congress }}">
@@ -17,7 +17,7 @@
<span class="chip">
{{ issue }}
<a href="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
hx-get="/partials/dashboard{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:])|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
aria-label="Remove {{ issue }}">×</a>
@@ -36,7 +36,7 @@
{% for suggestion in suggestions %}
{% if suggestion not in issues %}
<a href="{{ build_url(request, issues=issues + [suggestion]) }}"
hx-get="/partials/dashboard{{ build_url(request, issues=issues + [suggestion])|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, issues=issues + [suggestion]) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, issues=issues + [suggestion]) }}">{{ suggestion }}</a>
{% endif %}
@@ -10,7 +10,7 @@
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
<a href="{{ build_url(request, compare=next_compare) }}"
hx-get="/partials/dashboard{{ build_url(request, compare=next_compare)|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, compare=next_compare) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, compare=next_compare) }}">
<span class="rank">{{ loop.index }}</span>
@@ -40,7 +40,7 @@
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
<a href="{{ build_url(request, compare=next_compare) }}"
hx-get="/partials/dashboard{{ build_url(request, compare=next_compare)|replace('/', '', 1) }}"
hx-get="{{ build_dashboard_partial_url(request, compare=next_compare) }}"
hx-target="#dashboard-body"
hx-push-url="{{ build_url(request, compare=next_compare) }}">
<span class="rank">{{ loop.index }}</span>