* Make /home install-hero links readable against blue background The Claude license-options link added in the previous commit inherited the default `<a>` style (`var(--hp-primary)` blue), which renders as blue-on-blue and is unreadable inside the blue install-hero. Add a scoped `.install-hero a` rule that uses white with an underline (matching the existing lead-paragraph contrast pattern) so any link nested in the hero stays legible. * Reorder /home install flow: auto-mode is now Step 2, Agnes install becomes Step 3 Step 3 (was Step 2) pastes a ~20-command bash bootstrap into a fresh Claude Code session. Without auto-mode enabled first, each Bash/edit command needs a manual approve click — bad UX for first-time users. Move auto-mode from the outside-hero `<details>` reference block into the install-hero as a real Step 2, between "install Claude Code" and "install Agnes". Content is the persistent `acceptEdits` snippet (write to ~/.claude/settings.json) plus a one-liner pointing at Shift+Tab for users who are already inside a running Claude Code session. YOLO mode for full Bash auto-approve stays on /setup-advanced behind the existing link. The outside-hero `setup-collapsible[data-section="step3"]` block is dropped — auto-mode is no longer reference content, it's a real install step, and duplicating it would just diverge over time. Onboarded users no longer see the auto-mode block at all (consistent with Steps 1 + 3 also hiding post-onboarding). Completion banner copy updated: "Step 1, 2 & 3 done — Claude Code installed, auto-mode set, Agnes ready". Dashboard CTA partial and other templates don't reference step numbers for this flow, so no adaptation needed there. * Simplify /home Step 2 to Shift+Tab only — drop the JSON snippet Operator pointed out two issues with the prior Step 2: 1. The settings.json snippet is redundant. Claude Code's first Shift+Tab cycle to auto-accept mode already prompts the user whether to persist it as default — Claude writes the config itself, no manual file edit needed. 2. The snippet only showed the POSIX path `~/.claude/settings.json`, which doesn't translate to native Windows. Replace the snippet + copy button with a plain Shift+Tab instruction, explicitly call out the first-time "make this the default?" prompt, and note that Claude handles the config write itself — same flow on macOS / Linux / WSL / Windows. Adds a fallback line for users who already closed the post-OAuth session. * Tighten /home Step 2 install-note to two paragraphs Operator: drop the 'Claude writes the setting itself, so this works the same on macOS / Linux / WSL / Windows...' line plus the 'auto-approves file edits going forward; Bash commands stay gated — that's the safe default' line. Both were filler — the make-default prompt already implies persistence, and gated Bash is the obvious default users won't be surprised by. Result: paragraph 1 carries Shift+Tab + first-time make-default say-yes + closed-session fallback in one breath; paragraph 2 keeps the verbatim YOLO link. Same affordances, less vertical space.
333 lines
13 KiB
Python
333 lines
13 KiB
Python
"""GET /home — state-aware landing page.
|
|
|
|
The boolean ``users.onboarded`` drives template selection. No
|
|
auto-transition: the not-onboarded view stays put until the user reloads
|
|
(the brainstorm called this out explicitly — quiet UI is preferable to a
|
|
surprise redirect mid-setup).
|
|
|
|
See origin: docs/brainstorms/home-page-requirements.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_db(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
monkeypatch.setenv("DATA_DIR", tmp)
|
|
monkeypatch.setenv("TESTING", "1")
|
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
|
|
yield tmp
|
|
|
|
|
|
def _make_user_and_session(conn, email="u@example.com", onboarded=False):
|
|
from src.repositories.users import UserRepository
|
|
from app.auth.jwt import create_access_token
|
|
|
|
uid = str(uuid.uuid4())
|
|
UserRepository(conn).create(id=uid, email=email, name=email.split("@")[0])
|
|
if onboarded:
|
|
conn.execute("UPDATE users SET onboarded = TRUE WHERE id = ?", [uid])
|
|
return uid, create_access_token(user_id=uid, email=email)
|
|
|
|
|
|
def _client(follow_redirects: bool = True):
|
|
from fastapi.testclient import TestClient
|
|
from app.main import app
|
|
|
|
return TestClient(app, follow_redirects=follow_redirects)
|
|
|
|
|
|
def test_home_unauth_redirects_to_login(fresh_db):
|
|
"""Non-API HTML routes redirect 401→/login per app.main's
|
|
StarletteHTTPException handler. /home follows that contract."""
|
|
c = _client(follow_redirects=False)
|
|
resp = c.get("/home")
|
|
assert resp.status_code == 302
|
|
assert resp.headers["location"].startswith("/login")
|
|
|
|
|
|
def test_home_not_onboarded_user_sees_setup_view(fresh_db):
|
|
"""A FALSE-onboarded user gets the install/setup template, identifiable
|
|
by its 'Install Claude Code' heading and the self-mark button."""
|
|
from src.db import get_system_db, close_system_db
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn, onboarded=False)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
|
|
c = _client()
|
|
resp = c.get("/home", cookies={"access_token": sess})
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
assert "install Claude Code" in body # step 1 label
|
|
assert "install Agnes" in body # step 2 label
|
|
assert "self-mark-btn" in body # self-acknowledged escape hatch
|
|
assert "setupClaudeBtn" in body # primary one-click CTA from shared partial
|
|
|
|
|
|
def test_home_onboarded_user_sees_nav_hub(fresh_db):
|
|
"""A TRUE-onboarded user gets the post-onboarding view, identifiable by
|
|
the 'Welcome back' hero, the 'Step 1 & Step 2 done' completion badge,
|
|
the offboard control, and the absence of the inline Step 1 / Step 2
|
|
install commands. Step 3 (auto-mode), connectors, and the rest stay
|
|
visible — they remain useful after onboarding."""
|
|
from src.db import get_system_db, close_system_db
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn, onboarded=True)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
|
|
c = _client()
|
|
resp = c.get("/home", cookies={"access_token": sess})
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
assert "Welcome back" in body
|
|
# Banner copy updated when auto-mode moved into install-hero as a real
|
|
# Step 2 (between Claude install and Agnes install). The completion
|
|
# badge now names all three.
|
|
assert "Step 1, 2 & 3 done" in body # completion badge
|
|
assert "Mark me as offboarded" in body # offboard control visible
|
|
# All three inline install-blocks are hidden post-onboarding — the
|
|
# labels rendered inside the install-block divs go away.
|
|
assert "Step 1 — install Claude Code" not in body
|
|
assert "Step 2 — turn on auto-mode" not in body
|
|
assert "Step 3 — install Agnes from inside Claude Code" not in body
|
|
|
|
|
|
def test_connectors_render_flat_when_onboarded_by_default(fresh_db):
|
|
"""Connect-your-tools section must NOT auto-collapse on the
|
|
server-side `users.onboarded=TRUE` flip. It renders flat (in <details
|
|
open>) by default; only an explicit user click on the in-hero
|
|
"Minimize setup view" toggle (persisted in localStorage, not server)
|
|
activates the collapsed bar layout.
|
|
|
|
Auto-mode used to be a peer `setup-collapsible` section
|
|
(`data-section="step3"`) outside the install-hero. It moved into the
|
|
install-hero as Step 2 of the install flow (so users enable it
|
|
BEFORE Step 3's ~20-command install runs), and the standalone
|
|
outside-hero copy was dropped to avoid duplicating reference
|
|
content. Onboarded users no longer see the auto-mode block at all —
|
|
consistent with Step 1 + Step 3 also hiding post-onboarding."""
|
|
from src.db import get_system_db, close_system_db
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn, onboarded=True)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
|
|
c = _client()
|
|
resp = c.get("/home", cookies={"access_token": sess})
|
|
assert resp.status_code == 200
|
|
body = resp.text
|
|
# Auto-mode no longer renders for onboarded users — both the
|
|
# in-hero install-block and the legacy outside-hero `<details>`
|
|
# reference card are gated `{% if not onboarded %}` / removed.
|
|
assert 'class="automode-card"' not in body
|
|
assert 'data-section="step3"' not in body
|
|
assert "Step 2 — turn on auto-mode" not in body
|
|
# Connect-your-tools section is still flat-open by default.
|
|
assert 'class="connector-tiles"' in body
|
|
assert 'class="setup-collapsible" data-section="connectors" open' in body
|
|
# Server-rendered HTML never carries the data-setup-minimized
|
|
# attribute on the .home-mock root — that's a client-side
|
|
# localStorage decision applied via JS on load. The token still
|
|
# appears in inline CSS selectors and the JS body, which is fine.
|
|
assert '<div class="home-mock" data-setup-minimized' not in body
|
|
assert 'class="home-mock"\n' in body or '<div class="home-mock">' in body
|
|
|
|
|
|
def test_minimize_toggle_visible_only_when_onboarded(fresh_db):
|
|
"""The "Minimize setup view" toggle markup is rendered for onboarded
|
|
users (so they can opt into the collapsed view) and absent for
|
|
not-onboarded users (where the install steps already dominate)."""
|
|
from src.db import get_system_db, close_system_db
|
|
|
|
# Not-onboarded → no toggle button.
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn, onboarded=False)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
c = _client()
|
|
resp = c.get("/home", cookies={"access_token": sess})
|
|
assert resp.status_code == 200
|
|
assert '<button id="setupMinimizeToggle"' not in resp.text
|
|
assert 'class="setup-minimize"' not in resp.text
|
|
|
|
# Onboarded → toggle button rendered inside the install-hero.
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess2 = _make_user_and_session(conn, email="b@example.com", onboarded=True)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
c2 = _client()
|
|
resp2 = c2.get("/home", cookies={"access_token": sess2})
|
|
assert resp2.status_code == 200
|
|
assert '<button id="setupMinimizeToggle"' in resp2.text
|
|
assert 'class="setup-minimize"' in resp2.text
|
|
|
|
|
|
def test_home_no_auto_transition_after_post_until_reload(fresh_db):
|
|
"""POST /api/me/onboarded flips the flag in the DB but the in-flight
|
|
/home response from before the POST keeps showing the setup view —
|
|
the next GET /home picks up the new state. Verifies the manual-reload
|
|
contract called out in the brainstorm."""
|
|
from src.db import get_system_db, close_system_db
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn, onboarded=False)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
|
|
c = _client()
|
|
|
|
pre = c.get("/home", cookies={"access_token": sess})
|
|
# `class="install-block"` is the not-onboarded-only structural element
|
|
# holding the inline Step-1 install pane. Use it as the discriminator
|
|
# instead of a free-form string like "install Claude Code", which now
|
|
# also appears in the always-on SETUP_INSTRUCTIONS_TEMPLATE clipboard
|
|
# payload's preflight comment after the 2026-05-10 init-report fix.
|
|
assert 'class="install-block"' in pre.text # setup view
|
|
|
|
flip = c.post("/api/me/onboarded", cookies={"access_token": sess})
|
|
assert flip.status_code == 200
|
|
|
|
post = c.get("/home", cookies={"access_token": sess})
|
|
assert "Welcome back" in post.text # nav hub view
|
|
assert 'class="install-block"' not in post.text
|
|
|
|
|
|
# ── GWS Email-admin button render tests (admin_email knob coverage) ────────
|
|
|
|
|
|
def test_home_hides_email_admin_button_when_admin_email_unset(fresh_db, monkeypatch):
|
|
"""When ``instance.admin_email`` is unset, the GWS connector tile
|
|
must NOT render the mailto link (template guards on truthiness;
|
|
empty resolver value cleanly hides). Defends against a `mailto:?`
|
|
link sneaking out as a render-time artifact."""
|
|
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
|
|
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
|
|
from src.db import get_system_db, close_system_db
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
body = _client().get("/home", cookies={"access_token": sess}).text
|
|
# No "Email admin" CTA, no mailto: link in the body.
|
|
assert "Email admin" not in body
|
|
assert "mailto:?" not in body # specifically, no broken empty mailto
|
|
|
|
|
|
def test_home_shows_email_admin_button_when_admin_email_set_and_gws_unconfigured(
|
|
fresh_db, monkeypatch,
|
|
):
|
|
"""When admin_email is set AND gws_oauth is unconfigured, the mailto
|
|
link renders. (Both conditions required — see template guard
|
|
``{% if not gws_oauth.configured and instance_admin_email %}``.)"""
|
|
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
|
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
|
|
from src.db import get_system_db, close_system_db
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
body = _client().get("/home", cookies={"access_token": sess}).text
|
|
assert "Email admin" in body
|
|
assert "mailto:ops@example.com" in body
|
|
|
|
|
|
def test_home_hides_email_admin_button_when_gws_configured(fresh_db, monkeypatch):
|
|
"""Even with admin_email set, when GWS OAuth is operator-provisioned
|
|
(gws_oauth.configured = True), the Email-admin CTA is redundant —
|
|
user can just connect. Template gates on `not gws_oauth.configured`."""
|
|
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
|
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
|
|
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret")
|
|
from src.db import get_system_db, close_system_db
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
body = _client().get("/home", cookies={"access_token": sess}).text
|
|
assert "Email admin" not in body
|
|
|
|
|
|
def test_home_renders_connector_prompts_from_shared_module(fresh_db):
|
|
"""Single source of truth check: the prompt text the /home tiles
|
|
paste must equal the strings ``app/web/connector_prompts.py`` returns.
|
|
The same strings are also inlined into the setup script's step 9, so
|
|
if they ever drift the two surfaces would tell users to do different
|
|
things — this test catches that early."""
|
|
import html as _html
|
|
import re
|
|
|
|
from src.db import get_system_db, close_system_db
|
|
from app.web.connector_prompts import (
|
|
asana_prompt, gws_prompt, atlassian_prompt,
|
|
)
|
|
from app.instance_config import (
|
|
get_gws_oauth_credentials, get_instance_admin_email,
|
|
)
|
|
|
|
conn = get_system_db()
|
|
try:
|
|
_, sess = _make_user_and_session(conn)
|
|
finally:
|
|
conn.close()
|
|
close_system_db()
|
|
|
|
c = _client()
|
|
body = c.get("/home", cookies={"access_token": sess}).text
|
|
|
|
# Resolve the same gws_oauth dict the route uses so the parity check
|
|
# exercises whichever branch (configured / manual) is active in the
|
|
# current test environment.
|
|
gws = get_gws_oauth_credentials()
|
|
expected_gws = gws_prompt(
|
|
gws_oauth_configured=bool(gws.get("configured")),
|
|
gws_client_id=str(gws.get("client_id") or ""),
|
|
gws_client_secret=str(gws.get("client_secret") or ""),
|
|
gws_project_id=str(gws.get("project_id") or ""),
|
|
oauthlib_insecure_transport=str(gws.get("oauthlib_insecure_transport") or "1"),
|
|
instance_admin_email=get_instance_admin_email(),
|
|
)
|
|
|
|
for slug, expected in (
|
|
("asana", asana_prompt()),
|
|
("gws", expected_gws),
|
|
("jira", atlassian_prompt()),
|
|
):
|
|
m = re.search(rf'<code id="{slug}-prompt">(.*?)</code>', body, re.DOTALL)
|
|
assert m, f"{slug}-prompt block missing from /home"
|
|
actual = _html.unescape(m.group(1))
|
|
assert actual == expected, (
|
|
f"{slug}-prompt body diverged from connector_prompts module — "
|
|
f"the home tile and setup script will paste different text. "
|
|
f"len(home)={len(actual)} len(module)={len(expected)}"
|
|
)
|