Files
hermes-agent/hermes_cli/dashboard_auth/login_page.py
T
红尘 d73ff9b0fb
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
Initial import of NousResearch/hermes-agent
2026-05-31 09:36:58 +08:00

385 lines
11 KiB
Python

"""Server-rendered /login page.
No React, no JavaScript dependency. Listed providers come from the
registry; clicking a provider sends a GET to
``/auth/login?provider=<name>``.
Visual styling mirrors the Nous Research design system (the
``@nous-research/ui`` package the React dashboard uses): the same
``Collapse`` / ``Rules Compressed`` typeface, amber-on-dark colour
tokens (``#170d02`` / ``#ffac02`` / ``#fff``), uppercase + wide-tracking
brand chrome, and the inset-bevel button shadow. Fonts are served
out of the SPA's ``/fonts/`` directory which the dashboard-auth gate
already allowlists pre-auth (see ``_GATE_PUBLIC_PREFIXES`` in
``middleware.py``), so the page renders without needing the React
bundle loaded.
Test-stable class names: the existing test suite extracts the
``class="provider-btn"`` anchor href to walk the OAuth flow. That
class name MUST NOT change without updating
``tests/hermes_cli/test_dashboard_auth_401_reauth.py``.
"""
from __future__ import annotations
import html
from hermes_cli.dashboard_auth import list_providers
# Inline minimal CSS. The dashboard's full skin lives in the React
# bundle, which we deliberately do NOT load here — the login page must
# not depend on the SPA build being present or on the injected session
# token.
#
# Single curly braces are placeholders for ``str.format``; CSS curlies
# are doubled (``{{`` / ``}}``).
_LOGIN_HTML_TEMPLATE = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — Hermes Agent</title>
<style>
/* Brand fonts shipped by @nous-research/ui — same files the SPA loads. */
@font-face {{
font-family: 'Collapse';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
}}
@font-face {{
font-family: 'Collapse';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/Collapse-Bold.woff2') format('woff2');
}}
@font-face {{
font-family: 'Rules Compressed';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/RulesCompressed-Regular.woff2') format('woff2');
}}
@font-face {{
font-family: 'Rules Compressed';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
}}
:root {{
--background-base: #170d02;
--background: #170d02;
--midground: #ffac02;
--foreground: #ffffff;
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
--hairline-strong: color-mix(in srgb, #ffac02 35%, transparent);
}}
*, *::before, *::after {{ box-sizing: border-box; }}
html, body {{
margin: 0;
padding: 0;
min-height: 100%;
background: var(--background-base);
color: var(--foreground);
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}}
/* Subtle dot-grid backdrop — DS idiom (see `.dither` in globals.css). */
body {{
background-image:
radial-gradient(
ellipse at top,
color-mix(in srgb, var(--midground) 6%, transparent) 0%,
transparent 55%
),
repeating-conic-gradient(
color-mix(in srgb, var(--midground) 4%, transparent) 0% 25%,
transparent 0% 50%
);
background-size: auto, 3px 3px;
background-attachment: fixed;
}}
/* Layout: vertically center on tall screens, top-anchor on short. */
body {{
display: grid;
place-items: center;
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
}}
main {{
width: 100%;
max-width: 26rem;
position: relative;
animation: slide-up 0.6s ease-out both;
}}
@keyframes slide-up {{
from {{ opacity: 0; transform: translateY(6px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
@media (prefers-reduced-motion: reduce) {{
main {{ animation: none; }}
}}
/* Brand wordmark above the card — same uppercase + wide-tracking
idiom DS Buttons use. */
.brand {{
text-align: center;
margin-bottom: 1.75rem;
font-family: 'Rules Compressed', 'Collapse', sans-serif;
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--midground);
}}
.brand .dot {{
display: inline-block;
width: 6px;
height: 6px;
background: var(--midground);
margin: 0 0.55em 0.18em;
vertical-align: middle;
border-radius: 1px;
}}
.card {{
position: relative;
padding: 2.25rem 2rem 2rem;
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
border: 1px solid var(--hairline);
/* Hairline highlight + bevel shadow — matches DS Button SHADOW_DEFAULT
(`inset -1px -1px 0 #00000080, inset 1px 1px 0 #ffffff80`) at panel scale. */
box-shadow:
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
0 24px 60px -20px rgba(0, 0, 0, 0.6);
}}
h1 {{
margin: 0 0 0.4rem;
font-family: 'Rules Compressed', 'Collapse', sans-serif;
font-weight: 600;
font-size: 1.85rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--foreground);
}}
.subtitle {{
margin: 0 0 1.75rem;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
font-size: 0.95rem;
}}
.provider-list {{
display: grid;
gap: 0.75rem;
}}
/* Provider button — mirrors DS Button (default variant):
amber surface, dark text, uppercase + wide tracking, inset bevel. */
.provider-btn {{
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.95rem 1rem;
text-align: center;
background: var(--midground);
color: var(--background-base);
font-family: 'Collapse', sans-serif;
font-weight: 700;
font-size: 0.78rem;
letter-spacing: 0.2em;
text-transform: uppercase;
text-decoration: none;
border: 0;
border-radius: 0; /* DS Button is squared — no rounded corners. */
cursor: pointer;
box-shadow:
inset 1px 1px 0 0 rgba(255, 255, 255, 0.5),
inset -1px -1px 0 0 rgba(0, 0, 0, 0.5);
transition: filter 0.12s ease-out;
}}
.provider-btn:hover {{
filter: brightness(1.08);
}}
.provider-btn:active {{
/* DS Button uses `active:invert` on the default surface. */
filter: invert(1);
}}
.provider-btn:focus-visible {{
outline: 2px solid var(--midground);
outline-offset: 3px;
}}
footer {{
margin-top: 1.75rem;
text-align: center;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
line-height: 1.7;
}}
footer .sep {{
display: inline-block;
width: 1.5rem;
height: 1px;
background: var(--hairline-strong);
vertical-align: middle;
margin: 0 0.6em 0.2em;
}}
/* Selection — DS uses midground bg + background text. */
::selection {{
background: var(--midground);
color: var(--background-base);
}}
</style>
</head>
<body>
<main>
<div class="brand">Nous<span class="dot"></span>Research</div>
<div class="card">
<h1>Sign in</h1>
<p class="subtitle">Choose a sign-in method to continue to the Hermes Agent dashboard.</p>
<div class="provider-list">
{provider_buttons}
</div>
</div>
<footer>
<span class="sep"></span>Public bind &middot; Auth required<span class="sep"></span>
</footer>
</main>
</body>
</html>
"""
_EMPTY_HTML = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign-in unavailable — Hermes Agent</title>
<style>
@font-face {
font-family: 'Collapse';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Rules Compressed';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
}
:root {
--background-base: #170d02;
--midground: #ffac02;
--foreground: #ffffff;
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; min-height: 100%;
background: var(--background-base);
color: var(--foreground);
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 16px; line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body {
display: grid; place-items: center;
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
}
main {
width: 100%; max-width: 32rem;
padding: 2.25rem 2rem;
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
border: 1px solid var(--hairline);
box-shadow:
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
0 24px 60px -20px rgba(0, 0, 0, 0.6);
}
h1 {
margin: 0 0 1rem;
font-family: 'Rules Compressed', 'Collapse', sans-serif;
font-weight: 600; font-size: 1.5rem;
letter-spacing: 0.05em; text-transform: uppercase;
color: var(--midground);
}
p { margin: 0 0 1rem; }
code {
background: var(--midground);
color: var(--background-base);
padding: 0.1em 0.35em;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
<main>
<h1>Sign-in unavailable</h1>
<p>This dashboard is bound to a non-loopback host but no authentication
providers are installed.</p>
<p>Install <code>plugins/dashboard-auth-nous</code> (default) or another
auth provider, or restart with <code>--insecure</code> to bypass the
auth gate (not recommended on untrusted networks).</p>
</main>
</body>
</html>
"""
def render_login_html(*, next_path: str = "") -> str:
"""Return the full HTML for ``GET /login``.
``next_path`` — when set, the post-login landing path the user
originally requested. Threaded into each provider button's ``href``
as a ``next=`` query parameter so the OAuth round trip carries it
end-to-end. The caller (``routes.login_page``) is responsible for
validating ``next_path`` against the same-origin rules before we
emit it; we still HTML-escape it as defence in depth.
"""
providers = list_providers()
if not providers:
return _EMPTY_HTML
if next_path:
# URL-encode then HTML-escape. The URL-encode step matches the
# gate's ``_safe_next_target`` output shape (also URL-encoded),
# so a value that round-tripped from /login?next=... back into
# the button href is byte-identical.
from urllib.parse import quote
next_qs = f"&next={html.escape(quote(next_path, safe=''), quote=True)}"
else:
next_qs = ""
buttons = []
for p in providers:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))