From e0a2cd915843901256f9a0d6b90a0016e483611a Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Mon, 1 Jun 2026 15:43:15 +0530 Subject: [PATCH 1/2] feat: add static Next.js dashboard Mount the exported dashboard at the daemon root, build it into the Python package, and use existing daemon endpoints for SWR data fetching. --- .github/workflows/publish-rc.yml | 13 + .github/workflows/publish.yml | 13 + .github/workflows/test.yml | 40 +- .gitignore | 8 +- pyproject.toml | 3 + scripts/build-ui.sh | 15 + src/authsome/server/app.py | 2 + src/authsome/server/routes/_deps.py | 14 + src/authsome/server/routes/audit.py | 7 +- src/authsome/server/routes/connections.py | 9 +- src/authsome/server/routes/health.py | 16 +- src/authsome/server/routes/ui.py | 20 +- src/authsome/ui/static/app.js | 124 - src/authsome/ui/templates/_layout.html | 2 - .../ui/templates/app_detail_apikey.html | 2 +- .../ui/templates/app_detail_disconnected.html | 2 +- .../ui/templates/app_detail_oauth.html | 4 +- src/authsome/ui/templates/app_provider.html | 2 +- src/authsome/ui/templates/applications.html | 4 +- src/authsome/ui/web/.gitkeep | 1 + tests/server/test_ui_dashboard.py | 38 +- tests/server/test_ui_sessions.py | 9 +- ui/.gitignore | 44 + ui/AGENTS.md | 5 + ui/CLAUDE.md | 1 + ui/README.md | 36 + ui/components.json | 25 + ui/eslint.config.mjs | 18 + ui/next-env.d.ts | 6 + ui/next.config.ts | 8 + ui/package.json | 34 + ui/pnpm-lock.yaml | 6246 +++++++++++++++++ ui/pnpm-workspace.yaml | 3 + ui/postcss.config.mjs | 7 + ui/public/file.svg | 1 + ui/public/globe.svg | 1 + ui/public/next.svg | 1 + ui/public/vercel.svg | 1 + ui/public/window.svg | 1 + ui/src/app/favicon.ico | Bin 0 -> 25931 bytes ui/src/app/globals.css | 137 + ui/src/app/layout.tsx | 36 + ui/src/app/page.tsx | 5 + ui/src/components/authsome-dashboard.tsx | 769 ++ ui/src/components/ui/badge.tsx | 52 + ui/src/components/ui/button.tsx | 58 + ui/src/components/ui/card.tsx | 103 + ui/src/components/ui/dialog.tsx | 160 + ui/src/components/ui/input.tsx | 20 + ui/src/components/ui/scroll-area.tsx | 55 + ui/src/components/ui/separator.tsx | 25 + ui/src/components/ui/skeleton.tsx | 13 + ui/src/components/ui/table.tsx | 116 + ui/src/components/ui/tabs.tsx | 82 + ui/src/components/ui/tooltip.tsx | 66 + ui/src/lib/authsome-api.ts | 331 + ui/src/lib/utils.ts | 6 + ui/tsconfig.json | 34 + 58 files changed, 8683 insertions(+), 171 deletions(-) create mode 100755 scripts/build-ui.sh delete mode 100644 src/authsome/ui/static/app.js create mode 100644 src/authsome/ui/web/.gitkeep create mode 100644 ui/.gitignore create mode 100644 ui/AGENTS.md create mode 100644 ui/CLAUDE.md create mode 100644 ui/README.md create mode 100644 ui/components.json create mode 100644 ui/eslint.config.mjs create mode 100644 ui/next-env.d.ts create mode 100644 ui/next.config.ts create mode 100644 ui/package.json create mode 100644 ui/pnpm-lock.yaml create mode 100644 ui/pnpm-workspace.yaml create mode 100644 ui/postcss.config.mjs create mode 100644 ui/public/file.svg create mode 100644 ui/public/globe.svg create mode 100644 ui/public/next.svg create mode 100644 ui/public/vercel.svg create mode 100644 ui/public/window.svg create mode 100644 ui/src/app/favicon.ico create mode 100644 ui/src/app/globals.css create mode 100644 ui/src/app/layout.tsx create mode 100644 ui/src/app/page.tsx create mode 100644 ui/src/components/authsome-dashboard.tsx create mode 100644 ui/src/components/ui/badge.tsx create mode 100644 ui/src/components/ui/button.tsx create mode 100644 ui/src/components/ui/card.tsx create mode 100644 ui/src/components/ui/dialog.tsx create mode 100644 ui/src/components/ui/input.tsx create mode 100644 ui/src/components/ui/scroll-area.tsx create mode 100644 ui/src/components/ui/separator.tsx create mode 100644 ui/src/components/ui/skeleton.tsx create mode 100644 ui/src/components/ui/table.tsx create mode 100644 ui/src/components/ui/tabs.tsx create mode 100644 ui/src/components/ui/tooltip.tsx create mode 100644 ui/src/lib/authsome-api.ts create mode 100644 ui/src/lib/utils.ts create mode 100644 ui/tsconfig.json diff --git a/.github/workflows/publish-rc.yml b/.github/workflows/publish-rc.yml index 98ba3783..ba80ab44 100644 --- a/.github/workflows/publish-rc.yml +++ b/.github/workflows/publish-rc.yml @@ -22,6 +22,16 @@ jobs: - uses: astral-sh/setup-uv@v7 + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: ui/pnpm-lock.yaml + + - name: Enable pnpm + run: corepack enable + - name: Compute RC version run: | CURRENT=$(grep '^version = ' pyproject.toml | sed 's/version = "//;s/"//') @@ -29,6 +39,9 @@ jobs: RC_VERSION="${BASE}rc${{ github.run_number }}" sed -i "s/^version = \".*\"/version = \"${RC_VERSION}\"/" pyproject.toml + - name: Build static UI + run: ./scripts/build-ui.sh + - name: Build run: uv build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a63da995..646533e9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,6 +17,19 @@ jobs: - uses: astral-sh/setup-uv@v7 + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: ui/pnpm-lock.yaml + + - name: Enable pnpm + run: corepack enable + + - name: Build static UI + run: ./scripts/build-ui.sh + - name: Build distributions run: uv build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6cc75c50..eabba211 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,29 +13,45 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - + + - uses: astral-sh/setup-uv@v7 + - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" cache: "pip" - + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: ui/pnpm-lock.yaml + + - name: Enable pnpm + run: corepack enable + - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" + run: uv pip install --system -e ".[dev]" + + - name: Build static UI + run: ./scripts/build-ui.sh + + - name: Lint static UI + run: uv run pnpm --dir ui lint - name: Lint with ruff run: | - ruff check src/ tests/ - ruff format --check src/ tests/ - + uv run ruff check src/ tests/ + uv run ruff format --check src/ tests/ + - name: Type check with ty - run: ty check src/ - + run: uv run ty check src/ + - name: Run tests with coverage - run: pytest --cov=authsome --cov-report=xml --cov-report=term -p no:xdist - + run: uv run pytest --cov=authsome --cov-report=xml --cov-report=term -p no:xdist + - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v6 with: diff --git a/.gitignore b/.gitignore index 441532c8..77f1a15c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ downloads/ eggs/ .eggs/ lib/ +!ui/src/lib/ +!ui/src/lib/** lib64/ parts/ sdist/ @@ -204,6 +206,10 @@ cython_debug/ # Ruff stuff: .ruff_cache/ +# Generated static dashboard bundle +src/authsome/ui/web/* +!src/authsome/ui/web/.gitkeep + # PyPI configuration file .pypirc @@ -252,4 +258,4 @@ __marimo__/ docs/superpowers/ # Authsome skill (generated at eval time) -.claude/skills/authsome/ \ No newline at end of file +.claude/skills/authsome/ diff --git a/pyproject.toml b/pyproject.toml index 27aeee87..d549c3f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,9 @@ authsome = "authsome.cli.main:cli" [tool.hatch.build.targets.wheel] packages = ["src/authsome"] +[tool.hatch.build.targets.sdist.force-include] +"src/authsome/ui/web" = "src/authsome/ui/web" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] diff --git a/scripts/build-ui.sh b/scripts/build-ui.sh new file mode 100755 index 00000000..eefe6417 --- /dev/null +++ b/scripts/build-ui.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UI_DIR="${ROOT_DIR}/ui" +TARGET_DIR="${ROOT_DIR}/src/authsome/ui/web" +UV_BIN="${UV:-uv}" + +"${UV_BIN}" run pnpm --dir "${UI_DIR}" install --frozen-lockfile +"${UV_BIN}" run pnpm --dir "${UI_DIR}" build + +rm -rf "${TARGET_DIR}" +mkdir -p "${TARGET_DIR}" +cp -R "${UI_DIR}/out/." "${TARGET_DIR}/" +touch "${TARGET_DIR}/.gitkeep" diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index ec627b24..9d443fea 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -111,5 +111,7 @@ def ui_auth_required_handler(request: Request, exc: UiAuthRequiredError): static_dir = files("authsome.ui").joinpath("static") app.mount("/static", StaticFiles(directory=str(static_dir)), name="ui-static") + ui_dir = files("authsome.ui").joinpath("web") + app.mount("/", StaticFiles(directory=str(ui_dir), html=True, check_dir=False), name="ui") return app diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index b83fcb82..a05accd8 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -173,6 +173,20 @@ async def get_admin_auth_service(request: Request) -> AuthService: return auth +async def get_daemon_or_browser_auth_service(request: Request) -> AuthService: + """Resolve auth from PoP headers or an existing browser dashboard session.""" + if request.headers.get("Authorization"): + return await get_protected_auth_service(request) + return await get_principal_browser_auth_service(request) + + +async def get_admin_daemon_or_browser_auth_service(request: Request) -> AuthService: + auth = await get_daemon_or_browser_auth_service(request) + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=403, detail="Admin role required") + return auth + + def get_vault_registry(request: Request) -> VaultRegistry: return request.app.state.vault_registry diff --git a/src/authsome/server/routes/audit.py b/src/authsome/server/routes/audit.py index 1506a6a6..fd3dd8cb 100644 --- a/src/authsome/server/routes/audit.py +++ b/src/authsome/server/routes/audit.py @@ -8,7 +8,10 @@ from authsome import audit from authsome.server.credential_service import AuthService -from authsome.server.routes._deps import get_admin_auth_service, get_protected_auth_service +from authsome.server.routes._deps import ( + get_admin_daemon_or_browser_auth_service, + get_protected_auth_service, +) router = APIRouter(prefix="/audit", tags=["audit"]) @@ -17,7 +20,7 @@ async def list_audit_events( request: Request, limit: int = 50, - auth: AuthService = Depends(get_admin_auth_service), + auth: AuthService = Depends(get_admin_daemon_or_browser_auth_service), ) -> dict[str, Any]: _ = auth return {"entries": await request.app.state.audit_log.list_events(limit=limit)} diff --git a/src/authsome/server/routes/connections.py b/src/authsome/server/routes/connections.py index 4d82b577..1a8ab9d4 100644 --- a/src/authsome/server/routes/connections.py +++ b/src/authsome/server/routes/connections.py @@ -7,14 +7,19 @@ from authsome.auth.models.enums import ExportFormat from authsome.server.analytics import capture_event from authsome.server.credential_service import AuthService -from authsome.server.routes._deps import get_admin_auth_service, get_protected_auth_service, get_vault_registry +from authsome.server.routes._deps import ( + get_admin_auth_service, + get_daemon_or_browser_auth_service, + get_protected_auth_service, + get_vault_registry, +) from authsome.server.store.repositories import VaultRegistry router = APIRouter(tags=["connections"]) @router.get("/connections") -async def list_connections(auth: AuthService = Depends(get_protected_auth_service)): +async def list_connections(auth: AuthService = Depends(get_daemon_or_browser_auth_service)): by_source = await auth.list_providers_by_source() return { "connections": await auth.list_connections(), diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index d2872a03..03fa04e0 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -6,7 +6,11 @@ from authsome import __version__ from authsome.server.credential_service import AuthService -from authsome.server.routes._deps import get_protected_auth_service, get_server_base_url +from authsome.server.routes._deps import ( + get_daemon_or_browser_auth_service, + get_protected_auth_service, + get_server_base_url, +) from authsome.server.schemas import HealthResponse, ReadyResponse from authsome.utils import connection_is_active @@ -115,18 +119,20 @@ async def ready( @router.get("/whoami") async def whoami( request: Request, - auth: AuthService = Depends(get_protected_auth_service), + auth: AuthService = Depends(get_daemon_or_browser_auth_service), server_base_url: str = Depends(get_server_base_url), ) -> dict[str, str]: effective_source, backend_description = _describe_vault_encryption(auth.vault) - identity = auth.require_identity() + identity = auth.identity or getattr(request.state, "ui_identity", "") or "" return { "version": __version__, "home": str(request.app.state.store.home), "identity": identity, "active_identity": identity, - "principal_id": getattr(request.state, "principal_id", ""), - "vault_id": getattr(request.state, "vault_id", ""), + "principal_id": getattr(request.state, "principal_id", None) or auth.principal_id or "", + "vault_id": getattr(request.state, "vault_id", None) or auth.vault_id or "", + "principal_role": auth.principal_role.value, + "account_email": getattr(request.state, "ui_email", ""), "did": getattr(request.state, "did", ""), "registration_status": getattr(request.state, "registration_status", "registered"), "daemon_url": server_base_url, diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 5481e7f5..a9c80038 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -451,7 +451,7 @@ def _build_connection_rows(providers: list[dict[str, Any]]) -> list[dict[str, An return sorted(rows, key=lambda row: (row["provider_display_name"].lower(), row["connection_name"].lower())) -@router.get("/", response_class=HTMLResponse) +@router.get("/legacy", response_class=HTMLResponse) async def overview( request: Request, auth: AuthService = Depends(require_ui_auth()), @@ -678,8 +678,10 @@ async def disconnect_app( auth: AuthService = Depends(require_ui_auth("/manage/connections")), ) -> Response: """Disconnect a provider connection from the dashboard.""" + form = await request.form() + return_path = _account_auth_next_url(form.get("return_url") or "/manage/connections") await auth.logout(provider_name, connection_name) - return _redirect(request, "/manage/connections") + return _redirect(request, return_path) @router.post("/apps/{provider_name}/connect") @@ -695,6 +697,7 @@ async def connect_app( form = await request.form() connection_name = str(form.get("connection") or form.get("connection_name") or "default") force = str(form.get("force", "false")).lower() in {"1", "true", "on", "yes"} + return_path = _account_auth_next_url(form.get("return_url") or f"/apps/{provider_name}") definition = await auth.get_provider(provider_name) flow = definition.flow @@ -707,7 +710,7 @@ async def connect_app( ) session.payload["force"] = force session.payload["callback_url_override"] = build_callback_url(server_base_url) - session.payload["return_url"] = f"{server_base_url.rstrip('/')}/apps/{provider_name}" + session.payload["return_url"] = f"{server_base_url.rstrip('/')}{return_path}" session.payload["ui_session_required"] = True if not force: @@ -716,7 +719,7 @@ async def connect_app( if auth._connection_is_valid(existing): session.status_message = "Already connected" await sessions.save(session) - return _redirect(request, f"/apps/{provider_name}") + return _redirect(request, return_path) except Exception: pass @@ -740,7 +743,7 @@ async def connect_app( await sessions.save(session) return _redirect(request, str(auth_url)) await sessions.save(session) - return _redirect(request, f"/apps/{provider_name}") + return _redirect(request, return_path) @router.post("/apps/{provider_name}/configure") @@ -752,6 +755,8 @@ async def configure_provider( server_base_url: str = Depends(get_server_base_url), ) -> Response: """Open the provider configuration flow for deployment-scoped credentials.""" + form = await request.form() + return_path = _account_auth_next_url(form.get("return_url") or f"/apps/{provider_name}") provider = await auth.get_provider(provider_name) policy = _ui_policy(request, auth) if not policy["show_provider_client_details"]: @@ -772,7 +777,7 @@ async def configure_provider( session.payload["provider_config_only"] = True session.payload["existing_provider_client"] = (await auth.get_provider_client(provider_name)) is not None session.payload["callback_url_override"] = build_callback_url(server_base_url) - session.payload["return_url"] = f"{server_base_url.rstrip('/')}/apps/{provider_name}" + session.payload["return_url"] = f"{server_base_url.rstrip('/')}{return_path}" session.payload["input_fields"] = [ field.model_dump(mode="json", exclude_none=True) for field in await auth.get_required_inputs(session) ] @@ -796,7 +801,8 @@ async def logout_ui_session( ui_sessions: UiSessionStore = Depends(get_ui_sessions), ) -> Response: """Clear the dashboard browser session.""" - response = _redirect(request, "/") + form = await request.form() + response = _redirect(request, _account_auth_next_url(form.get("return_url") or "/")) cookie_value = request.cookies.get(UI_SESSION_COOKIE_NAME) if cookie_value: try: diff --git a/src/authsome/ui/static/app.js b/src/authsome/ui/static/app.js deleted file mode 100644 index f89e396d..00000000 --- a/src/authsome/ui/static/app.js +++ /dev/null @@ -1,124 +0,0 @@ -(() => { - const ready = (fn) => - document.readyState === "loading" - ? document.addEventListener("DOMContentLoaded", fn) - : fn(); - - function initSearchAndFilter() { - const grid = document.getElementById("connectionList") || document.getElementById("appGrid"); - const search = document.getElementById("appSearch"); - const empty = document.getElementById("appEmpty"); - if (!grid) return; - - let activeFilter = "all"; - const cards = Array.from(grid.querySelectorAll(".app-card, .connection-row")); - - const applyFilters = () => { - const q = (search?.value || "").trim().toLowerCase(); - let visible = 0; - cards.forEach((card) => { - const matchesQuery = !q || card.dataset.name.includes(q); - const status = card.dataset.status; - const matchesFilter = - !document.querySelector(".filter-pill") || - activeFilter === "all" || - (activeFilter === "connected" && status !== "available") || - (activeFilter === "available" && status === "available"); - const show = matchesQuery && matchesFilter; - card.classList.toggle("hidden", !show); - if (show) visible += 1; - }); - if (empty) empty.classList.toggle("hidden", visible !== 0); - }; - - document.querySelectorAll(".filter-pill").forEach((pill) => { - pill.addEventListener("click", () => { - document - .querySelectorAll(".filter-pill") - .forEach((p) => p.classList.remove("active")); - pill.classList.add("active"); - activeFilter = pill.dataset.filter || "all"; - applyFilters(); - }); - }); - - if (search) search.addEventListener("input", applyFilters); - } - - function initSecretToggles() { - document.querySelectorAll("[data-toggle-secret]").forEach((btn) => { - btn.addEventListener("click", () => { - const row = btn.closest(".field-row"); - const target = row?.querySelector("[data-secret]"); - if (!target) return; - const real = target.dataset.secretValue; - if (!real) return; - const isMasked = target.classList.contains("mask"); - if (isMasked) { - target.dataset.maskedDisplay = target.textContent; - target.textContent = real; - target.classList.remove("mask"); - } else { - target.textContent = target.dataset.maskedDisplay || "••••••••••••••••"; - target.classList.add("mask"); - } - }); - }); - } - - function initCopyButtons() { - document.querySelectorAll("[data-copy]").forEach((btn) => { - btn.addEventListener("click", async () => { - const value = btn.dataset.copy; - if (!value) return; - try { - await navigator.clipboard.writeText(value); - const original = btn.title; - btn.title = "Copied"; - btn.classList.add("active"); - setTimeout(() => { - btn.title = original; - btn.classList.remove("active"); - }, 900); - } catch { - /* clipboard write failed; nothing fatal to do here */ - } - }); - }); - } - - function initLoginModal() { - const modal = document.getElementById("loginModal"); - const form = document.getElementById("loginModalForm"); - const hiddenConnection = document.getElementById("loginConnectionName"); - const input = document.getElementById("connectionNameInput"); - if (!modal || !form || !hiddenConnection || !input) return; - - document.querySelectorAll("[data-open-login-modal]").forEach((btn) => { - btn.addEventListener("click", () => { - const provider = btn.dataset.provider; - if (!provider) return; - form.action = `/apps/${provider}/connect`; - hiddenConnection.value = ""; - input.value = ""; - modal.showModal(); - input.focus(); - }); - }); - - document.querySelectorAll("[data-close-login-modal]").forEach((btn) => { - btn.addEventListener("click", () => modal.close()); - }); - - form.addEventListener("submit", () => { - hiddenConnection.value = input.value.trim(); - }); - } - - ready(() => { - initSearchAndFilter(); - initSecretToggles(); - initCopyButtons(); - initLoginModal(); - }); -})(); diff --git a/src/authsome/ui/templates/_layout.html b/src/authsome/ui/templates/_layout.html index eb0bb9cc..777b05d6 100644 --- a/src/authsome/ui/templates/_layout.html +++ b/src/authsome/ui/templates/_layout.html @@ -111,7 +111,5 @@ - - diff --git a/src/authsome/ui/templates/app_detail_apikey.html b/src/authsome/ui/templates/app_detail_apikey.html index de701484..7b34c9fc 100644 --- a/src/authsome/ui/templates/app_detail_apikey.html +++ b/src/authsome/ui/templates/app_detail_apikey.html @@ -35,7 +35,7 @@ {% block actions %} -
+
{% endblock %} diff --git a/src/authsome/ui/templates/app_detail_disconnected.html b/src/authsome/ui/templates/app_detail_disconnected.html index 1399dba8..472e7a33 100644 --- a/src/authsome/ui/templates/app_detail_disconnected.html +++ b/src/authsome/ui/templates/app_detail_disconnected.html @@ -72,7 +72,7 @@ {% endblock %} {% block actions %} -
+
diff --git a/src/authsome/ui/templates/app_detail_oauth.html b/src/authsome/ui/templates/app_detail_oauth.html index 6007f851..9df2e203 100644 --- a/src/authsome/ui/templates/app_detail_oauth.html +++ b/src/authsome/ui/templates/app_detail_oauth.html @@ -59,12 +59,12 @@ {% block actions %} -
+
-
+
{% endblock %} diff --git a/src/authsome/ui/templates/app_provider.html b/src/authsome/ui/templates/app_provider.html index 1e901983..7420be7f 100644 --- a/src/authsome/ui/templates/app_provider.html +++ b/src/authsome/ui/templates/app_provider.html @@ -97,7 +97,7 @@

