feat(setup): /setup?role=analyst|admin branching with role tiles
This commit is contained in:
parent
54f83c281c
commit
f731ee7897
5 changed files with 173 additions and 6 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -10,6 +10,16 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`/setup?role=analyst|admin` query-param branching**: the setup page now
|
||||
renders two role tiles (Analyst workspace / Admin CLI) at the top, with
|
||||
the matching tile styled as active. `?role=analyst` short-circuits the
|
||||
bash bootstrap to the trimmed analyst-workspace flow (no marketplace,
|
||||
no plugins). Default is `admin` — unspecified or invalid values fall
|
||||
back to the existing admin layout, so any caller that doesn't pass
|
||||
`?role=` keeps the byte-identical pre-Task-4 page. `/install` still
|
||||
302-redirects to `/setup`.
|
||||
|
||||
## [0.32.0] — 2026-05-04
|
||||
|
||||
Closes #160. Headline fix: `da query --remote` now resolves
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import logging
|
|||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import duckdb
|
||||
|
|
@ -717,11 +717,19 @@ async def activity_center(
|
|||
@router.get("/setup", response_class=HTMLResponse)
|
||||
async def setup_page(
|
||||
request: Request,
|
||||
role: Literal["analyst", "admin"] = Query(default="admin"),
|
||||
user: Optional[dict] = Depends(get_optional_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Setup instructions for the local agent (CLI + Claude Code).
|
||||
|
||||
The `role` query param picks the layout:
|
||||
- `admin` (default) → full marketplace + skills + diagnose flow,
|
||||
byte-identical to the pre-Task-4 page for any caller that doesn't
|
||||
pass `?role=`.
|
||||
- `analyst` → trimmed workspace-bootstrap flow rendered by
|
||||
`_resolve_analyst_lines` (no marketplace, no plugins).
|
||||
|
||||
When an admin override is saved, the override replaces the auto-generated
|
||||
setup_instructions output everywhere (both the /setup page display and the
|
||||
dashboard clipboard CTA). When no override is set, the live default from
|
||||
|
|
@ -734,6 +742,9 @@ async def setup_page(
|
|||
base_url = str(request.base_url).rstrip("/")
|
||||
|
||||
# Determine the script text: override (Jinja2-rendered) or live default.
|
||||
# The override is role-agnostic by design — admins who set an override are
|
||||
# opting into the exact text they wrote, regardless of which tile the
|
||||
# caller picked. Only the live default branches on `role`.
|
||||
row = WelcomeTemplateRepository(conn).get()
|
||||
override_content = row.get("content")
|
||||
if override_content:
|
||||
|
|
@ -748,9 +759,13 @@ async def setup_page(
|
|||
setup_script_text = _sanitize_banner_html(template.render(**ctx_vars))
|
||||
except (TemplateError, Exception) as exc:
|
||||
logger.warning("setup_page: override render failed (%s); falling back to default", exc)
|
||||
setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url)
|
||||
setup_script_text = compute_default_agent_prompt(
|
||||
conn, user=user, server_url=base_url, role=role,
|
||||
)
|
||||
else:
|
||||
setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url)
|
||||
setup_script_text = compute_default_agent_prompt(
|
||||
conn, user=user, server_url=base_url, role=role,
|
||||
)
|
||||
|
||||
# Split for the legacy setup_instructions_lines list variable that the
|
||||
# Jinja2 partial (_claude_setup_instructions.jinja) uses.
|
||||
|
|
@ -766,6 +781,7 @@ async def setup_page(
|
|||
# Override both variables so the partial and the JS array stay in sync.
|
||||
setup_instructions_lines=setup_instructions_lines,
|
||||
setup_script_text=setup_script_text,
|
||||
role=role,
|
||||
)
|
||||
return templates.TemplateResponse(request, "install.html", ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -629,6 +629,46 @@
|
|||
.manual-body { padding: 16px 18px 18px; }
|
||||
}
|
||||
|
||||
/* ── Role tiles (Analyst vs Admin) ── */
|
||||
.role-tiles {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.role-tile {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
border: 2px solid var(--border, #e1e4e8);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
display: block;
|
||||
}
|
||||
.role-tile:hover {
|
||||
border-color: var(--primary, #0073D1);
|
||||
}
|
||||
.role-tile.is-active {
|
||||
border-color: var(--primary, #0073D1);
|
||||
background: var(--primary-light, rgba(0, 115, 209, 0.08));
|
||||
}
|
||||
.role-tile h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.role-tile p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.role-tiles { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Admin-configured banner (above setup commands) ── */
|
||||
.setup-banner {
|
||||
background: var(--background, #f6f7f9);
|
||||
|
|
@ -663,6 +703,22 @@
|
|||
|
||||
<main class="main">
|
||||
|
||||
<!-- ═══════════════ ROLE TILES ═══════════════ -->
|
||||
<nav class="role-tiles" aria-label="Choose setup role">
|
||||
<a href="/setup?role=analyst"
|
||||
class="role-tile{% if role == 'analyst' %} is-active{% endif %}"
|
||||
{% if role == 'analyst' %}aria-current="page"{% endif %}>
|
||||
<h3>Analyst workspace</h3>
|
||||
<p>Bootstrap a workspace folder with CLAUDE.md, hooks, and synced data.</p>
|
||||
</a>
|
||||
<a href="/setup?role=admin"
|
||||
class="role-tile{% if role != 'analyst' %} is-active{% endif %}"
|
||||
{% if role != 'analyst' %}aria-current="page"{% endif %}>
|
||||
<h3>Admin CLI</h3>
|
||||
<p>Install the CLI, register the marketplace, set up admin tooling.</p>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- ═══════════════ ADMIN BANNER (optional) ═══════════════ -->
|
||||
{% if banner_html %}<div class="setup-banner">{{ banner_html | safe }}</div>{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import os
|
|||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import duckdb
|
||||
|
|
@ -130,6 +130,7 @@ def compute_default_agent_prompt(
|
|||
*,
|
||||
user: dict[str, Any] | None,
|
||||
server_url: str,
|
||||
role: Literal["analyst", "admin"] = "admin",
|
||||
) -> str:
|
||||
"""Return the live default setup script from setup_instructions.resolve_lines().
|
||||
|
||||
|
|
@ -140,6 +141,10 @@ def compute_default_agent_prompt(
|
|||
``conn`` and ``user`` are forwarded to resolve the RBAC-filtered plugin
|
||||
install list (anonymous visitors / no conn get the no-marketplace layout).
|
||||
``server_url`` is used to derive the server host for the marketplace block.
|
||||
|
||||
``role`` selects the layout: ``"admin"`` (default) keeps the existing
|
||||
full bootstrap, ``"analyst"`` short-circuits to the trimmed analyst
|
||||
workspace flow (no marketplace, plugins forced empty).
|
||||
"""
|
||||
try:
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
|
|
@ -148,8 +153,12 @@ def compute_default_agent_prompt(
|
|||
_wheel = _find_wheel()
|
||||
_wheel_filename = _wheel.name if _wheel else "agnes.whl"
|
||||
|
||||
# Analyst flow has no marketplace concept — skip the RBAC plugin
|
||||
# resolution entirely so the analyst tile renders the same lines for
|
||||
# everyone (and so resolve_lines's analyst short-circuit fires
|
||||
# regardless of whether the caller has plugin grants).
|
||||
plugin_install_names: list[str] = []
|
||||
if user and conn is not None:
|
||||
if role == "admin" and user and conn is not None:
|
||||
try:
|
||||
from src import marketplace_filter
|
||||
plugin_install_names = [
|
||||
|
|
@ -179,6 +188,7 @@ def compute_default_agent_prompt(
|
|||
self_signed_tls=self_signed_tls,
|
||||
server_host=server_host,
|
||||
ca_pem=ca_pem,
|
||||
role=role,
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception:
|
||||
|
|
|
|||
75
tests/test_setup_page_roles.py
Normal file
75
tests/test_setup_page_roles.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Tests for /setup role query-param branching.
|
||||
|
||||
Task 4 wires `?role=analyst|admin` through the /setup route handler so the
|
||||
template can render two role tiles and the renderer can pick the right
|
||||
layout (admin = full marketplace/skills/diagnose flow; analyst = trimmed
|
||||
workspace-bootstrap flow). Default is `admin` to preserve existing behavior.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""TestClient against a freshly-built FastAPI app rooted at tmp_path.
|
||||
|
||||
Mirrors the `web_client` fixture in tests/test_web_ui.py — we re-create
|
||||
the app so the DuckDB singleton picks up the per-test DATA_DIR rather
|
||||
than leaking state across tests on the same xdist worker.
|
||||
"""
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("TESTING", "1")
|
||||
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-min-32-characters!!")
|
||||
(tmp_path / "state").mkdir()
|
||||
(tmp_path / "analytics").mkdir()
|
||||
(tmp_path / "extracts").mkdir()
|
||||
from src.db import close_system_db
|
||||
close_system_db()
|
||||
from app.main import create_app
|
||||
app = create_app()
|
||||
yield TestClient(app)
|
||||
close_system_db()
|
||||
|
||||
|
||||
def test_setup_page_default_role_is_admin(client):
|
||||
"""No `role` query param → admin layout (default, preserves existing flow)."""
|
||||
resp = client.get("/setup", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
# Both tiles present in markup; admin tile is the active one.
|
||||
assert "role=analyst" in text
|
||||
assert "role=admin" in text or 'href="/setup"' in text
|
||||
# Active state lives on the admin tile when role=admin (default).
|
||||
# Asserting the tile labels are both rendered keeps the assertion
|
||||
# robust against future styling tweaks.
|
||||
assert "Analyst workspace" in text
|
||||
assert "Admin CLI" in text
|
||||
|
||||
|
||||
def test_setup_page_analyst_role(client):
|
||||
"""`?role=analyst` → analyst tile is the active one."""
|
||||
resp = client.get("/setup?role=analyst", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
assert "Analyst workspace" in text
|
||||
assert "Admin CLI" in text
|
||||
# The page must reflect the analyst selection somewhere — either via
|
||||
# the active-state CSS class or the `role=analyst` link being rendered.
|
||||
assert "role=analyst" in text
|
||||
|
||||
|
||||
def test_install_redirects_to_setup(client):
|
||||
"""`/install` legacy path keeps redirecting to `/setup` (302/307)."""
|
||||
resp = client.get("/install", follow_redirects=False)
|
||||
assert resp.status_code in (302, 307)
|
||||
assert "/setup" in resp.headers["location"]
|
||||
|
||||
|
||||
def test_setup_page_invalid_role_falls_back(client):
|
||||
"""Invalid role values must NOT 500 — either FastAPI's Literal
|
||||
validation rejects with 422, or the route quietly falls back to admin.
|
||||
Both are acceptable; what's not acceptable is an unhandled exception.
|
||||
"""
|
||||
resp = client.get("/setup?role=hacker", follow_redirects=True)
|
||||
assert resp.status_code in (200, 422)
|
||||
Loading…
Reference in a new issue