fix(setup): default /setup to analyst, hide admin tile from non-admins

Three coupled UX fixes for the analyst-onboarding flow:

1. Dashboard "Setup a new Claude Code" CTA was rendering admin paste
   prompt for everyone (analysts couldn't actually execute the marketplace
   plugin install / skills setup steps). render_agent_prompt_banner now
   picks role based on user.is_admin — analysts get the analyst flow.

2. /setup default role changed from admin to analyst. Most visitors are
   analysts; admin layout is opt-in via the admin tile or ?role=admin.

3. Admin tile is admin-only on the role-tile nav. Non-admins see only
   the analyst tile. Server-side: non-admin requesting ?role=admin is
   silently downgraded to analyst (otherwise they'd see admin paste
   prompt despite no tile).

Tests:
- New: test_setup_page_admin_tile_hidden_for_non_admin (anonymous client
  can't see "Admin CLI" or role=admin link)
- New: test_setup_page_admin_role_downgraded_for_non_admin (anonymous
  ?role=admin → analyst layout, no marketplace step in clipboard)
- New: test_install_preview_default_role_is_analyst (admin signing in to
  bare /setup gets analyst clipboard by default)
- Renamed: test_setup_page_default_role_is_admin → ..._is_analyst
- Updated: test_setup_page_admin_clipboard_renders_admin_layout uses
  FastAPI dependency_overrides to inject admin user (admin layout is
  now admin-gated)
- Updated: test_install_preview_visible_for_signed_in_user explicitly
  passes ?role=admin to exercise admin layout
This commit is contained in:
ZdenekSrotyr 2026-05-04 20:20:37 +02:00
parent d8dc7c7799
commit 92d477e422
5 changed files with 104 additions and 32 deletions

View file

@ -717,18 +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"),
role: Literal["analyst", "admin"] = Query(default="analyst"),
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
- `analyst` (default) trimmed workspace-bootstrap flow rendered by
`_resolve_analyst_lines` (no marketplace, no plugins).
- `admin` full marketplace + skills + diagnose flow. Admin-only:
non-admin callers asking for `?role=admin` are silently downgraded
to `analyst` so the page never shows them instructions they can't
execute.
When an admin override is saved, the override replaces the auto-generated
setup_instructions output everywhere (both the /setup page display and the
@ -739,6 +740,13 @@ async def setup_page(
from src.welcome_template import compute_default_agent_prompt, _sanitize_banner_html
from jinja2 import Environment, StrictUndefined, TemplateError
# Gate the admin layout on user.is_admin. Non-admins requesting `?role=admin`
# silently fall back to analyst — admin instructions reference admin-only
# endpoints (marketplace registration, skills install) that a non-admin
# PAT can't authenticate against.
if role == "admin" and not (user and user.get("is_admin")):
role = "analyst"
base_url = str(request.base_url).rstrip("/")
# Determine the script text: override (Jinja2-rendered) or live default.

View file

@ -704,6 +704,8 @@
<main class="main">
<!-- ═══════════════ ROLE TILES ═══════════════ -->
{# Admin tile is admin-only — non-admins see the analyst tile alone. #}
{% set _show_admin_tile = user and user.is_admin %}
<nav class="role-tiles" aria-label="Choose setup role">
<a href="/setup?role=analyst"
class="role-tile{% if role == 'analyst' %} is-active{% endif %}"
@ -711,12 +713,14 @@
<h3>Analyst workspace</h3>
<p>Bootstrap a workspace folder with CLAUDE.md, hooks, and synced data.</p>
</a>
{% if _show_admin_tile %}
<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>
{% endif %}
</nav>
<!-- ═══════════════ ADMIN BANNER (optional) ═══════════════ -->

View file

@ -244,4 +244,11 @@ def render_agent_prompt_banner(
# Fall through to default
# No override (or broken override) — return live default bash script.
return compute_default_agent_prompt(conn, user=user, server_url=server_url)
# Pick role by user identity: admins get the full CLI install + marketplace
# flow (existing behavior). Everyone else gets the analyst workspace
# bootstrap. The dashboard CTA hits this path; without role-by-identity,
# analysts would get admin instructions they can't actually execute.
role = "admin" if (user and user.get("is_admin")) else "analyst"
return compute_default_agent_prompt(
conn, user=user, server_url=server_url, role=role,
)

View file

@ -32,19 +32,16 @@ def client(tmp_path, monkeypatch):
close_system_db()
def test_setup_page_default_role_is_admin(client):
"""No `role` query param → admin layout (default, preserves existing flow)."""
def test_setup_page_default_role_is_analyst(client):
"""No `role` query param → analyst layout (most users are analysts;
admin layout is opt-in via the admin tile, which only renders to admins)."""
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.
# Analyst tile rendered; analyst layout is what the unauthenticated /
# non-admin caller gets by default.
assert "Analyst workspace" in text
assert "Admin CLI" in text
assert "role=analyst" in text
def test_setup_page_analyst_role(client):
@ -53,12 +50,35 @@ def test_setup_page_analyst_role(client):
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_setup_page_admin_tile_hidden_for_non_admin(client):
"""Non-admin caller (anonymous in this test) must NOT see the admin tile —
the admin paste prompt references admin-only endpoints (marketplace
registration, skills install) that a non-admin PAT can't authenticate
against, so showing it would lead to a confusing failure.
"""
resp = client.get("/setup", follow_redirects=True)
assert resp.status_code == 200
assert "Admin CLI" not in resp.text
assert "role=admin" not in resp.text
def test_setup_page_admin_role_downgraded_for_non_admin(client):
"""Non-admin requesting `?role=admin` is silently downgraded to analyst.
The page must NOT render admin instructions (no `claude plugin marketplace
add` in the rendered prompt) for someone who can't execute them."""
resp = client.get("/setup?role=admin", follow_redirects=True)
assert resp.status_code == 200
# Admin-only steps must NOT appear (would surface admin paste prompt).
assert "claude plugin marketplace add" not in resp.text
# Analyst-only step IS present (downgrade landed on analyst layout).
assert "agnes init" in resp.text
def test_install_redirects_to_setup(client):
"""`/install` legacy path keeps redirecting to `/setup` (302/307)."""
resp = client.get("/install", follow_redirects=False)
@ -168,13 +188,30 @@ def test_setup_page_analyst_clipboard_renders_analyst_layout(client):
)
def test_setup_page_admin_clipboard_renders_admin_layout(client):
"""Counterpart to the analyst test — admin tile MUST keep the existing
full marketplace/plugins flow byte-equivalent (no regression from Task 4).
def test_setup_page_admin_clipboard_renders_admin_layout(client, monkeypatch):
"""Counterpart to the analyst test — admin caller asking for `?role=admin`
sees the full marketplace/plugins flow.
Admin layout is now admin-gated (non-admins are silently downgraded to
analyst). To exercise the admin path, monkeypatch `get_optional_user` to
return an admin user dict. This avoids spinning up a full session-cookie
fixture for one assertion.
"""
import re
from app.web.router import get_optional_user
from fastapi import Request
async def _admin_user(request: Request): # type: ignore[no-redef]
return {"id": "admin-1", "email": "admin@example.com",
"is_admin": True, "name": "Admin"}
# Override the FastAPI dependency on the running app.
client.app.dependency_overrides[get_optional_user] = _admin_user
try:
resp = client.get("/setup?role=admin", follow_redirects=True)
finally:
client.app.dependency_overrides.pop(get_optional_user, None)
resp = client.get("/setup?role=admin", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
@ -186,21 +223,14 @@ def test_setup_page_admin_clipboard_renders_admin_layout(client):
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array not found in rendered HTML"
clipboard_block = match.group(1)
# Admin layout marker MUST be present. `agnes auth import-token` is the
# admin login step (analyst replaces it with `agnes init`); `agnes skills`
# is admin-only post-auth setup. Either one anchors the admin layout
# without depending on RBAC plugin grants (which the unauthenticated
# TestClient won't have).
# Admin layout marker MUST be present.
assert "agnes auth import-token" in clipboard_block, (
"Admin clipboard payload missing `agnes auth import-token` — "
"Task 4 should not have changed admin behavior."
"Admin clipboard payload missing `agnes auth import-token`"
)
assert "agnes skills" in clipboard_block, (
"Admin clipboard payload missing the skills setup step"
)
# Analyst-only marker MUST NOT appear in admin layout. `agnes init` is
# the analyst-only auth + workspace bootstrap; admin uses
# `agnes auth import-token` instead.
# Analyst-only marker MUST NOT appear in admin layout.
assert "agnes init" not in clipboard_block, (
"Admin clipboard block leaked the analyst `agnes init` step"
)

View file

@ -214,7 +214,9 @@ class TestClaudeSetupPreview:
"""
def test_install_preview_visible_for_signed_in_user(self, web_client, admin_cookie):
resp = web_client.get("/setup", cookies=admin_cookie)
# /setup defaults to ?role=analyst (the common visitor case).
# Admin can switch to ?role=admin to see the marketplace + plugins flow.
resp = web_client.get("/setup?role=admin", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
# Preview card + placeholder token render
@ -227,12 +229,33 @@ class TestClaudeSetupPreview:
# because it validates the PEP 427 filename in the URL before fetch).
assert "/cli/wheel/" in body
assert "/cli/agnes.whl" not in body
# New numbered headers + agnes diagnose step
# Admin layout: numbered headers + diagnose step
assert "1) Install the CLI" in body
assert "4) Run diagnostics" in body
assert "agnes diagnose" in body
assert "agnes auth whoami" in body
def test_install_preview_default_role_is_analyst(self, web_client, admin_cookie):
"""Bare /setup defaults to analyst layout — even for admin users.
Admin opts in via the admin tile (?role=admin) when they want the
marketplace/plugins flow."""
import re
resp = web_client.get("/setup", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
# The clipboard payload (SETUP_INSTRUCTIONS_TEMPLATE JS array)
# carries the analyst layout. Admin tile label "Admin CLI" still
# appears as a tile heading (admin user sees both tiles), but the
# default rendered prompt is the analyst flow.
match = re.search(
r"var\s+SETUP_INSTRUCTIONS_TEMPLATE\s*=\s*\[(.*?)\]\.join\(",
body, re.DOTALL,
)
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array missing"
clipboard = match.group(1)
assert "agnes init" in clipboard
assert "claude plugin marketplace add" not in clipboard
def test_dashboard_setup_cta_links_to_setup(self, web_client, admin_cookie):
"""Dashboard setup CTA shows env-setup-cta and a link to /setup instead
of an inline collapsed preview."""