{{ provider.display_name }}

{% endif %} -
+ {% if requires_named_login %} diff --git a/src/authsome/ui/templates/applications.html b/src/authsome/ui/templates/applications.html index 4565d363..3039ee39 100644 --- a/src/authsome/ui/templates/applications.html +++ b/src/authsome/ui/templates/applications.html @@ -33,9 +33,7 @@

Applications

+ action="/apps/{{ p.name }}/connect"> {% if p.requires_named_login %} + + ); +} + +function LoadingScreen() { + return ( +
+ +
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+
+
+ ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+ + + + + Dashboard Unavailable + + The daemon did not return dashboard data. + + + + + +
+ ); +} + +function Sidebar({ + activeView, + data, + onChange, +}: { + activeView: View; + data: DashboardData; + onChange: (view: View) => void; +}) { + const items = NAV_ITEMS.filter((item) => !item.adminOnly || data.account.isAdmin); + + return ( + + ); +} + +function Topbar() { + return ( +
+
+
Local Dashboard
+
Workspace Status
+
+
+ + +
+ + +
+
+
+ ); +} + +function StatCards({ data }: { data: DashboardData }) { + const stats = [ + { label: "Connected Apps", value: data.stats.connected, foot: `${data.stats.available} available`, icon: }, + { label: "Next Expiry", value: data.lastActivity, foot: "Across active providers", icon: }, + { label: "Auth Types", value: `${data.stats.oauth} / ${data.stats.apiKey}`, foot: "OAuth 2.0 / API Key", icon: }, + ]; + + return ( +
+ {stats.map((stat) => ( + + + {stat.label} + {stat.icon} + + +
{stat.value}
+

{stat.foot}

+
+
+ ))} +
+ ); +} + +function DashboardView({ data, onViewChange }: { data: DashboardData; onViewChange: (view: View) => void }) { + return ( +
+ +
+ + +
+ Connected Providers + Active credential surfaces in the current vault. +
+ +
+ + {data.connectedProviders.length ? ( +
+ {data.connectedProviders.slice(0, 6).map((provider) => ( + + ))} +
+ ) : ( + onViewChange("providers")} title="No connections yet" /> + )} +
+
+ + + Vault + Default credential namespace. + + + + + + + +
+
+ ); +} + +function ProviderSummary({ provider }: { provider: ProviderView }) { + return ( +
+
+
+ + {provider.logoInitial} + +
+
{provider.displayName}
+
{provider.authTypeLabel}
+
+
+ +
+

{provider.description || provider.apiUrl}

+
+ ); +} + +function EmptyBlock({ actionLabel, onAction, title }: { actionLabel: string; onAction: () => void; title: string }) { + return ( +
+
{title}
+ +
+ ); +} + +function ProvidersView({ providers }: { providers: ProviderView[] }) { + const [query, setQuery] = useState(""); + const [dialogProvider, setDialogProvider] = useState(null); + + const filteredProviders = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return providers; + } + return providers.filter((provider) => + `${provider.displayName} ${provider.name} ${provider.authTypeLabel}`.toLowerCase().includes(normalized), + ); + }, [providers, query]); + + return ( +
+ + +
+ {filteredProviders.map((provider) => ( + setDialogProvider(provider)} provider={provider} /> + ))} +
+ {!filteredProviders.length ?
No providers found.
: null} + +
+ ); +} + +function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; provider: ProviderView }) { + return ( + + +
+
+ + {provider.logoInitial} + +
+ {provider.displayName} + {provider.name} +
+
+ +
+
+ +

{provider.description || provider.apiUrl}

+
+ {provider.authTypeLabel} + {provider.source} + {provider.connectionCount ? {provider.connectionCount} connections : null} +
+ {provider.requiresNamedLogin ? ( + + ) : ( +
+ + + +
+ )} +
+
+ ); +} + +function NamedConnectionDialog({ + onOpenChange, + provider, +}: { + onOpenChange: (provider: ProviderView | null) => void; + provider: ProviderView | null; +}) { + const [connectionName, setConnectionName] = useState(""); + + function handleSubmit(event: FormEvent) { + if (!connectionName.trim()) { + event.preventDefault(); + } + } + + return ( + onOpenChange(open ? provider : null)}> + + + Connection name + {provider?.displayName} already has a default connection. + +
+ + + + }>Cancel + + +
+
+
+ ); +} + +function ConnectionsView({ connections }: { connections: DashboardData["connections"] }) { + const [query, setQuery] = useState(""); + const filteredConnections = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return connections; + } + return connections.filter((row) => + `${row.connectionName} ${row.providerDisplayName} ${row.authTypeLabel}`.toLowerCase().includes(normalized), + ); + }, [connections, query]); + + return ( +
+ + + + + {filteredConnections.length ? ( + + + + Connection + Provider + Type + Status + + + + {filteredConnections.map((row) => ( + + {row.connectionName} + {row.providerDisplayName} + {row.authTypeLabel} + + + + + ))} + +
+ ) : ( +
No connections found.
+ )} +
+
+
+ ); +} + +function VaultView({ data }: { data: DashboardData }) { + return ( +
+ +
+ + + Default Vault + {data.vault.isDefault ? "Active for this account" : "Vault binding"} + + + + + + + + + Identities + Claims accepted for this account. + + + {data.identities.map((identity) => ( +
+
+ + {identity.handle} +
+ {identity.isActive ? Active : null} +
+ ))} +
+
+
+
+ ); +} + +function AuditView({ data }: { data: DashboardData }) { + return ( +
+ + + + {data.audit.events.length ? ( + + + + Time + Event + Actor + Target + Status + + + + {data.audit.events.map((event) => ( + + {event.time} + {event.event} + {event.actor} + {event.target} + {event.status} + + ))} + +
+ ) : ( +
No audit events found.
+ )} +
+
+
+ ); +} + +function SettingsView({ data }: { data: DashboardData }) { + return ( +
+ +
+ + + Account + + + + + + + + + + Daemon + + + + + + +
+
+ ); +} + +function KeyValue({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+ + }> + {value} + + {value} + +
+ ); +} + +function SectionHeader({ description, title }: { description: string; title: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} + +function SearchInput({ + onChange, + placeholder, + value, +}: { + onChange: (value: string) => void; + placeholder: string; + value: string; +}) { + return ( + + ); +} + +function ActiveView({ + data, + onViewChange, + view, +}: { + data: DashboardData; + onViewChange: (view: View) => void; + view: View; +}) { + if (view === "providers") { + return ; + } + if (view === "connections") { + return ; + } + if (view === "vault") { + return ; + } + if (view === "audit" && data.account.isAdmin) { + return ; + } + if (view === "settings") { + return ; + } + return ; +} + +export function AuthsomeDashboard() { + const [activeView, setActiveView] = useState("dashboard"); + const { data, error, mutate } = useSWR("authsome-dashboard", fetchDashboard, { + dedupingInterval: 10_000, + revalidateOnFocus: true, + }); + + if (isUnauthorized(error)) { + return ; + } + if (error) { + return void mutate()} />; + } + if (!data) { + return ; + } + + return ( +
+
+ +
+ +
+
+
+
Authsome
+
v{data.version}
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx new file mode 100644 index 00000000..b20959dd --- /dev/null +++ b/ui/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx new file mode 100644 index 00000000..b0336017 --- /dev/null +++ b/ui/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx new file mode 100644 index 00000000..40cac5f9 --- /dev/null +++ b/ui/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx new file mode 100644 index 00000000..014f5aa3 --- /dev/null +++ b/ui/src/components/ui/dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/ui/src/components/ui/input.tsx b/ui/src/components/ui/input.tsx new file mode 100644 index 00000000..7d21babb --- /dev/null +++ b/ui/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/ui/src/components/ui/scroll-area.tsx b/ui/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..84c1e9fb --- /dev/null +++ b/ui/src/components/ui/scroll-area.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: ScrollAreaPrimitive.Root.Props) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: ScrollAreaPrimitive.Scrollbar.Props) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/ui/src/components/ui/separator.tsx b/ui/src/components/ui/separator.tsx new file mode 100644 index 00000000..6e1369e4 --- /dev/null +++ b/ui/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +"use client" + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/ui/src/components/ui/skeleton.tsx b/ui/src/components/ui/skeleton.tsx new file mode 100644 index 00000000..0118624f --- /dev/null +++ b/ui/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/ui/src/components/ui/table.tsx b/ui/src/components/ui/table.tsx new file mode 100644 index 00000000..abeaced4 --- /dev/null +++ b/ui/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx new file mode 100644 index 00000000..8ee8054f --- /dev/null +++ b/ui/src/components/ui/tabs.tsx @@ -0,0 +1,82 @@ +"use client" + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..69e8a822 --- /dev/null +++ b/ui/src/components/ui/tooltip.tsx @@ -0,0 +1,66 @@ +"use client" + +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts new file mode 100644 index 00000000..1a0b47dc --- /dev/null +++ b/ui/src/lib/authsome-api.ts @@ -0,0 +1,331 @@ +export type DashboardStats = { + connected: number; + available: number; + oauth: number; + apiKey: number; +}; + +export type ProviderView = { + name: string; + displayName: string; + authType: "oauth2" | "api_key" | string; + authTypeLabel: string; + apiUrl: string; + description: string; + source: "bundled" | "custom" | string; + logoInitial: string; + status: "available" | "connected" | "reauth" | string; + scopeCount: number; + connectionCount: number; + requiresNamedLogin: boolean; +}; + +export type ConnectionRow = { + providerName: string; + providerDisplayName: string; + connectionName: string; + status: string; + authTypeLabel: string; + href: string; +}; + +export type IdentityRow = { + handle: string; + isActive: boolean; +}; + +export type AuditRow = { + eventId: string; + time: string; + event: string; + source: string; + actor: string; + target: string; + status: string; + metadata: Record; +}; + +export type DashboardData = { + version: string; + account: { + email: string | null; + roleLabel: string | null; + isAdmin: boolean; + principalId: string | null; + identity: string | null; + }; + stats: DashboardStats; + lastActivity: string; + providers: ProviderView[]; + connectedProviders: ProviderView[]; + connections: ConnectionRow[]; + identities: IdentityRow[]; + vault: { + vaultId: string | null; + handle: string; + isDefault: boolean; + }; + audit: { + canView: boolean; + total: number; + events: AuditRow[]; + }; +}; + +type WhoamiResponse = { + version: string; + identity?: string; + active_identity?: string; + principal_id?: string; + principal_role?: string; + account_email?: string; + vault_id?: string; +}; + +type ConnectionSummary = { + connection_name: string; + auth_type?: string; + status?: string; + scopes?: string[]; + expires_at?: string | null; +}; + +type ProviderResponse = { + name: string; + display_name?: string; + auth_type?: string; + api_url?: string | string[] | null; + oauth?: { + base_url?: string | null; + } | null; + metadata?: { + description?: string; + }; +}; + +type ConnectionsResponse = { + connections: Array<{ + name: string; + connections: ConnectionSummary[]; + }>; + by_source: Record; +}; + +type AuditResponse = { + entries: Array>; +}; + +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function requestJson(path: string): Promise { + const response = await fetch(path, { + credentials: "same-origin", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + let message = response.statusText || "Request failed"; + try { + const payload = (await response.json()) as { detail?: string; message?: string }; + message = payload.detail || payload.message || message; + } catch { + // Status is sufficient for the UI's failure modes. + } + throw new ApiError(response.status, message); + } + + return response.json() as Promise; +} + +function authTypeLabel(authType?: string): string { + return authType === "oauth2" ? "OAuth 2.0" : authType === "api_key" ? "API Key" : authType || "Provider"; +} + +function providerApiUrl(provider: ProviderResponse): string { + if (Array.isArray(provider.api_url)) { + return provider.api_url.filter(Boolean).join(", ") || provider.name; + } + return provider.api_url || provider.oauth?.base_url || provider.name; +} + +function providerStatus(connections: ConnectionSummary[]): ProviderView["status"] { + if (!connections.length) { + return "available"; + } + return connections.some((connection) => ["error", "expired"].includes(connection.status || "")) + ? "reauth" + : "connected"; +} + +function providerView( + provider: ProviderResponse, + source: string, + connections: ConnectionSummary[], +): ProviderView { + const displayName = provider.display_name || provider.name; + return { + name: provider.name, + displayName, + authType: provider.auth_type || "provider", + authTypeLabel: authTypeLabel(provider.auth_type), + apiUrl: providerApiUrl(provider), + description: provider.metadata?.description || "", + source, + logoInitial: (displayName[0] || "?").toUpperCase(), + status: providerStatus(connections), + scopeCount: connections[0]?.scopes?.length || 0, + connectionCount: connections.length, + requiresNamedLogin: connections.some((connection) => connection.connection_name === "default"), + }; +} + +function buildProviders(data: ConnectionsResponse): ProviderView[] { + const connectionMap = new Map(data.connections.map((group) => [group.name, group.connections])); + return Object.entries(data.by_source).flatMap(([source, providers]) => + providers.map((provider) => providerView(provider, source, connectionMap.get(provider.name) || [])), + ); +} + +function buildConnectionRows(data: ConnectionsResponse, providers: ProviderView[]): ConnectionRow[] { + const providerMap = new Map(providers.map((provider) => [provider.name, provider])); + return data.connections + .flatMap((group) => { + const provider = providerMap.get(group.name); + return group.connections.map((connection) => ({ + providerName: group.name, + providerDisplayName: provider?.displayName || group.name, + connectionName: connection.connection_name, + status: connection.status || "unknown", + authTypeLabel: authTypeLabel(connection.auth_type || provider?.authType), + href: `/apps/${group.name}/connections/${connection.connection_name}`, + })); + }) + .sort((a, b) => `${a.providerDisplayName}:${a.connectionName}`.localeCompare(`${b.providerDisplayName}:${b.connectionName}`)); +} + +function formatRelative(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) { + return null; + } + const deltaSeconds = Math.round((parsed.valueOf() - Date.now()) / 1000); + const absSeconds = Math.abs(deltaSeconds); + const direction = deltaSeconds >= 0 ? "in" : "ago"; + const units: Array<[number, string]> = [ + [86_400, "day"], + [3_600, "hour"], + [60, "minute"], + [1, "second"], + ]; + const [unitSeconds, unit] = units.find(([seconds]) => absSeconds >= seconds) || [1, "second"]; + const amount = Math.max(1, Math.floor(absSeconds / unitSeconds)); + const label = `${amount} ${unit}${amount === 1 ? "" : "s"}`; + return direction === "in" ? `in ${label}` : `${label} ago`; +} + +function lastActivity(data: ConnectionsResponse): string { + const latest = data.connections + .flatMap((group) => group.connections) + .map((connection) => connection.expires_at) + .filter((value): value is string => Boolean(value)) + .sort((a, b) => new Date(b).valueOf() - new Date(a).valueOf())[0]; + return formatRelative(latest) || "-"; +} + +function humanize(value: unknown): string { + const event = String(value || "audit_event").replaceAll("_", " ").replaceAll("-", " ").trim(); + return event ? event[0].toUpperCase() + event.slice(1) : "Audit event"; +} + +function formatAuditTime(value: unknown): string { + if (!value) { + return "-"; + } + const parsed = new Date(String(value)); + if (Number.isNaN(parsed.valueOf())) { + return String(value); + } + return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC"; +} + +function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] { + const known = new Set(["event_id", "timestamp", "event", "source", "principal_id", "identity", "provider", "connection", "status"]); + return entries.map((entry, index) => { + const provider = entry.provider ? String(entry.provider) : ""; + const connection = entry.connection ? String(entry.connection) : ""; + const metadata = Object.fromEntries(Object.entries(entry).filter(([key, value]) => !known.has(key) && value != null)); + return { + eventId: String(entry.event_id || `${entry.timestamp || "event"}-${index}`), + time: formatAuditTime(entry.timestamp), + event: humanize(entry.event), + source: String(entry.source || "internal"), + actor: String(entry.identity || entry.principal_id || "system"), + target: [provider, connection].filter(Boolean).join(" / ") || "Authsome", + status: String(entry.status || "-"), + metadata, + }; + }); +} + +function roleLabel(role: string | undefined): string | null { + if (!role) { + return null; + } + return role.slice(0, 1).toUpperCase() + role.slice(1); +} + +export async function fetchDashboard(): Promise { + const [whoami, connectionsData] = await Promise.all([ + requestJson("/whoami"), + requestJson("/connections"), + ]); + const isAdmin = whoami.principal_role === "admin"; + const audit = isAdmin ? await requestJson("/audit/events?limit=100") : { entries: [] }; + const providers = buildProviders(connectionsData); + const connections = buildConnectionRows(connectionsData, providers); + const connectedProviders = providers.filter((provider) => provider.status !== "available"); + + return { + version: whoami.version, + account: { + email: whoami.account_email || null, + roleLabel: roleLabel(whoami.principal_role), + isAdmin, + principalId: whoami.principal_id || null, + identity: whoami.identity || whoami.active_identity || null, + }, + stats: { + connected: connectedProviders.length, + available: providers.length - connectedProviders.length, + oauth: connectedProviders.filter((provider) => provider.authType === "oauth2").length, + apiKey: connectedProviders.filter((provider) => provider.authType === "api_key").length, + }, + lastActivity: lastActivity(connectionsData), + providers, + connectedProviders: connectedProviders.slice(0, 6), + connections, + identities: whoami.identity ? [{ handle: whoami.identity, isActive: true }] : [], + vault: { + vaultId: whoami.vault_id || null, + handle: "default", + isDefault: true, + }, + audit: { + canView: isAdmin, + total: audit.entries.length, + events: buildAuditRows(audit.entries), + }, + }; +} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 00000000..bd0c391d --- /dev/null +++ b/ui/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 00000000..cf9c65d3 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} From 29bb29a388767dd787d40776b40ed84a672817cb Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Mon, 1 Jun 2026 16:04:36 +0530 Subject: [PATCH 2/2] refactor: remove legacy dashboard routes Delete the server-rendered dashboard pages, remove the static asset mount, and route the Next dashboard provider login form through the browser-session auth path. --- docs/site/reference/daemon-api.mdx | 19 +- pyproject.toml | 1 - scripts/build-ui.sh | 2 +- src/authsome/proxy/server.py | 5 +- src/authsome/server/app.py | 2 - src/authsome/server/routes/ui.py | 602 +-------- src/authsome/ui/static/style.css | 1130 ----------------- .../ui/templates/_app_detail_shell.html | 71 -- src/authsome/ui/templates/_layout.html | 115 -- .../ui/templates/app_detail_apikey.html | 41 - .../ui/templates/app_detail_disconnected.html | 79 -- .../ui/templates/app_detail_managed.html | 25 - .../ui/templates/app_detail_oauth.html | 70 - src/authsome/ui/templates/app_provider.html | 128 -- src/authsome/ui/templates/applications.html | 71 -- src/authsome/ui/templates/audit.html | 71 -- src/authsome/ui/templates/connections.html | 52 - src/authsome/ui/templates/identity.html | 23 - src/authsome/ui/templates/overview.html | 94 -- tests/proxy/test_proxy.py | 2 +- tests/server/test_ui_dashboard.py | 455 +------ tests/server/test_ui_sessions.py | 59 +- ui/src/components/authsome-dashboard.tsx | 9 +- ui/src/lib/authsome-api.ts | 2 - uv.lock | 4 +- 25 files changed, 72 insertions(+), 3060 deletions(-) delete mode 100644 src/authsome/ui/static/style.css delete mode 100644 src/authsome/ui/templates/_app_detail_shell.html delete mode 100644 src/authsome/ui/templates/_layout.html delete mode 100644 src/authsome/ui/templates/app_detail_apikey.html delete mode 100644 src/authsome/ui/templates/app_detail_disconnected.html delete mode 100644 src/authsome/ui/templates/app_detail_managed.html delete mode 100644 src/authsome/ui/templates/app_detail_oauth.html delete mode 100644 src/authsome/ui/templates/app_provider.html delete mode 100644 src/authsome/ui/templates/applications.html delete mode 100644 src/authsome/ui/templates/audit.html delete mode 100644 src/authsome/ui/templates/connections.html delete mode 100644 src/authsome/ui/templates/identity.html delete mode 100644 src/authsome/ui/templates/overview.html diff --git a/docs/site/reference/daemon-api.mdx b/docs/site/reference/daemon-api.mdx index c183dc3e..2f33233e 100644 --- a/docs/site/reference/daemon-api.mdx +++ b/docs/site/reference/daemon-api.mdx @@ -141,19 +141,18 @@ These routes back the local HTTP proxy started by `authsome run`. The proxy neve ## Dashboard UI -These routes serve HTML. The dashboard is served at the root prefix. +The Next.js dashboard is served as a static export at the daemon root. Browser-only form routes remain for account sessions and provider login starts. | Method | Path | Purpose | |--------|------|---------| -| `GET` | `/` | Dashboard overview. | -| `GET` | `/applications` | Applications / Providers view. | -| `GET` | `/manage/connections` | All connections, by provider. | -| `GET` | `/identity` | Identity details. | -| `GET` | `/audit` | Admin audit dashboard. | -| `GET` | `/apps/{provider_name}` | Detail pane for one provider. | -| `GET` | `/apps/{provider_name}/connections/{connection_name}` | Connection detail pane. | -| `POST` | `/apps/{provider_name}/{connection_name}/disconnect` | Log out from the dashboard UI. | -| `POST` | `/apps/{provider_name}/connect` | Start a flow from the dashboard UI. | +| `GET` | `/` | Static dashboard shell. | +| `POST` | `/session` | Return the dashboard URL for a PoP-authenticated local client. | +| `POST` | `/auth/login` | Create a browser dashboard session. | +| `POST` | `/auth/register` | Register an account and create a browser dashboard session. | +| `POST` | `/logout` | Clear the browser dashboard session. | +| `GET` | `/claim/{token}` | Render the account claim confirmation page. | +| `POST` | `/claim/{token}/confirm` | Attach a local identity to the signed-in account. | +| `POST` | `/auth/providers/{provider_name}/connect` | Start a provider login flow from the dashboard. | ## Auth diff --git a/pyproject.toml b/pyproject.toml index d549c3f6..10f78d86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ dependencies = [ "fastapi>=0.115", "uvicorn>=0.30", "python-multipart>=0.0.27", - "jinja2>=3.1", "py-key-value-aio[disk]", "aiosqlite>=0.20", "asyncpg>=0.30", diff --git a/scripts/build-ui.sh b/scripts/build-ui.sh index eefe6417..4b106982 100755 --- a/scripts/build-ui.sh +++ b/scripts/build-ui.sh @@ -12,4 +12,4 @@ UV_BIN="${UV:-uv}" rm -rf "${TARGET_DIR}" mkdir -p "${TARGET_DIR}" cp -R "${UI_DIR}/out/." "${TARGET_DIR}/" -touch "${TARGET_DIR}/.gitkeep" +printf '%s\n' "Generated static UI assets are copied here by scripts/build-ui.sh." > "${TARGET_DIR}/.gitkeep" diff --git a/src/authsome/proxy/server.py b/src/authsome/proxy/server.py index 4f3cbafd..6d637e50 100644 --- a/src/authsome/proxy/server.py +++ b/src/authsome/proxy/server.py @@ -520,15 +520,14 @@ def _deny_body(reason: str, match: RouteMatch | None) -> str: and a dashboard URL so the agent (or human) can recover; other reasons fall back to a generic message. - The dashboard URL uses ``DEFAULT_SERVER_BASE_URL``. It still requires an active dashboard session - to land on the connect screen directly. + The dashboard URL uses ``DEFAULT_SERVER_BASE_URL`` and still requires an active browser session. """ if reason == "no_credentials" and match is not None: provider = match.provider return ( f"Forbidden: provider '{provider}' is configured but has no " f"active connection. Run `authsome login {provider}` to connect, " - f"or visit {DEFAULT_SERVER_BASE_URL}/apps/{provider}." + f"or open the dashboard at {DEFAULT_SERVER_BASE_URL}/." ) return "Forbidden by Authsome proxy policy" diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index 9d443fea..7573a03f 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -109,8 +109,6 @@ def ui_auth_required_handler(request: Request, exc: UiAuthRequiredError): app.include_router(proxy_router) app.include_router(ui_router) - static_dir = files("authsome.ui").joinpath("static") - app.mount("/static", StaticFiles(directory=str(static_dir)), name="ui-static") ui_dir = files("authsome.ui").joinpath("web") app.mount("/", StaticFiles(directory=str(ui_dir), html=True, check_dir=False), name="ui") diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index a9c80038..971846dc 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -1,32 +1,20 @@ -"""HTML routes that render the Authsome local dashboard. - -The UI is intentionally server-rendered against the same FastAPI app that -serves the JSON daemon API. This keeps it a single process on port 7998 -and avoids a separate static server. -""" +"""Browser-session routes for the Authsome local dashboard.""" from __future__ import annotations from collections.abc import Awaitable, Callable -from datetime import UTC, datetime, timedelta -from importlib.resources import files +from datetime import timedelta from typing import Any from urllib.parse import urlencode from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.templating import Jinja2Templates -from authsome import __version__ -from authsome.auth.models.connection import ConnectionRecord, ProviderClientRecord -from authsome.auth.models.enums import AuthType, FlowType -from authsome.auth.models.provider import ProviderDefinition +from authsome.auth.models.enums import FlowType from authsome.auth.sessions import AuthSession, AuthSessionStore -from authsome.identity.principal import PrincipalRole from authsome.server.credential_service import AuthService from authsome.server.routes._deps import ( UI_SESSION_COOKIE_NAME, - build_auth_service, get_auth_service, get_auth_sessions, get_protected_auth_service, @@ -42,13 +30,9 @@ router = APIRouter(tags=["ui"], include_in_schema=False) -# Templates ship inside the installed package alongside the code. -_TEMPLATES_DIR = files("authsome.ui").joinpath("templates") -templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) - def _redirect(request: Request, url: str) -> Response: - """Redirect normally, or via htmx full-page redirect for boosted forms.""" + """Redirect normally, or via htmx full-page redirect for form compatibility.""" if request.headers.get("HX-Request") == "true": return Response(status_code=204, headers={"HX-Redirect": url}) return RedirectResponse(url=url, status_code=303) @@ -63,28 +47,6 @@ def _update_device_code_expiry(sessions: AuthSessionStore, session: AuthSession) pass -def _field_payloads(session: AuthSession) -> list[dict[str, Any]]: - fields = session.payload.get("input_fields", []) - return [dict(field) for field in fields] - - -def _ui_cookie_secure(server_base_url: str) -> bool: - return server_base_url.startswith("https://") - - -def _ui_policy(request: Request, auth: AuthService | None = None) -> dict[str, Any]: - role = auth.principal_role if auth is not None else getattr(request.state, "ui_principal_role", None) - is_admin = role == PrincipalRole.ADMIN - return { - "is_admin": is_admin, - "role_label": role.value.title() if role else None, - "show_admin_sections": is_admin, - "show_provider_client_details": is_admin, - "provider_management_label": ("OAuth Application" if is_admin else "OAuth application managed by Authsome"), - "show_identity": True, - } - - class UiAuthRequiredError(Exception): """Raised when a UI route needs to return an auth-related response.""" @@ -126,6 +88,10 @@ def _account_auth_entry_url(next_url: str = "/") -> str: return f"/?{urlencode({'next': _account_auth_next_url(next_url)})}" +def _ui_cookie_secure(server_base_url: str) -> bool: + return server_base_url.startswith("https://") + + def _set_ui_session_cookie( response: Response, token: str, @@ -181,523 +147,20 @@ def _account_auth_page_response( return HTMLResponse(page, status_code=400 if error else 200) -def _page_context(request: Request, page: str, *, auth: AuthService | None = None, **kwargs: Any) -> dict[str, Any]: - return { - "page": page, - "version": __version__, - "ui_identity": getattr(request.state, "ui_identity", None), - "ui_email": getattr(request.state, "ui_email", None), - **_ui_policy(request, auth), - **kwargs, - } - - -def _format_relative(when: datetime | None) -> str | None: - """Return a compact "in 47 minutes" / "2 days ago" label.""" - if when is None: - return None - if when.tzinfo is None: - when = when.replace(tzinfo=UTC) - delta_seconds = int((when - datetime.now(UTC)).total_seconds()) - abs_seconds = abs(delta_seconds) - direction = "in" if delta_seconds >= 0 else "ago" - - if abs_seconds < 60: - amount, unit = abs_seconds, "second" - elif abs_seconds < 3600: - amount, unit = abs_seconds // 60, "minute" - elif abs_seconds < 86400: - amount, unit = abs_seconds // 3600, "hour" - else: - amount, unit = abs_seconds // 86400, "day" - - plural = "" if amount == 1 else "s" - return f"{direction} {amount} {unit}{plural}" if direction == "in" else f"{amount} {unit}{plural} ago" - - -def _format_audit_time(value: Any) -> str: - if not value: - return "-" - try: - parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) - except ValueError: - return str(value) - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC).strftime("%Y-%m-%d %H:%M UTC") - - -def _humanize_audit_event(value: Any) -> str: - event = str(value or "audit_event").replace("_", " ").replace("-", " ").strip() - return event[:1].upper() + event[1:] if event else "Audit event" - - -def _audit_event_rows(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: - known = { - "event_id", - "timestamp", - "event", - "source", - "principal_id", - "identity", - "provider", - "connection", - "status", - } - rows: list[dict[str, Any]] = [] - for entry in entries: - provider = entry.get("provider") - connection = entry.get("connection") - metadata = {key: value for key, value in entry.items() if key not in known and value is not None} - target = " / ".join(str(part) for part in (provider, connection) if part) or "Authsome" - rows.append( - { - "event_id": entry.get("event_id") or "-", - "time": _format_audit_time(entry.get("timestamp")), - "event": _humanize_audit_event(entry.get("event")), - "source": entry.get("source") or "internal", - "actor": entry.get("identity") or entry.get("principal_id") or "system", - "target": target, - "status": entry.get("status") or "-", - "metadata": metadata, - } - ) - return rows - - -def _admin_required_response(title: str, message: str) -> HTMLResponse: - return HTMLResponse(pages.message_page(title, message), status_code=403) - - -def _provider_status(provider_name: str, connection_summaries: list[dict[str, Any]]) -> str: - """Map a connection list to a single status string for the overview cards.""" - if not connection_summaries: - return "available" - statuses = {c.get("status") for c in connection_summaries} - if "error" in statuses or "expired" in statuses: - return "reauth" - return "connected" - - -def _logo_initial(name: str) -> str: - return (name[:1] or "?").upper() - - -def _provider_api_url_label(provider: ProviderDefinition) -> str: - urls = provider.api_urls() - if urls: - return ", ".join(urls) - return (provider.oauth.base_url if provider.oauth else None) or provider.name - - -def _provider_primary_api_url(provider: ProviderDefinition) -> str: - return provider.primary_api_url() or (provider.oauth.base_url if provider.oauth else None) or provider.name - - -def _build_provider_view( - provider: ProviderDefinition, - source: str, - connections: list[dict[str, Any]], -) -> dict[str, Any]: - return { - "name": provider.name, - "display_name": provider.display_name, - "auth_type": provider.auth_type.value, - "auth_type_label": "OAuth 2.0" if provider.auth_type == AuthType.OAUTH2 else "API Key", - "api_url": _provider_api_url_label(provider), - "description": (provider.metadata or {}).get("description", ""), - "source": source, - "logo_initial": _logo_initial(provider.display_name or provider.name), - "status": _provider_status(provider.name, connections), - "connections": connections, - "scope_count": len(connections[0].get("scopes") or []) if connections else 0, - } - - -async def _provider_connection_groups( - request: Request, - *, - identity: str | None, - principal_id: str | None, - provider_name: str, -) -> list[dict[str, Any]]: - if not principal_id: - return [] - - bindings = request.app.state.store.principal_vault_bindings - vaults = request.app.state.store.vaults - groups: list[dict[str, Any]] = [] - - for binding in await bindings.list_for_principal(principal_id): - scoped_auth = build_auth_service( - request, - identity=identity, - principal_id=principal_id, - vault_id=binding.vault_id, - ) - provider_connections = next( - (group["connections"] for group in await scoped_auth.list_connections() if group["name"] == provider_name), - [], - ) - if not provider_connections: - continue - - vault_record = await vaults.get(binding.vault_id) - group_items: list[dict[str, Any]] = [] - for connection in provider_connections: - record = await scoped_auth.get_connection(provider_name, connection["connection_name"]) - group_items.append( - { - "connection_name": connection["connection_name"], - "identity": record.identity, - "status": connection["status"], - "href": f"/apps/{provider_name}/connections/{connection['connection_name']}", - } - ) - - groups.append( - { - "vault_label": (vault_record.handle if vault_record else "default").replace("-", " ").title(), - "connections": group_items, - } - ) - - return groups - - -def _provider_page_context( - request: Request, - auth: AuthService, - provider: ProviderDefinition, - api_url: str, - *, - grouped_connections: list[dict[str, Any]], - provider_client: ProviderClientRecord | None, - redirect_uri: str, - auth_url: str | None, - token_url: str | None, -) -> dict[str, Any]: - policy = _ui_policy(request, auth) - return _page_context( - request, - "applications", - auth=auth, - provider=provider, - connection=None, - grouped_connections=grouped_connections, - logo_initial=_logo_initial(provider.display_name or provider.name), - api_url=api_url, - auth_type_label="OAuth 2.0" if provider.auth_type == AuthType.OAUTH2 else "API Key", - client_id=provider_client.client_id if provider_client and policy["show_provider_client_details"] else None, - has_client_secret=bool( - provider_client and provider_client.client_secret and policy["show_provider_client_details"] - ), - redirect_uri=redirect_uri, - auth_url=auth_url, - token_url=token_url, - requires_named_login=any( - connection["connection_name"] == "default" - for group in grouped_connections - for connection in group["connections"] - ), - ) - - -def _connection_detail_context( - request: Request, - auth: AuthService, - provider: ProviderDefinition, - connection_record: ConnectionRecord, - api_url: str, -) -> dict[str, Any]: - return _page_context( - request, - "connections", - auth=auth, - provider=provider, - connection=connection_record, - logo_initial=_logo_initial(provider.display_name or provider.name), - api_url=api_url, - expires_label=_format_relative(connection_record.expires_at), - obtained_label=_format_relative(connection_record.obtained_at), - scopes=connection_record.scopes or [], - ) - - -async def _all_provider_views(auth: AuthService) -> list[dict[str, Any]]: - by_source = await auth.list_providers_by_source() - connections_by_provider = {group["name"]: group["connections"] for group in await auth.list_connections()} - views: list[dict[str, Any]] = [] - for source in ("bundled", "custom"): - for provider in by_source.get(source, []): - views.append(_build_provider_view(provider, source, connections_by_provider.get(provider.name, []))) - return views - - -def _build_connection_rows(providers: list[dict[str, Any]]) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for provider in providers: - for connection in provider["connections"]: - rows.append( - { - "provider_name": provider["name"], - "provider_display_name": provider["display_name"], - "connection_name": connection["connection_name"], - "status": connection["status"], - "auth_type_label": provider["auth_type_label"], - "href": f"/apps/{provider['name']}/connections/{connection['connection_name']}", - } - ) - return sorted(rows, key=lambda row: (row["provider_display_name"].lower(), row["connection_name"].lower())) - - -@router.get("/legacy", response_class=HTMLResponse) -async def overview( - request: Request, - auth: AuthService = Depends(require_ui_auth()), -) -> HTMLResponse: - providers = await _all_provider_views(auth) - connected = [p for p in providers if p["status"] != "available"] - available_count = len(providers) - len(connected) - oauth_count = sum(1 for p in connected if p["auth_type"] == AuthType.OAUTH2.value) - apikey_count = sum(1 for p in connected if p["auth_type"] == AuthType.API_KEY.value) - - # "Last activity" ~ most recent token issue/refresh time across connections. - last_activity_label: str | None = None - most_recent: datetime | None = None - for view in connected: - for conn in view["connections"]: - for ts_field in ("expires_at",): - ts = conn.get(ts_field) - if not ts: - continue - try: - parsed = datetime.fromisoformat(ts.replace("Z", "+00:00")) - except (TypeError, ValueError): - continue - if most_recent is None or parsed > most_recent: - most_recent = parsed - if most_recent is not None: - last_activity_label = _format_relative(most_recent) - - return templates.TemplateResponse( - request, - "overview.html", - _page_context( - request, - "overview", - auth=auth, - stats={ - "connected": len(connected), - "available": available_count, - "oauth": oauth_count, - "api_key": apikey_count, - }, - last_activity=last_activity_label or "—", - connected_providers=connected[:6], - ), - ) - - -@router.get("/applications", response_class=HTMLResponse) -async def applications( - request: Request, - auth: AuthService = Depends(require_ui_auth("/applications")), -) -> Response: - providers = [ - { - **provider, - "requires_named_login": any( - connection["connection_name"] == "default" for connection in provider["connections"] - ), - } - for provider in await _all_provider_views(auth) - ] - return templates.TemplateResponse( - request, - "applications.html", - _page_context(request, "applications", auth=auth, providers=providers), - ) - - -@router.get("/manage/connections", response_class=HTMLResponse) -async def connections( - request: Request, - auth: AuthService = Depends(require_ui_auth("/manage/connections")), -) -> Response: - providers = await _all_provider_views(auth) - rows = _build_connection_rows(providers) - return templates.TemplateResponse( - request, - "connections.html", - _page_context( - request, - "connections", - auth=auth, - connection_rows=rows, - total_connections=len(rows), - ), - ) - - -@router.get("/identity", response_class=HTMLResponse) -async def identity_page( - request: Request, - auth: AuthService = Depends(require_ui_auth("/identity")), -) -> Response: - claims = await request.app.state.identity_claim_registry.list_for_principal(request.state.ui_principal_id) - identities = [{"handle": claim.identity_handle, "is_active": False} for claim in claims] - return templates.TemplateResponse( - request, - "identity.html", - _page_context( - request, - "identity", - auth=auth, - identities=identities, - principal_id=auth.principal_id, - ), - ) - - -@router.get("/audit", response_class=HTMLResponse) -async def audit_page( - request: Request, - auth: AuthService = Depends(require_ui_auth("/audit")), -) -> Response: - if auth.principal_role != PrincipalRole.ADMIN: - return _admin_required_response( - "Admin access required", - "Audit events are available only to administrators.", - ) - - entries = await request.app.state.audit_log.list_events(limit=100) - rows = _audit_event_rows(entries) - return templates.TemplateResponse( - request, - "audit.html", - _page_context( - request, - "audit", - auth=auth, - audit_events=rows, - audit_total=len(rows), - ), - ) - - -@router.get("/apps/{provider_name}", response_class=HTMLResponse) -async def app_detail( - provider_name: str, - request: Request, - auth: AuthService = Depends(require_ui_auth()), - server_base_url: str = Depends(get_server_base_url), -) -> Response: - provider = await auth.get_provider(provider_name) - redirect_uri = build_callback_url(server_base_url) - api_url = _provider_api_url_label(provider) - policy = _ui_policy(request, auth) - if not policy["show_provider_client_details"]: - return templates.TemplateResponse( - request, - "app_detail_managed.html", - _page_context( - request, - "applications", - auth=auth, - provider=provider, - logo_initial=_logo_initial(provider.display_name or provider.name), - ), - ) - - client_record = await auth.get_provider_client(provider_name) - grouped_connections = await _provider_connection_groups( - request, - identity=auth.identity, - principal_id=auth.principal_id, - provider_name=provider_name, - ) - return templates.TemplateResponse( - request, - "app_provider.html", - _provider_page_context( - request, - auth, - provider, - api_url, - grouped_connections=grouped_connections, - provider_client=client_record, - redirect_uri=redirect_uri, - auth_url=provider.oauth.authorization_url if provider.oauth else None, - token_url=provider.oauth.token_url if provider.oauth else None, - ), - ) - - -@router.get("/apps/{provider_name}/connections/{connection_name}", response_class=HTMLResponse) -async def connection_detail( - provider_name: str, - connection_name: str, - request: Request, - auth: AuthService = Depends(require_ui_auth()), -) -> Response: - provider = await auth.get_provider(provider_name) - connection_record = await auth.get_connection(provider_name, connection_name) - api_url = _provider_api_url_label(provider) - common = _connection_detail_context(request, auth, provider, connection_record, api_url) - - if provider.auth_type == AuthType.OAUTH2: - return templates.TemplateResponse( - request, - "app_detail_oauth.html", - { - **common, - "access_token": connection_record.access_token, - "refresh_token": connection_record.refresh_token, - }, - ) - - return templates.TemplateResponse( - request, - "app_detail_apikey.html", - { - **common, - "api_key": connection_record.api_key, - "base_url": connection_record.base_url - or (provider.oauth.base_url if provider.oauth else None) - or _provider_primary_api_url(provider), - }, - ) - - -@router.post("/apps/{provider_name}/{connection_name}/disconnect") -async def disconnect_app( - provider_name: str, - connection_name: str, - request: Request, - auth: AuthService = Depends(require_ui_auth("/manage/connections")), -) -> Response: - """Disconnect a provider connection from the dashboard.""" - form = await request.form() - return_path = _account_auth_next_url(form.get("return_url") or "/manage/connections") - await auth.logout(provider_name, connection_name) - return _redirect(request, return_path) - - -@router.post("/apps/{provider_name}/connect") -async def connect_app( +@router.post("/auth/providers/{provider_name}/connect", include_in_schema=False) +async def connect_provider( provider_name: str, request: Request, background_tasks: BackgroundTasks, - auth: AuthService = Depends(require_ui_auth()), + auth: AuthService = Depends(require_ui_auth("/")), sessions: AuthSessionStore = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ) -> Response: - """Start a provider connection from the dashboard.""" + """Start a provider connection from the static dashboard.""" form = await request.form() connection_name = str(form.get("connection") or form.get("connection_name") or "default") force = str(form.get("force", "false")).lower() in {"1", "true", "on", "yes"} - return_path = _account_auth_next_url(form.get("return_url") or f"/apps/{provider_name}") + return_path = _account_auth_next_url(form.get("return_url") or "/") definition = await auth.get_provider(provider_name) flow = definition.flow @@ -746,45 +209,6 @@ async def connect_app( return _redirect(request, return_path) -@router.post("/apps/{provider_name}/configure") -async def configure_provider( - provider_name: str, - request: Request, - auth: AuthService = Depends(require_ui_auth()), - sessions: AuthSessionStore = Depends(get_auth_sessions), - server_base_url: str = Depends(get_server_base_url), -) -> Response: - """Open the provider configuration flow for deployment-scoped credentials.""" - form = await request.form() - return_path = _account_auth_next_url(form.get("return_url") or f"/apps/{provider_name}") - provider = await auth.get_provider(provider_name) - policy = _ui_policy(request, auth) - if not policy["show_provider_client_details"]: - return _admin_required_response( - "Admin access required", - "Provider configuration is available only to administrators.", - ) - if provider.auth_type != AuthType.OAUTH2: - return _redirect(request, f"/apps/{provider_name}") - - session = await sessions.create( - provider=provider_name, - identity=auth.identity, - principal_id=auth.principal_id, - connection_name="default", - flow_type=provider.flow.value, - ) - session.payload["provider_config_only"] = True - session.payload["existing_provider_client"] = (await auth.get_provider_client(provider_name)) is not None - session.payload["callback_url_override"] = build_callback_url(server_base_url) - session.payload["return_url"] = f"{server_base_url.rstrip('/')}{return_path}" - session.payload["input_fields"] = [ - field.model_dump(mode="json", exclude_none=True) for field in await auth.get_required_inputs(session) - ] - await sessions.save(session) - return _redirect(request, build_auth_input_url(server_base_url, session.session_id)) - - @router.post("/session", response_model=UiBootstrapResponse) async def start_ui_session( auth: AuthService = Depends(get_protected_auth_service), diff --git a/src/authsome/ui/static/style.css b/src/authsome/ui/static/style.css deleted file mode 100644 index 8704117f..00000000 --- a/src/authsome/ui/static/style.css +++ /dev/null @@ -1,1130 +0,0 @@ -:root { - --bg-body: #f5f6f8; - --bg-sidebar: #ffffff; - --bg-surface: #ffffff; - --bg-subtle: #eef2f5; - --bg-muted: #e6ebf0; - --border: #d7dde5; - --border-strong: #b9c3cf; - --accent: #1f6f5b; - --accent-dark: #155041; - --accent-soft: #e5f1ed; - --blue: #2f5d88; - --blue-soft: #e7eef6; - --warn: #9a5b13; - --warn-soft: #fff3dd; - --danger: #a33a3a; - --danger-soft: #f9e4e4; - --text: #16202a; - --text-secondary: #3d4a57; - --text-muted: #6d7a86; - --shadow: 0 1px 2px rgba(22, 32, 42, 0.06); - --radius-sm: 5px; - --radius-md: 6px; - --radius-lg: 8px; - --font-sans: Aptos, "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; - --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; - min-height: 100vh; - background: var(--bg-body); - color: var(--text); - font-family: var(--font-sans); - font-size: 14px; - line-height: 1.5; -} - -body { - display: grid; - grid-template-columns: 232px minmax(0, 1fr); -} - -a { - color: inherit; - text-decoration: none; -} - -button, -input { - font: inherit; -} - -.sidebar { - position: sticky; - top: 0; - height: 100vh; - display: flex; - flex-direction: column; - gap: 6px; - padding: 18px 14px 16px; - background: var(--bg-sidebar); - border-right: 1px solid var(--border); -} - -.brand { - padding: 5px 10px 16px; - font-size: 15px; - font-weight: 700; - letter-spacing: 0; -} - -.brand::after { - content: "."; - color: var(--accent); -} - -.nav-primary, -.nav-secondary { - display: flex; - flex-direction: column; - gap: 2px; -} - -.nav-spacer { - flex: 1; -} - -.nav-item { - display: flex; - align-items: center; - gap: 10px; - min-height: 36px; - padding: 8px 10px; - border: 1px solid transparent; - border-radius: var(--radius-md); - color: var(--text-secondary); - font-size: 13px; - transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.nav-item:hover { - background: var(--bg-subtle); - color: var(--text); -} - -.nav-item.active { - background: var(--accent-soft); - border-color: #b8d9cf; - color: var(--accent-dark); - font-weight: 600; -} - -.nav-item.disabled { - opacity: 0.45; - pointer-events: none; -} - -.nav-item svg { - width: 16px; - height: 16px; - flex-shrink: 0; -} - -.brand-foot { - padding: 8px 10px 2px; - color: var(--text-muted); - font-size: 11px; -} - -.main { - min-width: 0; - display: flex; - flex-direction: column; -} - -.topbar { - min-height: 57px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 30px; - background: rgba(255, 255, 255, 0.88); - border-bottom: 1px solid var(--border); - backdrop-filter: blur(8px); -} - -.topbar-meta, -.topbar-actions, -.page-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.topbar-actions { - justify-content: flex-end; -} - -.topbar-actions form { - margin: 0; -} - -.meta { - color: var(--text-muted); - font-size: 12.5px; -} - -.page { - width: 100%; - max-width: 1220px; - padding: 26px 30px 48px; -} - -.page-header { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 16px; - margin-bottom: 22px; -} - -.eyebrow { - margin-bottom: 4px; - color: var(--text-muted); - font-size: 11px; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; -} - -h1, -h2 { - margin: 0; - letter-spacing: 0; -} - -h1 { - font-size: 24px; - line-height: 1.2; - font-weight: 700; -} - -h2 { - font-size: 14px; - font-weight: 700; -} - -.subtitle { - margin: 5px 0 0; - color: var(--text-muted); - font-size: 13px; -} - -.muted, -.subtle { - color: var(--text-muted); -} - -.link { - color: var(--accent-dark); - font-size: 13px; - font-weight: 600; -} - -.link:hover { - text-decoration: underline; -} - -.btn, -.icon-btn { - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-surface); - color: var(--text); - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-height: 34px; - padding: 7px 12px; - font-size: 13px; - font-weight: 600; - white-space: nowrap; -} - -.btn:hover, -.icon-btn:hover { - background: var(--bg-subtle); - border-color: var(--border-strong); -} - -.btn svg { - width: 14px; - height: 14px; - flex-shrink: 0; -} - -.btn[disabled] { - cursor: not-allowed; - opacity: 0.55; -} - -.btn-sm { - min-height: 30px; - padding: 5px 10px; - font-size: 12px; -} - -.btn-primary { - background: var(--accent); - border-color: var(--accent); - color: #ffffff; -} - -.btn-primary:hover { - background: var(--accent-dark); - border-color: var(--accent-dark); -} - -.btn-secondary { - background: var(--bg-surface); - color: var(--text-secondary); -} - -.btn-light { - background: var(--blue-soft); - border-color: #c8d6e5; - color: var(--blue); -} - -.btn-warn-ghost { - background: var(--warn-soft); - border-color: #eed09f; - color: var(--warn); -} - -.btn-danger-ghost { - background: var(--danger-soft); - border-color: #efc3c3; - color: var(--danger); -} - -.btn-block { - width: 100%; -} - -.icon-btn { - width: 30px; - height: 30px; - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - padding: 0; -} - -.icon-btn svg { - width: 15px; - height: 15px; -} - -.pill { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 100%; - padding: 3px 8px; - border: 1px solid var(--border); - border-radius: 999px; - background: var(--bg-subtle); - color: var(--text-secondary); - font-size: 11.5px; - font-weight: 600; - line-height: 1.35; - white-space: nowrap; -} - -.pill-accent { - background: var(--accent-soft); - border-color: #b8d9cf; - color: var(--accent-dark); -} - -.pill-neutral { - background: var(--bg-subtle); - border-color: var(--border); - color: var(--text-secondary); -} - -.pill-warn { - background: var(--warn-soft); - border-color: #efd4aa; - color: var(--warn); -} - -.stat-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; - margin-bottom: 26px; -} - -.stat-card, -.card, -.panel, -.notice-band, -.provider-card, -.app-card, -.empty, -.docs-card, -.connection-row { - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow); -} - -.stat-card { - min-height: 118px; - display: flex; - flex-direction: column; - gap: 6px; - padding: 16px 18px; -} - -.stat-label, -.card-title { - color: var(--text-muted); - font-size: 11px; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; -} - -.stat-value { - font-size: 31px; - line-height: 1.1; - font-weight: 700; - letter-spacing: 0; -} - -.stat-value-sm { - font-size: 19px; - line-height: 1.25; -} - -.stat-foot { - margin-top: auto; - color: var(--text-muted); - font-size: 12px; -} - -.section { - margin-bottom: 26px; -} - -.section-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.provider-grid, -.app-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.provider-card { - min-height: 166px; - display: flex; - flex-direction: column; - gap: 9px; - padding: 15px; - transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; -} - -.provider-card:hover, -.app-card:hover, -.connection-row:hover, -.docs-card:hover { - border-color: var(--border-strong); - box-shadow: 0 2px 7px rgba(22, 32, 42, 0.08); -} - -.provider-card-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.logo { - width: 32px; - height: 32px; - border-radius: var(--radius-md); - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - background: var(--blue-soft); - color: var(--blue); - font-size: 13px; - font-weight: 700; -} - -.logo-lg { - width: 44px; - height: 44px; - font-size: 16px; -} - -.provider-card-name { - color: var(--text); - font-size: 14px; - font-weight: 700; -} - -.provider-card-desc { - min-height: 18px; - color: var(--text-muted); - font-size: 12.5px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.provider-card-foot { - margin-top: auto; - padding-top: 10px; - border-top: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - color: var(--text-muted); - font-size: 12px; -} - -.icon-gear { - display: inline-flex; - color: var(--text-muted); -} - -.notice-band { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - padding: 18px 20px; -} - -.notice-title { - margin-bottom: 3px; - font-size: 14px; - font-weight: 700; -} - -.notice-body { - max-width: 680px; - margin: 0; - color: var(--text-muted); - font-size: 13px; -} - -.toolbar { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 18px; -} - -.search { - min-height: 38px; - display: flex; - align-items: center; - gap: 8px; - flex: 1; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--bg-surface); - color: var(--text-muted); - box-shadow: var(--shadow); -} - -.search input { - width: 100%; - min-width: 0; - border: 0; - outline: 0; - background: transparent; - color: var(--text); - font-size: 13px; -} - -.filter-pills { - display: flex; - gap: 4px; - padding: 3px; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--bg-surface); -} - -.filter-pill { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 29px; - padding: 5px 11px; - border: 0; - border-radius: var(--radius-md); - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 12.5px; -} - -.filter-pill.active { - background: var(--bg-subtle); - color: var(--text); -} - -.filter-pill .count { - color: var(--accent-dark); - font-size: 12px; - font-weight: 700; -} - -.app-card { - position: relative; - min-height: 76px; - display: flex; - align-items: center; - gap: 12px; - padding: 13px 14px; -} - -.app-card.is-connected { - border-color: #b8d9cf; -} - -.app-card-hit { - position: absolute; - inset: 0; - z-index: 1; - border-radius: inherit; -} - -.app-card-text { - min-width: 0; - flex: 1; -} - -.app-card-name { - overflow: hidden; - color: var(--text); - font-size: 13.5px; - font-weight: 700; - text-overflow: ellipsis; - white-space: nowrap; -} - -.app-card-sub { - color: var(--text-muted); - font-size: 12px; -} - -.inline-action { - position: relative; - z-index: 2; - margin: 0; -} - -.as-button { - cursor: pointer; -} - -.empty { - padding: 38px 24px; - color: var(--text-muted); - text-align: center; -} - -.empty-grid { - grid-column: 1 / -1; -} - -.empty-title { - margin-bottom: 4px; - color: var(--text); - font-size: 14px; - font-weight: 700; -} - -.empty-body { - margin-bottom: 14px; - font-size: 13px; -} - -.empty .btn { - margin-top: 4px; -} - -.hidden { - display: none !important; -} - -.breadcrumb { - margin-bottom: 12px; - color: var(--text-muted); - font-size: 12.5px; -} - -.breadcrumb a { - color: var(--text-secondary); -} - -.breadcrumb a:hover { - color: var(--accent-dark); -} - -.breadcrumb .sep { - margin: 0 6px; - opacity: 0.6; -} - -.detail-header { - display: flex; - align-items: center; - gap: 14px; - margin-bottom: 18px; -} - -.detail-title { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - font-size: 23px; -} - -.detail-meta { - margin-top: 3px; - color: var(--text-muted); - font-size: 12.5px; -} - -.detail-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) 260px; - gap: 18px; - align-items: start; -} - -.detail-main, -.detail-side, -.connection-list, -.action-stack { - display: flex; - flex-direction: column; - gap: 12px; -} - -.detail-side { - position: sticky; - top: 72px; -} - -.docs-card { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 14px; -} - -.docs-icon { - width: 30px; - height: 30px; - border-radius: var(--radius-md); - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - background: var(--accent-soft); - color: var(--accent-dark); -} - -.docs-icon svg { - width: 16px; - height: 16px; -} - -.docs-title { - display: block; - font-size: 13px; - font-weight: 700; -} - -.docs-subtitle { - display: block; - margin-top: 1px; - color: var(--text-muted); - font-size: 12px; -} - -.card, -.panel { - padding: 16px 18px; -} - -.card-title { - margin-bottom: 10px; -} - -.card-body { - margin: 0; - color: var(--text-secondary); - font-size: 13px; -} - -.card-warn { - background: var(--warn-soft); - border-color: #efd4aa; -} - -.field { - margin-bottom: 12px; -} - -.field:last-child { - margin-bottom: 0; -} - -.field-label { - margin-bottom: 4px; - color: var(--text-muted); - font-size: 12px; - font-weight: 600; -} - -.field-row { - min-height: 34px; - display: flex; - align-items: center; - gap: 6px; - padding: 7px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: #fbfcfd; -} - -.field-row .mono { - min-width: 0; - flex: 1; - overflow: hidden; - color: var(--text-secondary); - text-overflow: ellipsis; - white-space: nowrap; -} - -.mono { - font-family: var(--font-mono); - font-size: 12.5px; -} - -.mask { - letter-spacing: 0; -} - -.scope-row { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.scope { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px 4px 10px; - border: 1px solid #b8d9cf; - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent-dark); - font-family: var(--font-mono); - font-size: 12px; -} - -.scope-x { - padding: 0 2px; - border: 0; - background: transparent; - color: var(--accent-dark); - cursor: pointer; - font-size: 14px; - line-height: 1; -} - -.scope.scope-add { - border-color: var(--border); - border-style: dashed; - background: var(--bg-surface); - color: var(--text-muted); - cursor: pointer; -} - -.kv { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - column-gap: 12px; - row-gap: 8px; - margin: 0; - font-size: 12.5px; -} - -.kv dt { - color: var(--text-muted); -} - -.kv dd { - min-width: 0; - margin: 0; - color: var(--text); -} - -.action-stack form { - margin: 0; -} - -.connection-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 14px; -} - -.connection-row-main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.connection-row-name { - color: var(--text); - font-size: 13.5px; - font-weight: 700; -} - -.connection-row-meta { - overflow: hidden; - color: var(--text-muted); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.connection-row-readonly { - padding: 10px 12px; - box-shadow: none; -} - -.connection-group + .connection-group { - margin-top: 14px; -} - -.identity-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 0; - border-bottom: 1px solid var(--border); -} - -.identity-row:first-child { - padding-top: 0; -} - -.identity-row:last-child { - padding-bottom: 0; - border-bottom: 0; -} - -.identity-name { - color: var(--text); - font-weight: 700; -} - -.identity-meta { - color: var(--text-muted); - font-size: 12px; -} - -.table-wrap { - width: 100%; - overflow-x: auto; -} - -.data-table { - width: 100%; - min-width: 760px; - border-collapse: collapse; - font-size: 13px; -} - -.data-table th, -.data-table td { - padding: 11px 12px; - border-bottom: 1px solid var(--border); - text-align: left; - vertical-align: top; -} - -.data-table th { - color: var(--text-muted); - font-size: 11px; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; -} - -.data-table tbody tr:last-child td { - border-bottom: 0; -} - -.table-primary { - color: var(--text); - font-weight: 700; -} - -.table-secondary { - margin-top: 2px; - color: var(--text-muted); - font-size: 11.5px; -} - -.audit-table td:nth-child(1) { - white-space: nowrap; -} - -.audit-metadata-row td { - padding-top: 0; - background: #fbfcfd; -} - -.metadata-list { - display: flex; - flex-wrap: wrap; - gap: 8px 14px; - margin: 0; - color: var(--text-muted); - font-size: 12px; -} - -.metadata-list div { - display: inline-flex; - gap: 5px; -} - -.metadata-list dt { - color: var(--text-muted); - font-weight: 700; -} - -.metadata-list dd { - margin: 0; - color: var(--text-secondary); -} - -.login-modal { - width: min(420px, calc(100vw - 32px)); - padding: 0; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--bg-surface); - color: var(--text); - box-shadow: 0 18px 44px rgba(22, 32, 42, 0.22); -} - -.login-modal::backdrop { - background: rgba(20, 29, 38, 0.44); -} - -.login-modal form { - display: flex; - flex-direction: column; - gap: 14px; - margin: 0; - padding: 18px; -} - -.login-modal-input { - width: 100%; - padding: 9px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: #fbfcfd; - color: var(--text); - font: inherit; -} - -@media (max-width: 980px) { - body { - grid-template-columns: 1fr; - } - - .sidebar { - position: static; - height: auto; - border-right: 0; - border-bottom: 1px solid var(--border); - } - - .nav-primary, - .nav-secondary { - flex-flow: row wrap; - } - - .nav-spacer, - .brand-foot { - display: none; - } - - .topbar, - .page-header, - .notice-band { - align-items: flex-start; - flex-direction: column; - } - - .topbar-actions, - .page-actions { - flex-wrap: wrap; - } - - .page { - padding: 22px 18px 36px; - } - - .stat-grid, - .provider-grid, - .app-grid, - .detail-grid { - grid-template-columns: 1fr; - } - - .detail-side { - position: static; - } -} diff --git a/src/authsome/ui/templates/_app_detail_shell.html b/src/authsome/ui/templates/_app_detail_shell.html deleted file mode 100644 index 94f113dc..00000000 --- a/src/authsome/ui/templates/_app_detail_shell.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}{{ provider.display_name }}{% endblock %} -{% block content %} - - -
- -
-

- {{ provider.display_name }} - {% if connection %} - ● Connected - {% else %} - Available - {% endif %} -

-
{% block detail_meta %}{% endblock %} · {{ api_url }}
-
-
- -
-
- {% block credentials %}{% endblock %} -
- - -
-{% endblock %} diff --git a/src/authsome/ui/templates/_layout.html b/src/authsome/ui/templates/_layout.html deleted file mode 100644 index 777b05d6..00000000 --- a/src/authsome/ui/templates/_layout.html +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - {% block title %}Authsome{% endblock %} · Authsome - - - - - -
-
- {% if show_identity %} -
- {% if role_label %}{{ role_label }}{% endif %} - Signed in as {{ ui_email or ui_identity }} -
- {% endif %} -
- {% if show_identity %} -
- -
- {% endif %} - - - GitHub - - - - PyPI - -
-
- -
- {% block content %}{% endblock %} -
-
- - - diff --git a/src/authsome/ui/templates/app_detail_apikey.html b/src/authsome/ui/templates/app_detail_apikey.html deleted file mode 100644 index 7b34c9fc..00000000 --- a/src/authsome/ui/templates/app_detail_apikey.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "_app_detail_shell.html" %} -{% block detail_meta %}API Key{% endblock %} - -{% block credentials %} -
-
API Credentials
-
-
API Key
-
- {% if api_key %}••••••••••••••••{% else %}—{% endif %} - {% if api_key %} - - - {% endif %} -
-
-
- -
-
Configuration
-
-
Base URL
-
{{ base_url or '—' }}
-
-
-{% endblock %} - -{% block side_rows %} -
Auth type
API Key
-{% endblock %} - -{% block actions %} - -
- -
-{% endblock %} diff --git a/src/authsome/ui/templates/app_detail_disconnected.html b/src/authsome/ui/templates/app_detail_disconnected.html deleted file mode 100644 index 472e7a33..00000000 --- a/src/authsome/ui/templates/app_detail_disconnected.html +++ /dev/null @@ -1,79 +0,0 @@ -{% extends "_app_detail_shell.html" %} -{% block detail_meta %}{{ auth_type_label }}{% endblock %} - -{% block credentials %} -
-
Setup
-

- {{ provider.display_name }} is not connected yet. Review the provider configuration and use Connect when you're ready. -

-
- -{% if provider.auth_type.value == "oauth2" %} -
-
{{ provider_management_label }}
- {% if show_provider_client_details %} -
-
Client ID
-
{{ client_id or 'Required during setup' }}
-
-
-
Client Secret
-
{% if has_client_secret %}••••••••••••••••{% else %}Required during setup{% endif %}
-
- {% else %} -

- Authsome manages the OAuth application for {{ provider.display_name }}. Start the connection flow and Authsome will use the configured client automatically. -

- {% endif %} -
-
Redirect URI
-
- {{ redirect_uri }} - -
-
-
- -
-
Endpoints
-
-
Authorization URL
-
{{ auth_url or '—' }}
-
-
-
Token URL
-
{{ token_url or '—' }}
-
-
-{% else %} -
-
API Credentials
-
-
API Key
-
Required during setup
-
-
- -
-
Configuration
-
-
Base URL
-
{{ base_url or '—' }}
-
-
-{% endif %} -{% endblock %} - -{% block side_rows %} -
Auth type
{{ auth_type_label }}
-{% endblock %} - -{% block actions %} -
- - -
-{% endblock %} diff --git a/src/authsome/ui/templates/app_detail_managed.html b/src/authsome/ui/templates/app_detail_managed.html deleted file mode 100644 index 6c070ed6..00000000 --- a/src/authsome/ui/templates/app_detail_managed.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}{{ provider.display_name }}{% endblock %} -{% block content %} - - -
- -
-

{{ provider.display_name }}

-
Managed by Authsome
-
-
- -
-
Provider configuration
-

- Authsome manages the OAuth application and related connections for {{ provider.display_name }}. - Provider configuration is available only to administrators. -

-
-{% endblock %} diff --git a/src/authsome/ui/templates/app_detail_oauth.html b/src/authsome/ui/templates/app_detail_oauth.html deleted file mode 100644 index 9df2e203..00000000 --- a/src/authsome/ui/templates/app_detail_oauth.html +++ /dev/null @@ -1,70 +0,0 @@ -{% extends "_app_detail_shell.html" %} -{% block detail_meta %}OAuth 2.0{% endblock %} - -{% block credentials %} -
-
Scopes
-
- {% for scope in scopes %} - {{ scope }} - {% endfor %} - -
-
- -
-
Tokens
-
-
Access Token
-
- {% if access_token %}••••••••••••••••{% else %}—{% endif %} - {% if access_token %} - - - {% endif %} -
-
- {% if refresh_token %} -
-
Refresh Token
-
- •••••••••••••••• - - -
-
- {% endif %} - {% if expires_label %} -
-
Expires
-
{{ expires_label }}
-
- {% endif %} -
-{% endblock %} - -{% block side_rows %} -{% if connection.account and connection.account.label %} -
Account
{{ connection.account.label }}
-{% endif %} -{% endblock %} - -{% block actions %} - -
- - - -
-
- -
-{% endblock %} diff --git a/src/authsome/ui/templates/app_provider.html b/src/authsome/ui/templates/app_provider.html deleted file mode 100644 index 7420be7f..00000000 --- a/src/authsome/ui/templates/app_provider.html +++ /dev/null @@ -1,128 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}{{ provider.display_name }}{% endblock %} -{% block content %} - - -
- -
-

{{ provider.display_name }}

-
{{ auth_type_label }} · {{ api_url }}
-
-
- -
-
- {% if provider.auth_type.value == "oauth2" %} -
-
{{ provider_management_label }}
- {% if show_provider_client_details %} -
-
Client ID
-
{{ client_id or 'Required during setup' }}
-
-
-
Client Secret
-
{% if has_client_secret %}••••••••••••••••{% else %}Required during setup{% endif %}
-
-
-
Redirect URI
-
- {{ redirect_uri }} - -
-
- {% else %} -

- This deployment manages the OAuth application for {{ provider.display_name }}. -

- {% endif %} -
- -
-
Replace provider credentials
-

Changing these credentials revokes existing connections for this provider. Continue only if you intend to reconnect them.

-
- -
-
Endpoints
-
-
Authorization URL
-
{{ auth_url or '—' }}
-
-
-
Token URL
-
{{ token_url or '—' }}
-
-
- {% endif %} - -
-
Existing connections
- {% if grouped_connections %} - {% for group in grouped_connections %} -
-
{{ group.vault_label }}
-
- {% for item in group.connections %} - -
-
{{ item.connection_name }}
-
{{ item.identity }}
-
- {{ item.status }} -
- {% endfor %} -
-
- {% endfor %} - {% else %} -

No connections for this provider yet.

- {% endif %} -
-
- - -
- - -{% endblock %} diff --git a/src/authsome/ui/templates/applications.html b/src/authsome/ui/templates/applications.html deleted file mode 100644 index 3039ee39..00000000 --- a/src/authsome/ui/templates/applications.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}Applications{% endblock %} -{% block content %} - - -
- -
- -
- {% for p in providers %} -
- - -
-
{{ p.display_name }}
-
{{ p.auth_type_label }}
-
- -
- {% endfor %} -
- - - - -{% endblock %} diff --git a/src/authsome/ui/templates/audit.html b/src/authsome/ui/templates/audit.html deleted file mode 100644 index d785d33c..00000000 --- a/src/authsome/ui/templates/audit.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}Audit{% endblock %} -{% block content %} - - -{% if audit_events %} -
-
- - - - - - - - - - - - - {% for event in audit_events %} - - - - - - - - - {% if event.metadata %} - - - - - {% endif %} - {% endfor %} - -
TimeEventActorTargetStatusSource
{{ event.time }} -
{{ event.event }}
-
{{ event.event_id }}
-
{{ event.actor }}{{ event.target }} - {% if event.status != "-" %} - {{ event.status }} - {% else %} - - - {% endif %} - {{ event.source }}
-
-
-{% else %} -
-
No audit events yet
-
Events will appear here after authentication, provider, or proxy activity is recorded.
-
-{% endif %} -{% endblock %} diff --git a/src/authsome/ui/templates/connections.html b/src/authsome/ui/templates/connections.html deleted file mode 100644 index f08cb16c..00000000 --- a/src/authsome/ui/templates/connections.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}Connections{% endblock %} -{% block content %} - - -
- -
- -{% if connection_rows %} - -{% else %} -
-
No connections yet
-
Go to Applications to configure a provider and start a new login.
- Add new connection -
-{% endif %} - - -{% endblock %} diff --git a/src/authsome/ui/templates/identity.html b/src/authsome/ui/templates/identity.html deleted file mode 100644 index 5d953dc2..00000000 --- a/src/authsome/ui/templates/identity.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}Identity{% endblock %} -{% block content %} - - -
-{% for identity in identities %} -
-
-
{{ identity.handle }}
-
Accepted principal claim
-
- Active -
-{% endfor %} -
-{% endblock %} diff --git a/src/authsome/ui/templates/overview.html b/src/authsome/ui/templates/overview.html deleted file mode 100644 index 9e49f410..00000000 --- a/src/authsome/ui/templates/overview.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "_layout.html" %} -{% block title %}Overview{% endblock %} -{% block content %} - - -
-
-
Connected Apps
-
{{ stats.connected }}
-
{{ stats.available }} more available
-
- -
-
Next Expiry
-
{{ last_activity }}
-
Across connected providers
-
- -
-
Auth Types
-
{{ stats.oauth }} / {{ stats.api_key }}
-
OAuth 2.0 / API Key
-
-
- -
-
-

