diff --git a/CLAUDE.md b/CLAUDE.md index 1f17e78..d0e900e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,7 @@ Auth providers in `app/auth/` (FastAPI-based): ## Key Implementation Details ### DuckDB Schema (src/db.py) -- Schema v4 with auto-migration from v1→v2→v3→v4 +- Schema v7 with auto-migration from v1→v2→v3→v4→v5→v6→v7 (v5 adds `users.active`, v6 adds `personal_access_tokens`, v7 adds `personal_access_tokens.last_used_ip`) - `table_registry`: id, name, source_type, bucket, source_table, query_mode, sync_schedule, etc. - `sync_state`, `sync_history`: track extraction progress - `users`, `dataset_permissions`, `audit_log`: auth + RBAC diff --git a/Dockerfile b/Dockerfile index 977204b..4b88690 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,7 @@ FROM python:3.13-slim -# Install curl for healthcheck RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* -# Install uv for fast dependency management COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv ARG AGNES_VERSION=dev @@ -15,12 +13,13 @@ ENV AGNES_COMMIT_SHA=${AGNES_COMMIT_SHA} WORKDIR /app -# Copy application code COPY . . +# Build wheel artifact (served at /cli/download) +RUN uv build --wheel --out-dir /app/dist + # Install production dependencies from pyproject.toml RUN uv pip install --system --no-cache . -# Default: run FastAPI server EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/api/cli_artifacts.py b/app/api/cli_artifacts.py new file mode 100644 index 0000000..d61d049 --- /dev/null +++ b/app/api/cli_artifacts.py @@ -0,0 +1,140 @@ +"""CLI artifact download + install script endpoints (#9).""" + +import os +import re +import shlex +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse + +# Strict allowlists for values interpolated into the generated install.sh. +# The endpoint is unauthenticated and users `curl | bash` it, so any shell +# metacharacter leaking through the Host header or AGNES_VERSION env var +# would become RCE. `shlex.quote` is applied on top for defense in depth. +# +# Host charset allows underscores (Docker Compose hostnames) and `[` `]` `:` +# so IPv6 literals like http://[::1]:8000 pass. Optional trailing path lets +# reverse-proxy deployments (request.base_url = "https://host/agnes/") work. +# +# `\Z` (not `$`) anchors strictly to end-of-string. Python's `$` also matches +# immediately before a trailing `\n`, which would let a crafted Host header +# like "good.example.com\n$(rm -rf /)" slip past the allowlist. `\Z` closes +# that bypass — shlex.quote downstream is still defense-in-depth. +_SAFE_URL_RE = re.compile(r"^https?://[A-Za-z0-9._\-\[\]:]+(:\d+)?(/[A-Za-z0-9._\-/]*)?\Z") +_SAFE_VERSION_RE = re.compile(r"^[A-Za-z0-9._\-]+\Z") + +router = APIRouter(tags=["cli"]) + + +def _dist_dir() -> Path: + return Path(os.environ.get("AGNES_CLI_DIST_DIR", "/app/dist")) + + +def _find_wheel() -> Path | None: + d = _dist_dir() + if not d.exists(): + return None + wheels = sorted(d.glob("*.whl")) + return wheels[-1] if wheels else None + + +@router.get("/cli/download") +async def cli_download(): + wheel = _find_wheel() + if not wheel: + raise HTTPException( + status_code=404, + detail=( + "CLI wheel not found in dist dir. Build it with `uv build --wheel` " + "or run the official docker image (which builds on image-build)." + ), + ) + return FileResponse( + path=str(wheel), + filename=wheel.name, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{wheel.name}"'}, + ) + + +@router.get("/cli/agnes.whl") +async def cli_wheel_stable(): + """Stable `.whl` URL alias so `uv tool install /cli/agnes.whl` works. + + `uv tool install` inspects the URL path to decide how to treat the resource + and only accepts it as a wheel when the path ends in `.whl`. The existing + `/cli/download` path does not, which forces users through a multi-step + curl + tmpfile + install + rm dance. This alias collapses that into a + single `uv tool install` invocation. + """ + wheel = _find_wheel() + if not wheel: + raise HTTPException( + status_code=404, + detail=( + "CLI wheel not found in dist dir. Build it with `uv build --wheel` " + "or run the official docker image (which builds on image-build)." + ), + ) + return FileResponse( + path=str(wheel), + filename=wheel.name, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{wheel.name}"'}, + ) + + +@router.get("/cli/install.sh", response_class=PlainTextResponse) +async def cli_install_script(request: Request): + """Shell installer — bakes this server's URL into the generated config.""" + base_url = str(request.base_url).rstrip("/") + if not _SAFE_URL_RE.match(base_url): + raise HTTPException(status_code=400, detail="Unexpected server URL format") + version = os.environ.get("AGNES_VERSION", "dev") + if not _SAFE_VERSION_RE.match(version): + version = "dev" + # shlex.quote hardens against anything that slipped past the regex + server_q = shlex.quote(base_url) + version_q = shlex.quote(version) + script = f"""#!/usr/bin/env bash +# Agnes CLI installer — server: {base_url} +set -euo pipefail + +SERVER={server_q} +echo "Installing Agnes CLI from $SERVER (version: {version_q})" + +# 1. Download the wheel +# Portable mktemp: X's must be at the end of the template on both GNU and BSD/macOS. +TMPDIR_WHEEL=$(mktemp -d -t agnes_cli.XXXXXX) +trap 'rm -rf "$TMPDIR_WHEEL"' EXIT +# Use -OJ so curl honours Content-Disposition and saves the wheel with its real +# PEP-427 filename (pip / uv tool install reject filenames without a version). +(cd "$TMPDIR_WHEEL" && curl -fsSL -OJ "$SERVER/cli/download") +WHEEL=$(ls "$TMPDIR_WHEEL"/*.whl 2>/dev/null | head -n1) +if [ -z "$WHEEL" ]; then + echo "error: wheel download failed (no .whl found in $TMPDIR_WHEEL)" >&2 + exit 1 +fi + +# 2. Install via pip (prefer uv tool install if available) +if command -v uv >/dev/null 2>&1; then + uv tool install --force "$WHEEL" +else + python3 -m pip install --user --force-reinstall "$WHEEL" +fi + +# 3. Seed the server URL in CLI config +CFG_DIR="${{DA_CONFIG_DIR:-$HOME/.config/da}}" +mkdir -p "$CFG_DIR" +cat > "$CFG_DIR/config.yaml" <" +echo " 3. Verify: da auth whoami" +""" + return script diff --git a/app/api/tokens.py b/app/api/tokens.py new file mode 100644 index 0000000..7899453 --- /dev/null +++ b/app/api/tokens.py @@ -0,0 +1,197 @@ +"""Personal access token endpoints (#12).""" + +import hashlib +import secrets +import uuid +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +import duckdb +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.auth.dependencies import require_session_token, require_role, get_current_user, _get_db +from src.rbac import Role +from src.repositories.access_tokens import AccessTokenRepository +from src.repositories.audit import AuditRepository +from app.auth.jwt import create_access_token + +router = APIRouter(prefix="/auth/tokens", tags=["tokens"]) +admin_router = APIRouter(prefix="/auth/admin/tokens", tags=["tokens-admin"]) + + +class CreateTokenRequest(BaseModel): + name: str + expires_in_days: Optional[int] = 90 # null = no expiry + + +class CreateTokenResponse(BaseModel): + id: str + name: str + prefix: str + token: str # raw token — returned exactly once + expires_at: Optional[str] + created_at: str + + +class TokenListItem(BaseModel): + id: str + name: str + prefix: str + created_at: str + expires_at: Optional[str] + last_used_at: Optional[str] + revoked_at: Optional[str] + + +class AdminTokenItem(TokenListItem): + """Admin list row: adds owner identity + last IP for incident response.""" + user_id: str + user_email: Optional[str] = None + last_used_ip: Optional[str] = None + + +def _audit(conn, actor: str, action: str, target: str, params=None): + try: + AuditRepository(conn).log(user_id=actor, action=action, + resource=f"token:{target}", params=params) + except Exception: + pass + + +def _row_to_item(row: dict) -> TokenListItem: + return TokenListItem( + id=row["id"], name=row["name"], prefix=row["prefix"], + created_at=str(row.get("created_at") or ""), + expires_at=str(row["expires_at"]) if row.get("expires_at") else None, + last_used_at=str(row["last_used_at"]) if row.get("last_used_at") else None, + revoked_at=str(row["revoked_at"]) if row.get("revoked_at") else None, + ) + + +def _row_to_admin_item(row: dict) -> AdminTokenItem: + return AdminTokenItem( + id=row["id"], name=row["name"], prefix=row["prefix"], + created_at=str(row.get("created_at") or ""), + expires_at=str(row["expires_at"]) if row.get("expires_at") else None, + last_used_at=str(row["last_used_at"]) if row.get("last_used_at") else None, + revoked_at=str(row["revoked_at"]) if row.get("revoked_at") else None, + user_id=row.get("user_id") or "", + user_email=row.get("user_email"), + last_used_ip=row.get("last_used_ip"), + ) + + +@router.post("", response_model=CreateTokenResponse, status_code=201) +async def create_token( + payload: CreateTokenRequest, + user: dict = Depends(require_session_token), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + if not payload.name.strip(): + raise HTTPException(status_code=400, detail="name is required") + if payload.expires_in_days is not None and payload.expires_in_days <= 0: + raise HTTPException(status_code=400, detail="expires_in_days must be a positive integer") + # Cap at 10 years — larger values overflow datetime.max during the + # `datetime.now(utc) + timedelta(days=...)` addition and surface as an + # unhandled OverflowError → 500. 10y is well past any legitimate PAT + # lifetime (the no-expiry path below uses ~100y and doesn't compute + # expires_at on the datetime object). + if payload.expires_in_days is not None and payload.expires_in_days > 3650: + raise HTTPException(status_code=400, detail="expires_in_days must not exceed 3650 (10 years)") + repo = AccessTokenRepository(conn) + token_id = str(uuid.uuid4()) + expires_at: Optional[datetime] = None + expires_delta: Optional[timedelta] = None + omit_exp = payload.expires_in_days is None + if payload.expires_in_days is not None: + expires_delta = timedelta(days=payload.expires_in_days) + expires_at = datetime.now(timezone.utc) + expires_delta + # else: "no expiry" — DB stores expires_at=NULL and the JWT carries no + # `exp` claim. The authoritative expiry check lives in + # app/auth/dependencies.py (via the DB row). + # Build the JWT that embeds jti=token_id and typ=pat + jwt_token = create_access_token( + user_id=user["id"], email=user["email"], role=user["role"], + token_id=token_id, typ="pat", + expires_delta=expires_delta, omit_exp=omit_exp, + ) + # Prefix: first 8 chars of the jti (UUID) — uniquely identifies the token in UI + # without exposing JWT headers (which all start with "eyJhbGci…" and are useless + # for identification). The JWT itself is returned ONCE in the response body. + prefix = token_id.replace("-", "")[:8] + # token_hash = sha256(raw JWT). Used in verify_token as defense-in-depth. + token_hash = hashlib.sha256(jwt_token.encode()).hexdigest() + repo.create( + id=token_id, user_id=user["id"], name=payload.name.strip(), + token_hash=token_hash, prefix=prefix, expires_at=expires_at, + ) + _audit(conn, user["id"], "token.create", token_id, {"name": payload.name}) + return CreateTokenResponse( + id=token_id, name=payload.name.strip(), prefix=prefix, + token=jwt_token, # returned EXACTLY ONCE; never retrievable again + expires_at=str(expires_at) if expires_at else None, + created_at=str(datetime.now(timezone.utc)), + ) + + +@router.get("", response_model=List[TokenListItem]) +async def list_tokens( + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + # PATs may list their owner's own tokens — required by the documented + # `da auth token list` CLI flow (HEADLESS_USAGE.md). Only `create_token` + # is session-only (to block PAT-spawning-PAT chains). + rows = AccessTokenRepository(conn).list_for_user(user["id"]) + return [_row_to_item(r) for r in rows] + + +@router.get("/{token_id}", response_model=TokenListItem) +async def get_token( + token_id: str, + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + row = AccessTokenRepository(conn).get_by_id(token_id) + if not row or row["user_id"] != user["id"]: + raise HTTPException(status_code=404, detail="Token not found") + return _row_to_item(row) + + +@router.delete("/{token_id}", status_code=204) +async def revoke_token( + token_id: str, + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + repo = AccessTokenRepository(conn) + row = repo.get_by_id(token_id) + if not row or row["user_id"] != user["id"]: + raise HTTPException(status_code=404, detail="Token not found") + repo.revoke(token_id) + _audit(conn, user["id"], "token.revoke", token_id) + + +# Admin — list & revoke tokens across users (for incident response) + +@admin_router.get("", response_model=List[AdminTokenItem]) +async def admin_list_tokens( + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + return [_row_to_admin_item(r) for r in AccessTokenRepository(conn).list_all_with_user()] + + +@admin_router.delete("/{token_id}", status_code=204) +async def admin_revoke_token( + token_id: str, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + repo = AccessTokenRepository(conn) + row = repo.get_by_id(token_id) + if not row: + raise HTTPException(status_code=404, detail="Token not found") + repo.revoke(token_id) + _audit(conn, user["id"], "token.admin_revoke", token_id, {"owner_id": row["user_id"]}) diff --git a/app/api/users.py b/app/api/users.py index 1bfae1f..79ac8d7 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -1,19 +1,42 @@ -"""User management endpoints.""" +"""User management endpoints (#11).""" import uuid - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from datetime import datetime, timezone from typing import Optional, List import duckdb +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from argon2 import PasswordHasher from app.auth.dependencies import require_role, Role, _get_db from src.repositories.users import UserRepository +from src.repositories.audit import AuditRepository router = APIRouter(prefix="/api/users", tags=["users"]) +def _audit(conn: duckdb.DuckDBPyConnection, actor_id: str, action: str, target_id: str, params: Optional[dict] = None) -> None: + try: + # Convert non-JSON-serializable values (datetime) to strings first + safe_params = None + if params: + safe_params = {} + for k, v in params.items(): + if isinstance(v, datetime): + safe_params[k] = v.isoformat() + else: + safe_params[k] = v + AuditRepository(conn).log( + user_id=actor_id, + action=action, + resource=f"user:{target_id}", + params=safe_params, + ) + except Exception: + pass # never block the endpoint on audit failure + + class CreateUserRequest(BaseModel): email: str name: str @@ -23,6 +46,11 @@ class CreateUserRequest(BaseModel): class UpdateUserRequest(BaseModel): name: Optional[str] = None role: Optional[str] = None + active: Optional[bool] = None + + +class SetPasswordRequest(BaseModel): + password: str class UserResponse(BaseModel): @@ -30,7 +58,21 @@ class UserResponse(BaseModel): email: str name: Optional[str] role: str + active: bool = True created_at: Optional[str] + deactivated_at: Optional[str] = None + + +def _to_response(u: dict) -> UserResponse: + return UserResponse( + id=u["id"], + email=u["email"], + name=u.get("name"), + role=u["role"], + active=bool(u.get("active", True)), + created_at=str(u.get("created_at", "")), + deactivated_at=str(u["deactivated_at"]) if u.get("deactivated_at") else None, + ) @router.get("", response_model=List[UserResponse]) @@ -38,37 +80,177 @@ async def list_users( user: dict = Depends(require_role(Role.ADMIN)), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): - repo = UserRepository(conn) - users = repo.list_all() - return [ - UserResponse( - id=u["id"], email=u["email"], name=u.get("name"), - role=u["role"], created_at=str(u.get("created_at", "")), - ) for u in users - ] + return [_to_response(u) for u in UserRepository(conn).list_all()] @router.post("", response_model=UserResponse, status_code=201) async def create_user( - request: CreateUserRequest, + payload: CreateUserRequest, + request: Request, user: dict = Depends(require_role(Role.ADMIN)), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): repo = UserRepository(conn) - if repo.get_by_email(request.email): + if repo.get_by_email(payload.email): raise HTTPException(status_code=409, detail="User with this email already exists") + try: + Role(payload.role) + except ValueError: + raise HTTPException(status_code=400, detail=f"Unknown role: {payload.role}") user_id = str(uuid.uuid4()) - repo.create(id=user_id, email=request.email, name=request.name, role=request.role) - return UserResponse(id=user_id, email=request.email, name=request.name, role=request.role, created_at=None) + repo.create(id=user_id, email=payload.email, name=payload.name, role=payload.role) + _audit(conn, user["id"], "user.create", user_id, {"email": payload.email, "role": payload.role}) + created = repo.get_by_id(user_id) + return _to_response(created) + + +@router.patch("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: str, + payload: UpdateUserRequest, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + repo = UserRepository(conn) + target = repo.get_by_id(user_id) + if not target: + raise HTTPException(status_code=404, detail="User not found") + + updates: dict = {} + if payload.name is not None: + updates["name"] = payload.name + if payload.role is not None: + # Validate role is a known value + try: + Role(payload.role) + except ValueError: + raise HTTPException(status_code=400, detail=f"Unknown role: {payload.role}") + # Protect: don't let admin demote themselves if they are the last admin + if ( + target["id"] == user["id"] + and target["role"] == "admin" + and payload.role != "admin" + and repo.count_admins(active_only=True) <= 1 + ): + raise HTTPException(status_code=409, detail="Cannot demote the last active admin") + updates["role"] = payload.role + if payload.active is not None: + # Protect: cannot self-deactivate + if target["id"] == user["id"] and payload.active is False: + raise HTTPException(status_code=409, detail="Cannot deactivate yourself") + # Protect: cannot deactivate the last active admin + if ( + target.get("role") == "admin" + and payload.active is False + and repo.count_admins(active_only=True) <= 1 + ): + raise HTTPException(status_code=409, detail="Cannot deactivate the last active admin") + updates["active"] = payload.active + if payload.active is False: + updates["deactivated_at"] = datetime.now(timezone.utc) + updates["deactivated_by"] = user["id"] + else: + updates["deactivated_at"] = None + updates["deactivated_by"] = None + + if updates: + repo.update(id=user_id, **updates) + _audit(conn, user["id"], "user.update", user_id, {k: v for k, v in updates.items() if k != "deactivated_at"}) + return _to_response(repo.get_by_id(user_id)) @router.delete("/{user_id}", status_code=204) async def delete_user( user_id: str, + request: Request, user: dict = Depends(require_role(Role.ADMIN)), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): repo = UserRepository(conn) - if not repo.get_by_id(user_id): + target = repo.get_by_id(user_id) + if not target: raise HTTPException(status_code=404, detail="User not found") + if target["id"] == user["id"]: + raise HTTPException(status_code=409, detail="Cannot delete yourself") + if target.get("role") == "admin" and repo.count_admins(active_only=True) <= 1: + raise HTTPException(status_code=409, detail="Cannot delete the last active admin") repo.delete(user_id) + _audit(conn, user["id"], "user.delete", user_id, {"email": target["email"]}) + + +@router.post("/{user_id}/reset-password") +async def reset_password( + user_id: str, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Generate a reset token and (best-effort) email it to the user.""" + import secrets + repo = UserRepository(conn) + target = repo.get_by_id(user_id) + if not target: + raise HTTPException(status_code=404, detail="User not found") + token = secrets.token_urlsafe(32) + repo.update( + id=user_id, + reset_token=token, + reset_token_created=datetime.now(timezone.utc), + ) + _audit(conn, user["id"], "user.reset_password", user_id, {"email": target["email"]}) + # Intentionally do NOT auto-send an email. The magic-link sender + # (`app/auth/providers/email.py:_send_email`) would deliver a "Login Link" + # that — when clicked — consumes the reset_token via verify_magic_link and + # logs the user in WITHOUT prompting for a new password, defeating the + # reset. Until a dedicated password-reset email flow with its own token + # column exists, admins share the `reset_token` below manually (or use the + # `set-password` endpoint directly). + return {"reset_token": token, "email_sent": False} + + +@router.post("/{user_id}/set-password", status_code=204) +async def set_password( + user_id: str, + payload: SetPasswordRequest, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + if not payload.password or len(payload.password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + repo = UserRepository(conn) + target = repo.get_by_id(user_id) + if not target: + raise HTTPException(status_code=404, detail="User not found") + ph = PasswordHasher() + repo.update(id=user_id, password_hash=ph.hash(payload.password)) + _audit(conn, user["id"], "user.set_password", user_id, {"email": target["email"]}) + + +@router.post("/{user_id}/deactivate", response_model=UserResponse) +async def deactivate_user( + user_id: str, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + return await update_user( + user_id=user_id, + payload=UpdateUserRequest(active=False), + request=request, user=user, conn=conn, + ) + + +@router.post("/{user_id}/activate", response_model=UserResponse) +async def activate_user( + user_id: str, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + return await update_user( + user_id=user_id, + payload=UpdateUserRequest(active=True), + request=request, user=user, conn=conn, + ) diff --git a/app/auth/_common.py b/app/auth/_common.py new file mode 100644 index 0000000..219e114 --- /dev/null +++ b/app/auth/_common.py @@ -0,0 +1,24 @@ +"""Shared helpers for auth providers (Google OAuth, password, email link). + +Kept out of `dependencies.py` so it doesn't pull FastAPI auth machinery into +thin provider modules that only need the sanitizer. +""" + +from typing import Optional + + +def safe_next_path(candidate: Optional[str], default: str = "/dashboard") -> str: + """Return `candidate` if it's a same-origin absolute path, else `default`. + + Open-redirect guard: must start with a single `/` and must NOT start with + `//` (which browsers treat as protocol-relative, i.e. cross-origin). + Accepts plain paths like `/catalog` or `/foo?bar=baz`. Rejects + `javascript:...`, `http://...`, `//evil/`, bare `dashboard`, empty/None, etc. + """ + if not candidate or not isinstance(candidate, str): + return default + if not candidate.startswith("/"): + return default + if candidate.startswith("//"): + return default + return candidate diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 22101fc..421c7ff 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -19,6 +19,26 @@ def _get_db(): conn.close() +def _client_ip(request: Optional[Request]) -> Optional[str]: + """Return the request's client IP, preferring the first hop of X-Forwarded-For. + + Trust model: this deployment runs behind Caddy (see repo Caddyfile), which + strips incoming X-Forwarded-For and sets its own. The leftmost hop is + therefore trustworthy. If the app is ever exposed directly to the internet + without a proxy, this value becomes client-settable and should only be + relied on for audit/diagnostics, never access control. Value is stored in + personal_access_tokens.last_used_ip and audit_log entries — informational + only, never authorization. + """ + if request is None: + return None + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",", 1)[0].strip() or None + client = getattr(request, "client", None) + return getattr(client, "host", None) if client else None + + async def get_current_user( request: Request = None, authorization: Optional[str] = Header(None), @@ -54,6 +74,69 @@ async def get_current_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", ) + if not bool(user.get("active", True)): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Account deactivated", + ) + + # PAT validation: check it's not revoked / expired / unknown in DB. + if payload.get("typ") == "pat": + from datetime import datetime, timezone + import hashlib + from src.repositories.access_tokens import AccessTokenRepository + + def _fail(detail: str) -> None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=detail + ) + + tokens_repo = AccessTokenRepository(conn) + record = tokens_repo.get_by_id(payload.get("jti", "")) + if not record: + _fail("Token unknown") + if record.get("revoked_at") is not None: + _fail("Token revoked") + exp_at = record.get("expires_at") + if exp_at is not None: + if isinstance(exp_at, str): + exp_at = datetime.fromisoformat(exp_at) + if exp_at.tzinfo is None: + exp_at = exp_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) > exp_at: + _fail("Token expired") + # Defense-in-depth: stored token_hash must match sha256(bearer JWT). + # Protects against a forged-but-unrevoked JWT using a stolen key. + stored_hash = record.get("token_hash") + if stored_hash: + actual = hashlib.sha256(token.encode()).hexdigest() + if actual != stored_hash: + _fail("Token mismatch") + + # First-use-from-new-IP audit entry (#12 acceptance criterion). + # Only emit when the IP changes on a *subsequent* use — the very + # first use of a token is not surprising and doesn't need an entry. + current_ip = _client_ip(request) + previous_ip = record.get("last_used_ip") + already_used = record.get("last_used_at") is not None + if already_used and current_ip and current_ip != previous_ip: + try: + from src.repositories.audit import AuditRepository + AuditRepository(conn).log( + user_id=user["id"], + action="token.first_use_new_ip", + resource=f"token:{payload['jti']}", + params={"ip": current_ip, "previous_ip": previous_ip}, + ) + except Exception: + pass # audit failure must not block auth + + # Record last_used_at / last_used_ip synchronously — acceptable cost; can batch later. + try: + tokens_repo.mark_used(payload["jti"], ip=current_ip) + except Exception: + pass + return user @@ -90,3 +173,23 @@ async def require_admin(user: dict = Depends(get_current_user)) -> dict: detail="Admin access required", ) return user + + +async def require_session_token(request: Request, user: dict = Depends(get_current_user)) -> dict: + """Like get_current_user but rejects PAT — for endpoints that must not + be callable via a long-lived CI token (e.g. creating new tokens, changing password).""" + auth = request.headers.get("authorization", "") + token = None + if auth.startswith("Bearer "): + token = auth.removeprefix("Bearer ") + if not token and request: + token = request.cookies.get("access_token") + if token: + from app.auth.jwt import verify_token + payload = verify_token(token) or {} + if payload.get("typ") == "pat": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This endpoint requires an interactive session, not a PAT", + ) + return user diff --git a/app/auth/jwt.py b/app/auth/jwt.py index 2816c64..c6ac969 100644 --- a/app/auth/jwt.py +++ b/app/auth/jwt.py @@ -48,18 +48,30 @@ def create_access_token( email: str, role: str = "analyst", expires_delta: Optional[timedelta] = None, + token_id: Optional[str] = None, + typ: str = "session", + omit_exp: bool = False, ) -> str: - expire = datetime.now(timezone.utc) + ( - expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) - ) + """Create a JWT. `typ` is "session" (interactive login) or "pat" (long-lived). + + If `omit_exp=True`, no `exp` claim is embedded. This is used by PATs with + "no expiry" — the authoritative expiry check is the DB row in + `personal_access_tokens.expires_at`, and a claim-less JWT avoids the + misleading ~100y horizon that previously pretended to be "never". + """ payload = { "sub": user_id, "email": email, "role": role, - "exp": expire, + "typ": typ, "iat": datetime.now(timezone.utc), - "jti": uuid.uuid4().hex, + "jti": token_id or uuid.uuid4().hex, } + if not omit_exp: + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + ) + payload["exp"] = expire return jwt.encode(payload, _get_cached_secret_key(), algorithm=ALGORITHM) diff --git a/app/auth/providers/google.py b/app/auth/providers/google.py index 45e019d..9d5b86e 100644 --- a/app/auth/providers/google.py +++ b/app/auth/providers/google.py @@ -9,6 +9,7 @@ from fastapi.responses import RedirectResponse from starlette.config import Config as StarletteConfig from app.auth.jwt import create_access_token +from app.auth._common import safe_next_path from app.instance_config import get_allowed_domains logger = logging.getLogger(__name__) @@ -42,9 +43,21 @@ _setup_oauth() @router.get("/login") async def google_login(request: Request): - """Redirect to Google OAuth.""" + """Redirect to Google OAuth. + + Honors `?next=` by stashing the sanitized value in the session so the + callback can redirect there instead of the default /dashboard. The session + is the right stash — OAuth flow is stateful and the `state` param is + managed by Authlib. + """ if not is_available(): return RedirectResponse(url="/login?error=google_not_configured") + next_path = safe_next_path(request.query_params.get("next"), default="") + if next_path: + request.session["login_next"] = next_path + else: + # Clear any stale value from an earlier aborted attempt. + request.session.pop("login_next", None) redirect_uri = str(request.url_for("google_callback")) return await oauth.google.authorize_redirect(request, redirect_uri) @@ -84,15 +97,23 @@ async def google_callback(request: Request): user_id = str(uuid.uuid4()) repo.create(id=user_id, email=email, name=name, role="analyst") user = repo.get_by_email(email) + if not bool(user.get("active", True)): + return RedirectResponse(url="/login?error=deactivated") finally: conn.close() # Issue JWT jwt_token = create_access_token(user["id"], user["email"], user["role"]) - # Redirect to dashboard with token in cookie + # Redirect to the post-login target. Prefer the value stashed by + # google_login() — re-sanitize defensively in case of session tampering. + target = safe_next_path( + request.session.pop("login_next", None), default="/dashboard" + ) + + # Redirect to target with token in cookie is_production = os.environ.get("TESTING", "").lower() not in ("1", "true") - response = RedirectResponse(url="/dashboard", status_code=302) + response = RedirectResponse(url=target, status_code=302) response.set_cookie( key="access_token", value=jwt_token, httponly=True, max_age=86400, samesite="lax", diff --git a/app/auth/providers/password.py b/app/auth/providers/password.py index 4437055..8a976e8 100644 --- a/app/auth/providers/password.py +++ b/app/auth/providers/password.py @@ -43,6 +43,8 @@ async def password_login( user = repo.get_by_email(request.email) if not user or not user.get("password_hash"): raise HTTPException(status_code=401, detail="Invalid email or password") + if not bool(user.get("active", True)): + raise HTTPException(status_code=401, detail="Account deactivated") # Verify password try: @@ -62,25 +64,37 @@ async def password_login( async def password_login_web( email: str = Form(...), password: str = Form(""), + next: str = Form(""), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): - """Web form login — sets cookie and redirects to dashboard.""" + """Web form login — sets cookie and redirects to `next` (or /dashboard).""" repo = UserRepository(conn) user = repo.get_by_email(email) if not user or not user.get("password_hash"): return RedirectResponse(url="/login/password?error=invalid", status_code=302) + if not bool(user.get("active", True)): + return RedirectResponse(url="/login/password?error=deactivated", status_code=302) try: ph = PasswordHasher() ph.verify(user["password_hash"], password) - except (VerifyMismatchError, Exception): + except VerifyMismatchError: + # Genuinely wrong password → usual UX. return RedirectResponse(url="/login/password?error=invalid", status_code=302) + except Exception: + # Corrupted hash / library error → surface a distinct error code so ops + # can tell broken-hash cases apart from bad-password cases. Log loudly. + logger.exception("Unexpected error during web password verification for %s", email) + return RedirectResponse(url="/login/password?err=auth_internal", status_code=302) token = create_access_token(user["id"], user["email"], user["role"]) # Secure cookie only over HTTPS (detect via X-Forwarded-Proto or request scheme) # For dev/staging on plain HTTP, secure=False so the cookie is actually sent use_secure = os.environ.get("DOMAIN", "") != "" # DOMAIN set = production with TLS - response = RedirectResponse(url="/dashboard", status_code=302) + + # Sanitize `next`: must start with `/` and must not start with `//` (open-redirect guard) + target = next if (next.startswith("/") and not next.startswith("//")) else "/dashboard" + response = RedirectResponse(url=target, status_code=302) response.set_cookie( key="access_token", value=token, httponly=True, max_age=86400, samesite="lax", diff --git a/app/auth/router.py b/app/auth/router.py index 99a76ea..8fac00b 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -65,6 +65,9 @@ async def create_token( user = repo.get_by_email(request.email) if not user: raise HTTPException(status_code=401, detail="User not found") + if not bool(user.get("active", True)): + _audit(user["id"], "login_failed", result="deactivated") + raise HTTPException(status_code=401, detail="Account deactivated") # If user has password_hash, require and verify it if user.get("password_hash"): diff --git a/app/main.py b/app/main.py index 1753343..dd92196 100644 --- a/app/main.py +++ b/app/main.py @@ -3,12 +3,15 @@ import logging from contextlib import asynccontextmanager from pathlib import Path +from urllib.parse import quote import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware from app.auth.router import router as auth_router @@ -30,6 +33,8 @@ from app.api.jira_webhooks import router as jira_webhooks_router from app.api.metrics import router as metrics_router from app.api.metadata import router as metadata_router from app.api.query_hybrid import router as query_hybrid_router +from app.api.cli_artifacts import router as cli_artifacts_router +from app.api.tokens import router as tokens_router, admin_router as tokens_admin_router from app.web.router import router as web_router logger = logging.getLogger(__name__) @@ -157,10 +162,33 @@ def create_app() -> FastAPI: app.include_router(metrics_router) app.include_router(metadata_router) app.include_router(query_hybrid_router) + app.include_router(cli_artifacts_router) + app.include_router(tokens_router) + app.include_router(tokens_admin_router) # Web UI router (must be last — has catch-all routes) app.include_router(web_router) + @app.exception_handler(StarletteHTTPException) + async def _html_auth_redirect_handler(request, exc: StarletteHTTPException): + """Redirect unauthenticated HTML page loads (GET) to /login. + + Only GET requests outside `/api/` and `/auth/` are redirected — that + targets browser navigations to HTML pages. POSTs, API prefixes, and + non-401 errors fall through to Starlette's default JSON response so + JSON clients (including `/auth/tokens` for PAT CRUD) keep their + existing contract. + """ + if ( + exc.status_code == 401 + and request.method == "GET" + and not request.url.path.startswith(("/api/", "/auth/")) + ): + next_param = quote(request.url.path, safe="") + return RedirectResponse(url=f"/login?next={next_param}", status_code=302) + from fastapi.exception_handlers import http_exception_handler + return await http_exception_handler(request, exc) + return app diff --git a/app/web/router.py b/app/web/router.py index 08f4e7c..84d0e0a 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -8,6 +8,7 @@ import os from datetime import datetime from pathlib import Path from typing import Optional +from urllib.parse import quote from fastapi import APIRouter, Depends, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse @@ -152,6 +153,11 @@ def _build_context(request: Request, user: Optional[dict] = None, **extra) -> di return {k: v for k, v in theme.items() if v} return {} + # Lines + server_url for the "Setup a new Claude Code" preview/clipboard + # partial; single source of truth lives in app/web/setup_instructions.py. + from app.web.setup_instructions import SETUP_INSTRUCTIONS_LINES + ctx_server_url = str(request.base_url).rstrip("/") + ctx = { "request": request, "config": ConfigProxy, @@ -162,8 +168,11 @@ def _build_context(request: Request, user: Optional[dict] = None, **extra) -> di "get_flashed_messages": lambda **kwargs: [], "url_for": lambda endpoint, **kw: _url_for_shim(endpoint, **kw), "session": _FlexDict({"user": user}) if user else _FlexDict(), + "setup_instructions_lines": SETUP_INSTRUCTIONS_LINES, + "server_url": ctx_server_url, } # Flex all extra context values for template compatibility + # (but skip ones we just populated — extras with the same key win) for k, v in extra.items(): ctx[k] = _flex(v) if isinstance(v, (dict, list)) else v return ctx @@ -192,6 +201,10 @@ async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depen @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): + next_path = request.query_params.get("next", "") + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "" + providers = [] try: from app.auth.providers.google import is_available as google_available @@ -211,39 +224,54 @@ async def login_page(request: Request): login_buttons = [] for p in providers: if p["name"] == "google": - login_buttons.append({"url": "/auth/google/login", "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""}) + _url = "/auth/google/login" + if next_path: + _url += f"?next={quote(next_path, safe='')}" + login_buttons.append({"url": _url, "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""}) elif p["name"] == "password": - login_buttons.append({"url": "/login/password", "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""}) + _url = "/login/password" + if next_path: + _url += f"?next={quote(next_path, safe='')}" + login_buttons.append({"url": _url, "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""}) elif p["name"] == "email": - login_buttons.append({"url": "/login/email", "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""}) + _url = "/login/email" + if next_path: + _url += f"?next={quote(next_path, safe='')}" + login_buttons.append({"url": _url, "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""}) - ctx = _build_context(request, providers=providers, login_buttons=login_buttons) + ctx = _build_context(request, providers=providers, login_buttons=login_buttons, next_path=next_path) return templates.TemplateResponse(request, "login.html", ctx) @router.get("/login/password", response_class=HTMLResponse) async def login_password_page(request: Request): """Password login form (email + password).""" + next_path = request.query_params.get("next", "") + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass - ctx = _build_context(request, google_available=google_ok) + ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @router.get("/login/email", response_class=HTMLResponse) async def login_email_page(request: Request): """Email magic link login form.""" + next_path = request.query_params.get("next", "") + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass - ctx = _build_context(request, google_available=google_ok) + ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @@ -288,7 +316,6 @@ async def dashboard( account_status="active", account_details=None, telegram_status={"linked": False}, - setup_instructions="Use 'da login' to connect your CLI tool.", data_stats={ "tables": total_tables, "total_tables": total_tables, @@ -495,6 +522,22 @@ async def activity_center( return templates.TemplateResponse(request, "activity_center.html", ctx) +@router.get("/install", response_class=HTMLResponse) +async def install_page( + request: Request, + user: Optional[dict] = Depends(get_optional_user), +): + """Public install instructions for the CLI.""" + base_url = str(request.base_url).rstrip("/") + ctx = _build_context( + request, + user=user, + server_url=base_url, + agnes_version=os.environ.get("AGNES_VERSION", "dev"), + ) + return templates.TemplateResponse(request, "install.html", ctx) + + @router.get("/admin/tables", response_class=HTMLResponse) async def admin_tables( request: Request, @@ -517,3 +560,48 @@ async def admin_permissions_page( """Admin page for managing permissions and access requests.""" ctx = _build_context(request, user=user) return templates.TemplateResponse(request, "admin_permissions.html", ctx) + + +@router.get("/admin/users", response_class=HTMLResponse) +async def admin_users_page( + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), +): + """Admin page for user management.""" + ctx = _build_context(request, user=user) + return templates.TemplateResponse(request, "admin_users.html", ctx) + + +@router.get("/tokens", response_class=HTMLResponse) +async def my_tokens_page( + request: Request, + user: dict = Depends(get_current_user), +): + """My tokens — ANY signed-in user (incl. admins' own). + + Always shows the user's own PATs. Create + reveal + revoke-own flow. + Admins who need the org-wide view go to /admin/tokens. + """ + ctx = _build_context(request, user=user) + return templates.TemplateResponse(request, "my_tokens.html", ctx) + + +@router.get("/admin/tokens", response_class=HTMLResponse) +async def admin_tokens_page( + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), +): + """Admin — list of ALL tokens for incident response + offboarding. + + Admin-only. No create form here (admins mint their own PATs via /tokens). + URL param ?user= pre-fills the owner filter (deep-link from + /admin/users "Tokens" action). + """ + ctx = _build_context(request, user=user) + return templates.TemplateResponse(request, "admin_tokens.html", ctx) + + +@router.get("/profile") +async def profile_redirect(request: Request): + """Back-compat: /profile (PAT CRUD) has been unified under /tokens.""" + return RedirectResponse(url="/tokens", status_code=302) diff --git a/app/web/setup_instructions.py b/app/web/setup_instructions.py new file mode 100644 index 0000000..bedb9e0 --- /dev/null +++ b/app/web/setup_instructions.py @@ -0,0 +1,79 @@ +"""Single source of truth for the "Setup a new Claude Code" clipboard payload. + +Both the JS-embedded clipboard renderer (`_claude_setup_instructions.jinja`) +and the read-only HTML preview on the dashboard and /install pages consume +these lines. Keep it in Python so there is exactly ONE place that edits. + +Placeholders `{server_url}` and `{token}` are substituted at render time. +For the preview we substitute `{token}` with a user-visible placeholder +string styled distinctly in the HTML preview. +""" + +from __future__ import annotations + +SETUP_INSTRUCTIONS_LINES: list[str] = [ + "Set up the Agnes CLI on this machine.", + "", + "Server: {server_url}", + "Personal access token: {token}", + "(Just generated; treat it as a secret.)", + "", + "Run these, in order. If any step fails, paste the exact error back and stop.", + "", + "1) Install the CLI:", + " uv tool install --force {server_url}/cli/agnes.whl", + "", + " If uv is not installed yet:", + " curl -LsSf https://astral.sh/uv/install.sh | sh", + "", + " If `da --version` fails after install because ~/.local/bin is not on PATH:", + " export PATH=\"$HOME/.local/bin:$PATH\"", + " # persist: append the same line to your ~/.zshrc or ~/.bashrc", + "", + "2) Log in (also saves the server URL):", + " da auth import-token --token \"{token}\" --server \"{server_url}\"", + "", + "3) Verify the login:", + " da auth whoami", + "", + "4) Run diagnostics:", + " da diagnose", + "", + " This should print \"Overall: healthy\" and a list of green checks. If", + " anything is yellow/red, paste the full output back.", + "", + "5) Skills (ask the user first):", + " The CLI ships with reusable markdown skills (setup, connectors,", + " corporate-memory, deploy, notifications, security, troubleshoot),", + " listable via `da skills list` and readable via `da skills show `.", + "", + " Ask the user verbatim: \"Do you want me to copy the Agnes skills into", + " ~/.claude/skills/agnes/ so they are always loaded in Claude Code,", + " or should I pull them on-demand via `da skills show ` when", + " needed?\"", + "", + " If they say copy:", + " mkdir -p ~/.claude/skills/agnes", + " for s in $(da skills list | awk '{print $1}'); do", + " da skills show \"$s\" > ~/.claude/skills/agnes/\"$s\".md", + " done", + " echo \"Copied skills to ~/.claude/skills/agnes/\"", + "", + "6) Confirm:", + " Tell me \"Agnes CLI is ready\" and summarize:", + " - `da --version` output", + " - `da auth whoami` output (email + role)", + " - Whether skills were copied or left on-demand", + " - The `da diagnose` overall status", +] + + +def render_setup_instructions(server_url: str, token: str) -> str: + """Render the setup instructions as a single string. + + Used server-side for tests and any non-JS rendering path. The browser + clipboard flow uses the JS renderer embedded in the Jinja partial; both + must produce byte-identical output for a given (server_url, token). + """ + text = "\n".join(SETUP_INSTRUCTIONS_LINES) + return text.replace("{server_url}", server_url).replace("{token}", token) diff --git a/app/web/static/style-custom.css b/app/web/static/style-custom.css index ce09fab..c9f4824 100644 --- a/app/web/static/style-custom.css +++ b/app/web/static/style-custom.css @@ -2040,3 +2040,164 @@ a.slack-badge:hover { background: #d97706; transform: translateY(-1px); } + +/* ─── Shared modern header (used by base.html + future pages) ─── */ +/* Mirrors the inline header styles in dashboard.html so all pages share chrome. */ + +.app-header { + background: var(--surface, #fff); + border-bottom: 1px solid var(--border, #e5e7eb); + padding: 0 32px; + height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.app-header-left { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +} + +.app-header-logo { + display: inline-flex; + align-items: center; + text-decoration: none; + color: inherit; + font-weight: 600; + font-size: 16px; +} +.app-header-logo svg { display: block; } +a.app-header-logo:focus-visible { + outline: 2px solid var(--primary, #6366f1); + outline-offset: 2px; + border-radius: 4px; +} + +.app-header-subtitle { + font-size: 11px; + font-weight: 500; + color: var(--text-secondary, #6b7280); + letter-spacing: 0.4px; + text-transform: uppercase; + margin-top: 2px; +} + +.app-header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.app-header-email { + font-size: 13px; + color: var(--text-secondary, #6b7280); + font-weight: 500; +} + +.app-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--primary-light, #eef2ff); + color: var(--primary, #6366f1); + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + letter-spacing: 0.3px; +} + +.app-avatar-img { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--border, #e5e7eb); +} + +.app-nav-link { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #6b7280); + text-decoration: none; + padding: 6px 12px; + border-radius: 8px; + transition: all 0.15s ease; +} +.app-nav-link:hover { + color: var(--text-primary, #111827); + background: var(--border-light, #f3f4f6); +} +.app-nav-link.is-active { + color: var(--primary, #6366f1); + background: var(--primary-light, #eef2ff); +} + +.app-btn-logout { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #6b7280); + background: none; + border: 1px solid var(--border, #e5e7eb); + border-radius: 8px; + padding: 6px 14px; + cursor: pointer; + transition: all 0.15s ease; + text-decoration: none; + display: inline-block; +} +.app-btn-logout:hover { + color: var(--text-primary, #111827); + border-color: #d1d5db; + background: var(--border-light, #f3f4f6); +} + +/* ── User menu (dropdown) ── */ +.app-user-menu { position: relative; display: inline-flex; align-items: center; } +.app-user-menu-trigger { + display: inline-flex; align-items: center; gap: 6px; + background: none; border: 1px solid transparent; + border-radius: 999px; padding: 4px 10px 4px 4px; + cursor: pointer; transition: all 0.15s ease; +} +.app-user-menu-trigger:hover { background: var(--border-light, #f3f4f6); border-color: var(--border, #e5e7eb); } +.app-user-menu-trigger[aria-expanded="true"] { background: var(--border-light, #f3f4f6); border-color: var(--border, #e5e7eb); } +.app-user-menu-chevron { color: var(--text-secondary, #6b7280); transition: transform 0.15s ease; } +.app-user-menu-trigger[aria-expanded="true"] .app-user-menu-chevron { transform: rotate(180deg); } +.app-user-menu-panel { + position: absolute; top: calc(100% + 8px); right: 0; + min-width: 220px; + background: var(--surface, #fff); + border: 1px solid var(--border, #e5e7eb); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + padding: 6px; + z-index: 50; +} +.app-user-menu-panel[hidden] { display: none; } +.app-user-menu-header { + padding: 10px 12px 8px; + border-bottom: 1px solid var(--border-light, #f3f4f6); + margin-bottom: 4px; +} +.app-user-menu-email { font-size: 13px; font-weight: 500; color: var(--text-primary, #111827); word-break: break-all; } +.app-user-menu-role { font-size: 11px; color: var(--text-secondary, #6b7280); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.3px; } +.app-user-menu-item { + display: block; padding: 8px 12px; + font-size: 13px; color: var(--text-primary, #111827); + text-decoration: none; border-radius: 6px; +} +.app-user-menu-item:hover { background: var(--border-light, #f3f4f6); } +.app-user-menu-item.is-active { background: rgba(0, 115, 209, 0.08); color: var(--primary, #0073D1); font-weight: 500; } + +@media (max-width: 720px) { + .app-header { padding: 0 16px; gap: 8px; } + .app-header-email { display: none; } + .app-nav-link { padding: 6px 8px; } +} diff --git a/app/web/static/style.css b/app/web/static/style.css index c232611..8d4449d 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -54,6 +54,9 @@ header { gap: 16px; } +a.logo { text-decoration: none; color: inherit; display: block; } +a.logo:hover h1 { color: var(--primary); } + .logo h1 { font-size: 1.375rem; font-weight: 600; diff --git a/app/web/templates/_app_header.html b/app/web/templates/_app_header.html new file mode 100644 index 0000000..1d292ca --- /dev/null +++ b/app/web/templates/_app_header.html @@ -0,0 +1,67 @@ +{# Shared modern header — used by base.html and dashboard.html. + Styles live in app/web/static/style-custom.css under the .app-* prefix. #} +{% if session.user %} +
+ +
+ {% set _path = request.url.path %} + Dashboard + Install CLI + {% if session.user.role == 'admin' %} + Users + All tokens + {% endif %} + +
+ + +
+
+
+ +{% endif %} diff --git a/app/web/templates/_claude_setup_instructions.jinja b/app/web/templates/_claude_setup_instructions.jinja new file mode 100644 index 0000000..30d0431 --- /dev/null +++ b/app/web/templates/_claude_setup_instructions.jinja @@ -0,0 +1,44 @@ +{# Single source of truth for the "Setup a new Claude Code" clipboard payload. + + Two modes: + + * preview_mode=True → emits a read-only HTML
 block rendered
+                           with the real server_url and a visible placeholder
+                           for the token. Used inline on /dashboard and
+                           /install so the reader can see exactly what will
+                           land in their clipboard.
+   * preview_mode=False →  emits the JS `SETUP_INSTRUCTIONS_TEMPLATE` array +
+                           `renderSetupInstructions(server, token)` function.
+                           Placed inside a 
+{% endblock %}
diff --git a/app/web/templates/admin_users.html b/app/web/templates/admin_users.html
new file mode 100644
index 0000000..3a92888
--- /dev/null
+++ b/app/web/templates/admin_users.html
@@ -0,0 +1,553 @@
+{% extends "base.html" %}
+{% block title %}Users — {{ config.INSTANCE_NAME }}{% endblock %}
+
+{% block content %}
+
+
+
+
+

Users

+ + +
+ +
+ + + + + + + + + + + + +
UserRoleActiveCreatedDeactivatedActions
+
Loading users…
+ +
+
+ + + + + + + + + + + + + +
+ + +{% endblock %} diff --git a/app/web/templates/base.html b/app/web/templates/base.html index f6962c9..068d4ca 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -9,25 +9,9 @@ {% include '_theme.html' %} -
-
- - {% if session.user %} - - {% endif %} -
+ {% include '_app_header.html' %} +
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
diff --git a/app/web/templates/catalog.html b/app/web/templates/catalog.html index 894abac..0141360 100644 --- a/app/web/templates/catalog.html +++ b/app/web/templates/catalog.html @@ -1494,24 +1494,7 @@ -
-
- - - - - -
- - Data Catalog -
-
-
- {% if data_stats.last_updated %}Last sync: {{ data_stats.last_updated }}{% endif %} -
-
+ {% include '_app_header.html' %}
diff --git a/app/web/templates/corporate_memory.html b/app/web/templates/corporate_memory.html index f91cc33..b9a1851 100644 --- a/app/web/templates/corporate_memory.html +++ b/app/web/templates/corporate_memory.html @@ -514,49 +514,7 @@
- + {% include '_app_header.html' %}
diff --git a/app/web/templates/corporate_memory_admin.html b/app/web/templates/corporate_memory_admin.html index d8b0a28..e73235f 100644 --- a/app/web/templates/corporate_memory_admin.html +++ b/app/web/templates/corporate_memory_admin.html @@ -802,34 +802,7 @@
- + {% include '_app_header.html' %}
diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index bbb0190..8bb94f3 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -32,9 +32,20 @@ gap: 2px; } + .header-logo { + display: inline-flex; + align-items: center; + text-decoration: none; + color: inherit; + } .header-logo svg { display: block; } + a.header-logo:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-radius: 4px; + } .header-subtitle { font-size: 11px; @@ -1221,6 +1232,147 @@ margin-left: auto; } + /* Error banner for setup flow */ + .setup-error { + margin-top: 12px; + padding: 10px 14px; + background: rgba(234, 88, 12, 0.12); + border-left: 3px solid #EA580C; + border-radius: 6px; + color: #FFF; + font-size: 13px; + } + .env-setup-cta .btn-setup[disabled] { + opacity: 0.7; + cursor: wait; + } + + /* ── Setup instructions preview (read-only card inside env-setup-cta) ── */ + .setup-preview-card { + margin-top: 18px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 14px 16px; + } + .setup-preview-summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + user-select: none; + } + .setup-preview-summary::-webkit-details-marker { display: none; } + .setup-preview-chevron { + display: inline-block; + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + transition: transform 0.15s ease; + } + details[open] > .setup-preview-summary .setup-preview-chevron { transform: rotate(90deg); } + .setup-preview-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.85); + margin: 0; + } + .setup-preview-sub { + font-size: 12px; + color: rgba(255, 255, 255, 0.65); + margin: 0 0 10px 0; + } + .setup-preview-pre { + background: #1e1e2e; + border-radius: 6px; + padding: 14px 16px; + margin: 0; + max-height: 400px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.55; + color: #cdd6f4; + white-space: pre-wrap; + word-break: break-word; + } + .setup-preview-code { font-family: inherit; font-size: inherit; } + .setup-preview-pre .placeholder-token { + background: rgba(249, 226, 175, 0.12); + color: #f9e2af; + padding: 0 4px; + border-radius: 3px; + font-style: italic; + } + + /* Fallback modal (when clipboard is blocked) */ + .setup-fallback-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .setup-fallback-modal { + background: var(--surface); + border-radius: 12px; + padding: 20px; + max-width: 720px; + width: calc(100% - 32px); + max-height: calc(100vh - 64px); + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.3); + } + .setup-fallback-modal h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + .setup-fallback-modal p { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + } + .setup-fallback-modal textarea { + flex: 1; + min-height: 260px; + font-family: var(--font-mono); + font-size: 12px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--background); + color: var(--text-primary); + resize: vertical; + } + .setup-fallback-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + .setup-fallback-actions button { + font-family: var(--font-primary); + font-size: 13px; + font-weight: 500; + padding: 8px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-primary); + cursor: pointer; + } + .setup-fallback-actions button.primary { + background: var(--primary); + color: #FFF; + border-color: var(--primary); + } + /* ── Setup Banner (bottom, for returning users) ── */ .setup-banner { background: var(--background); @@ -1834,26 +1986,8 @@ - -
-
- - Data Analyst Portal -
- {% if session.user %} -
- {{ session.user.email }} - {% if session.user.picture %} - Profile - {% else %} -
{{ (user.name or user.email)[:2] | upper }}
- {% endif %} - Logout -
- {% endif %} -
+ + {% include '_app_header.html' %} {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -1875,19 +2009,33 @@ {% if not account_details or not account_details.last_sync_display %}
-

Set up your local environment

-

Run Claude Code in your project folder and paste the setup instructions to configure SSH, sync data, and initialize DuckDB.

+

Set up a new Claude Code

+

Generates a personal access token and copies a ready-to-paste setup script to your clipboard. Paste into Claude Code to finish.

- cd {{ project_dir }} && claude - - Paste into Claude Code to complete setup + Valid 90 days · token stays in clipboard only
+ +
+ + + What Claude Code will receive + +

+ Read-only preview. The real token is generated the moment + you click the button above and is placed directly in your + clipboard — never shown on this page. +

+ {% with preview_mode=True %} + {% include "_claude_setup_instructions.jinja" %} + {% endwith %} +
{% endif %} @@ -2266,17 +2414,6 @@
- -
-
-
Set up a new machine
-
Copy instructions and paste into Claude Code to configure another local environment.
-
- -
- {% else %} @@ -2421,19 +2558,112 @@ }); } - function copyBootstrapInstructions(btn) { - var instructions = {{ setup_instructions | tojson }}; + // ══════════════════════════════════════════════════════════════════ + // "Setup a new Claude Code" one-click flow + // ══════════════════════════════════════════════════════════════════ + // Template + renderer included from _claude_setup_instructions.jinja + // so dashboard.html and install.html always render the same payload. + {% include "_claude_setup_instructions.jinja" %} - var button = btn || document.getElementById('bootstrapCopyBtn'); - var origText = button.textContent; - copyToClipboard(instructions).then(function() { - button.textContent = 'Copied!'; - button.classList.add('copied'); - setTimeout(function() { - button.textContent = origText; - button.classList.remove('copied'); - }, 2000); + function defaultTokenName() { + var stamp = new Date().toISOString().slice(0, 16).replace("T", " "); + return "Claude Code — " + stamp; + } + + function showSetupFallback(instructions) { + // Clipboard blocked (non-secure context, permission denied, etc.). + // Show a modal with the instructions preselected so the user can Ctrl+C. + var overlay = document.createElement('div'); + overlay.className = 'setup-fallback-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + var ta = overlay.querySelector('textarea'); + ta.value = instructions; + ta.focus(); + ta.select(); + overlay.addEventListener('click', function(ev) { + if (ev.target === overlay) { document.body.removeChild(overlay); } }); + overlay.querySelector('[data-action="close"]').addEventListener('click', function() { + document.body.removeChild(overlay); + }); + overlay.querySelector('[data-action="select"]').addEventListener('click', function() { + ta.focus(); + ta.select(); + }); + } + + async function setupNewClaude(btn) { + var errEl = document.getElementById('setupClaudeError'); + if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } + var origText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Generating token…'; + try { + var resp = await fetch('/auth/tokens', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: defaultTokenName(), + expires_in_days: 90, + }), + }); + if (resp.status === 401) { + // Session expired mid-flight — bounce to login and come back. + window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); + return; + } + if (!resp.ok) { + var detail = 'HTTP ' + resp.status; + try { + var body = await resp.json(); + if (body && body.detail) { detail = body.detail; } + } catch (_) { /* non-JSON */ } + throw new Error(detail); + } + var data = await resp.json(); + if (!data || !data.token) { + throw new Error('Server did not return a token.'); + } + var serverUrl = window.location.origin; + var instructions = renderSetupInstructions(serverUrl, data.token); + + try { + await copyToClipboard(instructions); + btn.textContent = 'Copied! Paste into Claude Code'; + btn.classList.add('copied'); + setTimeout(function() { + btn.textContent = origText; + btn.classList.remove('copied'); + btn.disabled = false; + }, 3000); + } catch (clipErr) { + // Clipboard denied — fall back to modal. Re-enable the button immediately. + btn.textContent = origText; + btn.disabled = false; + showSetupFallback(instructions); + } + // Token is NOT stored in DOM after the modal closes / flash disappears. + } catch (err) { + btn.textContent = origText; + btn.disabled = false; + if (errEl) { + errEl.textContent = 'Setup failed: ' + (err && err.message ? err.message : err); + errEl.style.display = 'block'; + } else { + alert('Setup failed: ' + (err && err.message ? err.message : err)); + } + } } async function updateSyncSettings() { diff --git a/app/web/templates/install.html b/app/web/templates/install.html new file mode 100644 index 0000000..9d37fc3 --- /dev/null +++ b/app/web/templates/install.html @@ -0,0 +1,1077 @@ + + + + + + Install CLI — {{ config.INSTANCE_NAME }} + {% if not config.THEME_FONT_URL %} + + + + {% endif %} + + + {% include '_theme.html' %} + + + + + {% include '_app_header.html' %} + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ + +
+
Getting started
+

Install the Agnes CLI on this machine

+

+ Connect your terminal and Claude Code to this server. Copy the + one-liner below — it downloads and installs the CLI wheel, + then seeds your local config. +

+
+ {{ server_url }} + {% if agnes_version and agnes_version != "dev" %} + v{{ agnes_version }} + {% endif %} +
+
+ + +
+
+ 1 + Quick install +
+
+ {% if session.user %} +

+ One click generates a personal access token, assembles a + complete setup script (install CLI, save token, verify), + and copies it to your clipboard. Paste the result into + Claude Code to finish. +

+ +

+ Valid 90 days · token stays in your clipboard only. +

+ + +
+ + + What Claude Code will receive + +

+ Read-only preview. The real token is generated when you + click the button above and is placed directly in your + clipboard — it is never rendered on this page. +

+ {% with preview_mode=True %} + {% include "_claude_setup_instructions.jinja" %} + {% endwith %} +
+ +
+ Or run manually on a restricted environment +
+ $ + curl -fsSL {{ server_url }}/cli/install.sh | bash + +
+

+ If da is not found in a new shell, add + ~/.local/bin to your PATH: +

+
+ $ + export PATH="$HOME/.local/bin:$PATH" + +
+

+ Add that line to your ~/.bashrc or + ~/.zshrc for persistence. +

+
+ {% else %} +

+ Run this in your terminal (Linux / macOS). The installer + downloads the wheel and seeds ~/.config/da/config.yaml + with this server URL. +

+

+ Sign in to skip the manual steps — you'll get a one-click + setup with a pre-configured token. +

+
+ $ + curl -fsSL {{ server_url }}/cli/install.sh | bash + +
+

+ If da is not found in a new shell, add + ~/.local/bin to your PATH: +

+
+ $ + export PATH="$HOME/.local/bin:$PATH" + +
+

+ Add that line to your ~/.bashrc or + ~/.zshrc for persistence. +

+ {% endif %} +
+
+ + + {% if not session.user %} +
+ + You'll need to sign in first to create a personal access token. + + Sign in → +
+ {% endif %} +
+
+ 2 + Create a personal access token +
+
+

+ Tokens let the CLI, CI jobs, and Claude Code talk to the + server without a browser session. +

+ + Open /tokens + + + + + +

+ Export it for the current shell and verify the connection: +

+
+ $ + export DA_TOKEN=<your-token> + +
+
+ $ + da auth whoami + +
+
+
+ + +
+ + + Manual install + + For restricted environments, offline machines, or + Windows — download the wheel yourself. + + + + +
+
    +
  1. + Download the wheel from + {{ server_url }}/cli/download. +
  2. +
  3. + Install it: +
    + $ + uv tool install ./agnes_the_ai_analyst-*.whl + +
    + — or — +
    + $ + python3 -m pip install --user ./agnes_the_ai_analyst-*.whl + +
    +

    + On macOS (Homebrew) or recent Debian/Ubuntu, + pip install --user is blocked by + PEP 668 — + prefer uv tool install above. The + pip command is for users with an + activated virtualenv. +

    +

    + If da is not found after install, ensure + ~/.local/bin is on your PATH: +

    +
    + $ + export PATH="$HOME/.local/bin:$PATH" + +
    +

    + Add that line to your ~/.bashrc or + ~/.zshrc to make it persistent. +

    +
  4. +
  5. + Seed the server URL: +
    + $ + mkdir -p ~/.config/da && echo "server: {{ server_url }}" > ~/.config/da/config.yaml + +
    +
  6. +
  7. + Continue with Step 2 — Create a personal + access token above. +
  8. +
+
+
+ + + + +
+ +
+

© {{ now().year if now is defined else 2024 }} {{ config.INSTANCE_COPYRIGHT or 'AI Data Analyst' }}

+
+ + + {% include "_version_badge.html" %} + + diff --git a/app/web/templates/login_email.html b/app/web/templates/login_email.html index 3478b96..3c7ae29 100644 --- a/app/web/templates/login_email.html +++ b/app/web/templates/login_email.html @@ -19,6 +19,7 @@