* feat(brand): inline operator SVG logo + drop header subtitle (release 0.54.6)
Three header tweaks, one PR:
1. _app_header.html drops the small uppercase subtitle line below the
brand. instance.subtitle still flows into the CLAUDE.md preamble +
init welcome template ("Operated by …"); only the web header chrome
loses it.
2. get_instance_logo_svg() in app/instance_config.py reads
instance.logo_svg (yaml) / AGNES_INSTANCE_LOGO_SVG (env). The
yaml field was already documented in instance.yaml.example and the
template already supported inline <svg> via {{ config.LOGO_SVG |
safe }}, but router.py:344 hard-coded LOGO_SVG = "" — the middle
wire was missing. Now operators can paste a lockup directly into
their instance.yaml under instance.logo_svg: | and have it render
in the header. Resolution mirrors get_instance_brand (env > yaml >
""). instance.name remains independent: drives browser <title>
tags + page h1s + CLAUDE.md heading; the SVG is the web-header
visual only.
3. .app-header-logo svg gains max-height: 40px; width: auto; so any
operator's lockup scales via its viewBox to fit the 72px header
without per-asset width/height edits. Pairs with #2 — without the
clamp, raw artwork (e.g. a 1600x430 lockup) overflows the chrome.
Release-cut included per the same-PR rule (Unreleased contained only
these bullets after rebase onto 0.54.5).
* revert: keep app-header-subtitle span — out of scope for this PR
Initial commit dropped the subtitle line on the assumption that
the user wanted both the secondary header line AND the future-SVG
brand cleaned up. The actual ask was narrower: drop the hostname
suffix that renders inside instance.name ("Foundry AI (hostname)"),
which is a startup.sh concern, not a template one. Restore the
subtitle span and the CHANGELOG bullet that announced its removal.
PR scope narrows to LOGO_SVG wiring + CSS clamp only.
* fix(header): hide subtitle span when instance.subtitle is empty
Pre-fix the template fell back to the literal string 'Data Analyst
Portal' when INSTANCE_SUBTITLE was unset, so operators who left the
field empty saw a stray hardcoded label below their brand. Switched
to a Jinja {% if %} guard around the whole <span class="app-header-
subtitle"> so an empty subtitle produces no element at all — clean
header chrome instead of placeholder leak.
* feat(home): hide install-hero once onboarded + X close button
- Wrap the entire install-hero in `{% if not onboarded %}` so once
`users.onboarded=true` (auto-flipped by `agnes init` POSTing
/api/me/onboarded, or by the new X / existing fallback button) the
blue hero disappears entirely. Pre-PR the onboarded branch reused
the same shell with a "Welcome back" header + "Steps 1–4 done" badge
+ minimize toggle, which visually outweighed the actual nav hub.
- Add a circular × close button (top-right of the hero, rendered only
when not-onboarded). Click → window.confirm() asking the user to
acknowledge onboarding → POST /api/me/onboarded → reload. The
confirm string intentionally avoids the literal phrase
"Mark me as offboarded" because cli/commands/onboarded.py::status
scans /home's rendered HTML for that exact marker as a fallback for
the api/me/profile check.
- Lift the offboard escape hatch out of the hero into a discrete
`.offboard-strip` rendered below, gated `{% if onboarded %}`. Lets
the analyst flip back to the install view after wiping their
workspace folder.
- Centralize the /api/me/onboarded POST into a `postOnboarded()` JS
helper reused by the hero X, the existing "Mark me as onboarded"
fallback button, and the new offboard button.
Tests updated to match the new behavior:
- `test_home_onboarded_user_sees_nav_hub` — asserts the hero is gone
and the offboard strip is the only setup-flow remnant.
- `test_minimize_toggle_no_longer_rendered` (renamed) — asserts the
minimize toggle is absent in both states (was previously rendered
inside the now-hidden onboarded branch of the hero).
- `test_home_no_auto_transition_after_post_until_reload` — checks
offboard-strip presence post-flip instead of the removed
"Welcome back" hero copy.
* fix(home): X-close button used invalid source enum, hit 422
The X button's data-target-source was 'self_acknowledged_x' to give
audit_log a separate marker for X-vs-button-driven flips. But
app/api/me.py:38's OnboardedRequest pins source to a Literal of
['agnes_init', 'self_acknowledged', 'self_unmark'] — pydantic
returned 422 on every X click.
Confusing side effect: both buttons share self-mark-status as the
status element, so the failed X click rendered 'Failed (422)' next
to the still-functional 'Mark me as onboarded' button. Looked like
the button itself broke.
Fix: drop the _x suffix. Both surfaces now POST source='self_acknowledged'.
Distinction in audit_log is not load-bearing — the source field
captures user intent ('I'm onboarded'), not the specific UI affordance.
331 lines
13 KiB
Python
331 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: the blue
|
||
install-hero is gone entirely (no welcome banner, no completion
|
||
badge, no inline step commands), the offboard escape strip is the
|
||
only setup-flow remnant rendered, and the rest of /home (connector
|
||
tiles, news, etc.) stays. PR #289 collapsed the dual-state hero
|
||
into a single not-onboarded-only render — pre-PR the onboarded
|
||
branch reused the same `.install-hero` shell with welcome copy
|
||
and a "Steps 1–4 done" badge."""
|
||
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
|
||
# Install hero entirely absent for onboarded users.
|
||
assert '<div class="install-hero">' not in body
|
||
# Offboard escape strip + its button replace the in-hero self-mark control.
|
||
assert '<div class="offboard-strip">' in body
|
||
assert "Mark me as offboarded" in body
|
||
# All four 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 — create your workspace folder" not in body
|
||
assert "Step 4 — install" 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_no_longer_rendered(fresh_db):
|
||
"""The "Minimize setup view" toggle used to live inside the
|
||
onboarded-branch of the install-hero. PR #289 hides the hero
|
||
entirely once `users.onboarded=true`, so the minimize toggle
|
||
has no rendering site anymore — verify the markup is absent
|
||
from both states. (The localStorage `agnes_home_setup_minimized`
|
||
flag and its applyMinimize() JS handler stay in the page so a
|
||
stale flag from a pre-PR session no-ops cleanly.)"""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
for onboarded in (False, True):
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(
|
||
conn, email=f"user-{onboarded}@example.com", onboarded=onboarded
|
||
)
|
||
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
|
||
|
||
|
||
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})
|
||
# PR #289: hero disappears entirely; offboard strip is the
|
||
# only setup-flow remnant. Use either as the nav-hub view marker.
|
||
assert '<div class="offboard-strip">' in post.text
|
||
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)}"
|
||
)
|