Connected Providers

- Manage Connections -
- - {% if connected_providers %} - - {% else %} -
-
No connections yet
-
Connect your first provider to get started.
- Browse providers -
- {% endif %} -
- -{% if show_admin_sections %} -
-
-
Administrative controls
-

Provider configuration and audit events are restricted to administrators.

-
- Review audit -
-{% endif %} -{% endblock %} diff --git a/tests/proxy/test_proxy.py b/tests/proxy/test_proxy.py index 19a36907..08b14390 100644 --- a/tests/proxy/test_proxy.py +++ b/tests/proxy/test_proxy.py @@ -557,7 +557,7 @@ async def test_addon_denies_no_credentials_with_provider_hint_in_configured_deny body = flow.response.content.decode("utf-8") assert "openai" in body assert "authsome login openai" in body - assert f"{DEFAULT_SERVER_BASE_URL}/apps/openai" in body + assert f"{DEFAULT_SERVER_BASE_URL}/" in body events = [call.args[0] for call in auth.record_audit_event.await_args_list] assert { "event": "proxy_no_credentials", diff --git a/tests/server/test_ui_dashboard.py b/tests/server/test_ui_dashboard.py index c2552205..89f89dbf 100644 --- a/tests/server/test_ui_dashboard.py +++ b/tests/server/test_ui_dashboard.py @@ -6,28 +6,14 @@ from fastapi.testclient import TestClient from authsome import audit -from authsome.auth.models.connection import ConnectionRecord, ProviderClientRecord, ProviderMetadataRecord +from authsome.auth.models.connection import ConnectionRecord, ProviderMetadataRecord from authsome.auth.models.enums import AuthType, ConnectionStatus -from authsome.identity import create_identity, load_private_key -from authsome.identity.proof import create_proof_jwt +from authsome.identity import create_identity from authsome.server.app import create_app from authsome.server.credential_repository import build_store_key from authsome.utils import utc_now -def _auth_header(tmp_path: Path, method: str, path: str, *, handle: str) -> dict[str, str]: - identity = create_identity(tmp_path, handle) - token = create_proof_jwt( - private_key=load_private_key(tmp_path, identity.handle), - issuer=identity.did, - subject=identity.handle, - method=method, - path_query=path, - body=b"", - ) - return {"Authorization": f"PoP {token}"} - - def _register_identity_for_claim(client: TestClient, tmp_path: Path, handle: str) -> str: identity = create_identity(tmp_path, handle) response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) @@ -101,124 +87,34 @@ def _seed_connection( ) -def _seed_provider_client( - client: TestClient, - *, - provider: str, - client_id: str, - client_secret: str | None = None, -) -> None: - from authsome.auth.models.connection import ProviderClientRecord - - asyncio.run( - client.app.state.vault.put( - build_store_key(provider=provider, record_type="server"), - ProviderClientRecord( - provider=provider, - client_id=client_id, - client_secret=client_secret, - ).model_dump_json(), - collection="server", - ) - ) - - -def test_overview_navigation_shows_applications_connections_and_identity(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - response = client.get("/legacy") - - assert response.status_code == 200 - assert "Overview" in response.text - assert "Applications" in response.text - assert "Connections" in response.text - assert "Identity" in response.text - assert 'href="/audit"' in response.text - - -def test_applications_page_renders_provider_catalog(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - response = client.get("/applications") - - assert response.status_code == 200 - assert "Applications" in response.text - - -def test_applications_page_shows_provider_login_action(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - response = client.get("/applications") - - assert response.status_code == 200 - assert 'action="/apps/github/connect"' in response.text - assert "Login" in response.text - - -def test_identity_page_renders_informational_identity_view(monkeypatch, tmp_path: Path) -> None: +def test_legacy_server_rendered_routes_are_removed(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - response = client.get("/identity") - - assert response.status_code == 200 - assert "Identity" in response.text - assert "steady-wisely-boldly-0042" in response.text + responses = { + path: client.get(path, follow_redirects=False) + for path in ( + "/legacy", + "/applications", + "/manage/connections", + "/identity", + "/audit", + "/apps/github", + "/apps/github/connections/default", + "/static/style.css", + ) + } - -def test_account_identity_page_lists_all_account_claims(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - first_claim = _register_identity_for_claim(client, tmp_path, "steady-wisely-boldly-0042") - registered = client.post( - "/auth/register", - data={"email": "dev@example.com", "password": "password-1", "next": first_claim}, - follow_redirects=False, - ) - assert registered.status_code == 303 - assert client.post(f"{first_claim}/confirm", follow_redirects=False).status_code == 303 - - second_claim = _register_identity_for_claim(client, tmp_path, "brave-softly-surely-0043") - assert "dev@example.com" in client.get(second_claim).text - assert client.post(f"{second_claim}/confirm", follow_redirects=False).status_code == 303 - - response = client.get("/identity") - - assert response.status_code == 200 - assert "Signed in as dev@example.com" in response.text - assert "steady-wisely-boldly-0042" in response.text - assert "brave-softly-surely-0043" in response.text - - -def test_audit_page_renders_recent_events_for_admin(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - audit.emit_event( - "credentials_exported", - source="external", - identity="steady-wisely-boldly-0042", - provider="github", - status="ok", - request_id="req-123", - ) - response = client.get("/audit") - - assert response.status_code == 200 - assert "Audit Log" in response.text - assert "Credentials exported" in response.text - assert "steady-wisely-boldly-0042" in response.text - assert "github" in response.text - assert "req-123" in response.text + assert {path: response.status_code for path, response in responses.items()} == { + "/legacy": 404, + "/applications": 404, + "/manage/connections": 404, + "/identity": 404, + "/audit": 404, + "/apps/github": 404, + "/apps/github/connections/default": 404, + "/static/style.css": 404, + } def test_browser_session_can_read_existing_daemon_json_routes(monkeypatch, tmp_path: Path) -> None: @@ -255,199 +151,7 @@ def test_browser_session_can_read_existing_daemon_json_routes(monkeypatch, tmp_p assert audit_events.json()["entries"][0]["provider"] == "github" -def test_non_admin_ui_hides_audit_and_provider_configuration(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "admin-steadily-surely-0041", email="admin@example.com") - _register_identity(client, tmp_path, "user-steadily-surely-0042", email="user@example.com") - _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="secret-123") - - overview = client.get("/legacy") - provider = client.get("/apps/github") - configure = client.post("/apps/github/configure", follow_redirects=False) - audit_page = client.get("/audit") - - assert overview.status_code == 200 - assert 'href="/audit"' not in overview.text - assert provider.status_code == 200 - assert 'action="/apps/github/configure"' not in provider.text - assert "Client ID" not in provider.text - assert configure.status_code == 403 - assert "Provider configuration is available only to administrators." in configure.text - assert audit_page.status_code == 403 - assert "Audit events are available only to administrators." in audit_page.text - - -def test_account_applications_redirects_to_ui_login_entry(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - response = client.get("/applications", follow_redirects=False) - - assert response.status_code == 303 - assert response.headers["location"] == "/?next=%2Fapplications" - - -def test_provider_page_shows_provider_configuration_not_connection_tokens(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="github", - auth_type=AuthType.OAUTH2, - access_token="gh-access-token", - refresh_token="gh-refresh-token", - ) - response = client.get("/apps/github") - - assert response.status_code == 200 - assert "OAuth Application" in response.text or "Managed by Authsome" in response.text - assert "Access Token" not in response.text - - -def test_named_connection_detail_route_exists(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="github", - auth_type=AuthType.OAUTH2, - access_token="gh-access-token", - refresh_token="gh-refresh-token", - ) - response = client.get("/apps/github/connections/default") - - assert response.status_code == 200 - - -def test_named_connection_detail_page_shows_oauth_tokens(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="github", - auth_type=AuthType.OAUTH2, - access_token="gh-access-token", - refresh_token="gh-refresh-token", - ) - response = client.get("/apps/github/connections/default") - - assert response.status_code == 200 - assert "Access Token" in response.text - assert "Refresh Token" in response.text - assert "Client ID" not in response.text - assert "Client Secret" not in response.text - assert "Redirect URI" not in response.text - assert "Authorization URL" not in response.text - assert "Token URL" not in response.text - - -def test_named_connection_detail_page_shows_api_key(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="openai", - auth_type=AuthType.API_KEY, - api_key="sk-test-key", - ) - response = client.get("/apps/openai/connections/default") - - assert response.status_code == 200 - assert "API Credentials" in response.text - - -def test_provider_page_for_api_key_provider_omits_provider_setup_section(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="openai", - auth_type=AuthType.API_KEY, - api_key="sk-test-key", - ) - response = client.get("/apps/openai") - - assert response.status_code == 200 - assert "OAuth Application" not in response.text - assert "API Credentials" not in response.text - - -def test_provider_page_lists_existing_connections_as_read_only_context(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="github", - auth_type=AuthType.OAUTH2, - access_token="gh-access-token", - refresh_token="gh-refresh-token", - ) - response = client.get("/apps/github") - - assert response.status_code == 200 - assert "Existing connections" in response.text - - -def test_connections_page_renders_connection_rows(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="github", - auth_type=AuthType.OAUTH2, - access_token="gh-access-token", - refresh_token="gh-refresh-token", - ) - response = client.get("/manage/connections") - - assert response.status_code == 200 - assert "Add new connection" in response.text - assert "connection-row" in response.text - - -def test_provider_login_modal_copy_is_rendered_when_default_exists(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_connection( - client, - identity="steady-wisely-boldly-0042", - provider="github", - auth_type=AuthType.OAUTH2, - access_token="gh-access-token", - refresh_token="gh-refresh-token", - ) - response = client.get("/applications") - - assert response.status_code == 200 - assert "Connection name" in response.text - - -def test_connect_app_accepts_connection_name_fallback(monkeypatch, tmp_path: Path) -> None: +def test_connect_provider_accepts_connection_name_fallback(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with TestClient(create_app()) as client: @@ -461,91 +165,22 @@ def test_connect_app_accepts_connection_name_fallback(monkeypatch, tmp_path: Pat refresh_token="gh-refresh-token", ) response = client.post( - "/apps/github/connect", + "/auth/providers/github/connect", data={"connection_name": "work"}, follow_redirects=False, ) - - assert response.status_code == 303 - assert "/auth/sessions/" in response.headers["location"] - assert any( - session.provider == "github" and session.connection_name == "work" - for session in client.app.state.auth_sessions._sessions.values() - ) - - -def test_provider_page_shows_configure_action_for_oauth(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="secret-123") - response = client.get("/apps/github") - - assert response.status_code == 200 - assert 'action="/apps/github/configure"' in response.text - assert "Replace" in response.text - - -def test_provider_configure_route_opens_edit_flow_with_existing_values(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="secret-123") - response = client.post("/apps/github/configure", follow_redirects=False) - - assert response.status_code == 303 - assert "/auth/sessions/" in response.headers["location"] - session = next(iter(client.app.state.auth_sessions._sessions.values())) - assert session.payload["provider_config_only"] is True - fields = session.payload["input_fields"] - assert any(field["name"] == "client_id" and field["default"] == "cid-123" for field in fields) - - -def test_account_admin_provider_configure_route_opens_edit_flow(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - claim_path = _register_identity_for_claim(client, tmp_path, "steady-wisely-boldly-0042") - registered = client.post( - "/auth/register", - data={"email": "dev@example.com", "password": "password-1", "next": claim_path}, - follow_redirects=False, + session = next( + session + for session in client.app.state.auth_sessions._sessions.values() + if session.provider == "github" and session.connection_name == "work" ) - assert registered.status_code == 303 - assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == 303 - - _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="secret-123") - response = client.post("/apps/github/configure", follow_redirects=False) assert response.status_code == 303 assert "/auth/sessions/" in response.headers["location"] - session = next(iter(client.app.state.auth_sessions._sessions.values())) - assert session.payload["provider_config_only"] is True - fields = session.payload["input_fields"] - assert any(field["name"] == "client_id" and field["default"] == "cid-123" for field in fields) + assert session.payload["return_url"].endswith("/") -def test_provider_configure_input_page_shows_revoke_warning(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") - _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="secret-123") - configure = client.post("/apps/github/configure", follow_redirects=False) - response = client.get(configure.headers["location"]) - - assert response.status_code == 200 - assert "Changing these credentials will revoke existing connections for this provider." in response.text - client_id_position = response.text.index('for="client_id">Client ID') - client_secret_position = response.text.index('for="client_secret">Client Secret') - advanced_position = response.text.index("
Advanced options") - assert client_id_position < client_secret_position < advanced_position - assert "Client Secret (Optional)" not in response.text - - -def test_provider_config_submit_replaces_client_and_revokes_connections(monkeypatch, tmp_path: Path) -> None: +def test_connect_provider_redirects_to_root_for_existing_connection(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with TestClient(create_app()) as client: @@ -558,25 +193,7 @@ def test_provider_config_submit_replaces_client_and_revokes_connections(monkeypa access_token="gh-access-token", refresh_token="gh-refresh-token", ) - _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="secret-123") - - configure = client.post("/apps/github/configure", follow_redirects=False) - session_id = configure.headers["location"].rstrip("/").split("/")[-2] - response = client.post( - f"/auth/sessions/{session_id}/input", - data={"client_id": "cid-456", "client_secret": "secret-456"}, - follow_redirects=False, - ) - - provider_client = asyncio.run( - client.app.state.vault.get(build_store_key(provider="github", record_type="server"), collection="server") - ) - connections_page = client.get("/manage/connections") + response = client.post("/auth/providers/github/connect", follow_redirects=False) assert response.status_code == 303 - assert response.headers["location"].endswith("/apps/github") - assert provider_client is not None - provider_client_record = ProviderClientRecord.model_validate_json(provider_client) - assert provider_client_record.client_id == "cid-456" - assert provider_client_record.scopes == ["repo", "read:user"] - assert "No connections yet" in connections_page.text + assert response.headers["location"] == "/" diff --git a/tests/server/test_ui_sessions.py b/tests/server/test_ui_sessions.py index 173c6748..9417e343 100644 --- a/tests/server/test_ui_sessions.py +++ b/tests/server/test_ui_sessions.py @@ -4,11 +4,9 @@ from fastapi.testclient import TestClient -from authsome.auth.models.connection import ProviderClientRecord from authsome.identity import create_identity, load_private_key from authsome.identity.proof import create_proof_jwt from authsome.server.app import create_app -from authsome.server.credential_repository import build_store_key from authsome.server.ui_sessions import UiSessionStore @@ -31,26 +29,6 @@ def _register_identity(client: TestClient, tmp_path: Path, handle: str) -> None: assert response.status_code == 200 -def _seed_provider_client( - client: TestClient, - *, - provider: str, - client_id: str, - client_secret: str | None = None, -) -> None: - asyncio.run( - client.app.state.vault.put( - build_store_key(provider=provider, record_type="server"), - ProviderClientRecord( - provider=provider, - client_id=client_id, - client_secret=client_secret, - ).model_dump_json(), - collection="server", - ) - ) - - def _claim_identity_via_account_ui(client: TestClient, tmp_path: Path, handle: str, email: str) -> None: identity = create_identity(tmp_path, handle) response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) @@ -165,47 +143,13 @@ def test_account_homepage_registration_redirects_to_dashboard(monkeypatch, tmp_p assert "Authsome Dashboard" in dashboard_response.text -def test_account_ui_hides_server_managed_oauth_client_details(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _claim_identity_via_account_ui(client, tmp_path, "admin-ready-boldly-0001", "admin@example.com") - _claim_identity_via_account_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") - vault = client.app.state.vault - key = build_store_key(provider="github", record_type="server") - record = ProviderClientRecord(provider="github", client_id="cid-123", client_secret="top-secret") - asyncio.run(vault.put(key, record.model_dump_json(), collection="server")) - - response = client.get("/apps/github") - - assert response.status_code == 200 - assert "cid-123" not in response.text - assert "manages the OAuth application" in response.text - assert "Existing connections" not in response.text - - -def test_account_admin_ui_shows_provider_client_details(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with TestClient(create_app()) as client: - _claim_identity_via_account_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") - _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="top-secret") - response = client.get("/apps/github") - - assert response.status_code == 200 - assert "cid-123" in response.text - assert "Existing connections" in response.text - assert "manages the OAuth application" not in response.text - assert 'action="/apps/github/configure"' in response.text - - def test_account_ui_connect_starts_principal_scoped_session_without_pop(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with TestClient(create_app()) as client: _claim_identity_via_account_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") - response = client.post("/apps/openai/connect", follow_redirects=False) + response = client.post("/auth/providers/openai/connect", follow_redirects=False) session = next(iter(client.app.state.auth_sessions._sessions.values())) assert response.status_code == 303 @@ -213,6 +157,7 @@ def test_account_ui_connect_starts_principal_scoped_session_without_pop(monkeypa assert session.identity is None assert session.principal_id is not None assert session.payload["ui_session_required"] is True + assert session.payload["return_url"].endswith("/") def test_account_auth_rejects_external_next_redirect(monkeypatch, tmp_path: Path) -> None: diff --git a/ui/src/components/authsome-dashboard.tsx b/ui/src/components/authsome-dashboard.tsx index c8e9ba9f..e1252f24 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -446,7 +446,7 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr Login ) : ( -
+