T301: m2-gpt identity login (central credentials consumer)

This commit is contained in:
m2 (AI Agent) 2026-07-02 06:20:43 +02:00
parent 5dd73eb4cd
commit a77b0dbaef
7 changed files with 536 additions and 37 deletions

View file

@ -1,11 +1,13 @@
# Contract: m2-market-web /api (v1)
Session: POST /api/login {passcode} → 204 + Set-Cookie m2mw_session (signed, 7d) | 401.
Config: GET /api/config → {auth_mode:"passcode"|"gpt"|"both"}.
Session: POST /api/login {passcode} OR {email,password} → 204 + Set-Cookie m2mw_session (signed, 7d) | 401.
Session identity: GET /api/session → {email,role,tenant_id,fleet_admin,operator_id,auth_method} with no upstream token.
All /api/* below require the cookie (401 otherwise). /health is open → {"status":"healthy"}.
- GET /api/search?q=&limit=10 → [{listing_id, name, summary, category, price:{amount,currency,model}, seller, stats, score}]
- GET /api/listing/{listing_id} → {listing..., evidence_summary, permissions[], install_ref, install_command: "m2-market install <id> --yes"}
- GET /api/wallet/{operator_id} → {operator_id, balance, as_of, transactions:[last 20 {ts,from,to,amount,reason,ref}]}
- GET /api/wallet/{operator_id} → {operator_id, balance, as_of, transactions:[last 20 {ts,from,to,amount,reason,ref}]} | 403 for non-admin foreign wallet
- GET /api/economy → {operators:[{operator_id,balance}], audit:{date, url}} # url deep-links registry audit file
- GET /api/governance → {open_prs:[{number,title,age_days,labels,url}], listings:[{listing_id,status,url}]}

View file

@ -1,21 +1,47 @@
from __future__ import annotations
import base64
import hmac
import json
import os
from typing import Annotated
import time
from typing import Annotated, Any, Literal
import httpx
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from .http import client
COOKIE_NAME = "m2mw_session"
SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
INTROSPECTION_TTL_SECONDS = 10 * 60
AUTH_MODES = {"passcode", "gpt", "both"}
router = APIRouter()
_introspection_cache: dict[str, float] = {}
class LoginBody(BaseModel):
passcode: str
passcode: str | None = None
email: str | None = None
password: str | None = None
class SessionIdentity(BaseModel):
model_config = ConfigDict(extra="ignore")
email: str | None = None
role: str
tenant_id: str | None = None
fleet_admin: bool = False
operator_id: str
auth_method: Literal["passcode", "gpt"]
m2gpt_token: str | None = None
def public(self) -> dict[str, Any]:
return self.model_dump(exclude={"m2gpt_token"})
def _serializer() -> URLSafeTimedSerializer:
@ -25,6 +51,11 @@ def _serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(secret, salt="m2-market-web-session")
def _auth_mode() -> str:
mode = os.environ.get("AUTH_MODE", "both").lower()
return mode if mode in AUTH_MODES else "both"
def _configured_passcode() -> str:
passcode = os.environ.get("FLEET_PASSCODE")
if not passcode:
@ -32,11 +63,81 @@ def _configured_passcode() -> str:
return passcode
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
def login(body: LoginBody, response: Response) -> None:
if not hmac.compare_digest(body.passcode, _configured_passcode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
token = _serializer().dumps({"scope": "fleet"})
def _operator_id(email: str | None, tenant_id: str | None) -> str:
if tenant_id:
return tenant_id
if email and "@" in email:
return email.split("@", 1)[0]
if email:
return email
return "unknown"
def _is_fleet_admin(role: str | None, fleet_admin: Any) -> bool:
if isinstance(fleet_admin, bool):
return fleet_admin
return str(role or "").lower() in {"fleet_admin", "admin", "owner", "superadmin"}
def _decode_jwt_payload(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2:
return {}
payload = parts[1] + "=" * (-len(parts[1]) % 4)
try:
# This decode is intentionally unverified: the token arrived from m2-gpt over
# the direct TLS login exchange, and we only need identity claims for binding.
decoded = base64.urlsafe_b64decode(payload.encode()).decode("utf-8")
body = json.loads(decoded)
return body if isinstance(body, dict) else {}
except (ValueError, json.JSONDecodeError):
return {}
def _token_from_response(body: dict[str, Any]) -> str | None:
for key in ("token", "access_token", "jwt", "id_token"):
token = body.get(key)
if isinstance(token, str) and token:
return token
return None
def _identity_from_gpt_response(body: dict[str, Any], login_email: str) -> SessionIdentity:
token = _token_from_response(body)
claims = _decode_jwt_payload(token) if token else {}
user = body.get("user") if isinstance(body.get("user"), dict) else {}
source = {**claims, **user, **body}
email = str(source.get("email") or source.get("sub") or login_email)
role = str(source.get("role") or source.get("admin_role") or "operator")
tenant_id = source.get("tenant_id") or source.get("tenant")
tenant = str(tenant_id) if tenant_id else None
fleet_admin = _is_fleet_admin(role, source.get("fleet_admin"))
return SessionIdentity(
email=email,
role=role,
tenant_id=tenant,
fleet_admin=fleet_admin,
operator_id=_operator_id(email, tenant),
auth_method="gpt",
m2gpt_token=token if os.environ.get("M2GPT_INTROSPECTION_KEY") else None,
)
def _passcode_identity() -> SessionIdentity:
return SessionIdentity(
email=None,
role="fleet_admin",
tenant_id=None,
fleet_admin=True,
operator_id="fleet",
auth_method="passcode",
)
def _set_session(response: Response, identity: SessionIdentity) -> None:
token = _serializer().dumps(identity.model_dump())
response.set_cookie(
COOKIE_NAME,
token,
@ -47,9 +148,87 @@ def login(body: LoginBody, response: Response) -> None:
)
def require_session(
async def _gpt_login(email: str, password: str) -> SessionIdentity:
base_url = os.environ.get("M2GPT_URL")
if not base_url:
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
try:
async with client(base_url) as http:
response = await http.post(
"/admin/v1/auth/login", json={"email": email, "password": password}
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if response.status_code in {401, 403}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
try:
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError):
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if not isinstance(body, dict):
raise HTTPException(status_code=502, detail="gpt_auth_unavailable")
return _identity_from_gpt_response(body, email)
async def _revalidate_with_m2gpt(identity: SessionIdentity) -> None:
introspection_key = os.environ.get("M2GPT_INTROSPECTION_KEY")
if not introspection_key or identity.auth_method != "gpt" or not identity.m2gpt_token:
return
now = time.time()
cached_at = _introspection_cache.get(identity.m2gpt_token)
if cached_at and now - cached_at < INTROSPECTION_TTL_SECONDS:
return
base_url = os.environ.get("M2GPT_URL")
if not base_url:
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
try:
async with client(base_url, {"X-Introspection-Key": introspection_key}) as http:
response = await http.post(
"/admin/v1/auth/introspect", json={"token": identity.m2gpt_token}
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if response.status_code in {401, 403}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
try:
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError):
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
if isinstance(body, dict) and body.get("active") is False:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
_introspection_cache[identity.m2gpt_token] = now
@router.get("/config")
def config() -> dict[str, str]:
return {"auth_mode": _auth_mode()}
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
async def login(body: LoginBody, response: Response) -> None:
mode = _auth_mode()
if body.passcode is not None:
if mode not in {"passcode", "both"}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
if not hmac.compare_digest(body.passcode, _configured_passcode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
_set_session(response, _passcode_identity())
return
if body.email and body.password:
if mode not in {"gpt", "both"}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
_set_session(response, await _gpt_login(body.email, body.password))
return
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_login_body")
async def require_session(
session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
) -> None:
) -> SessionIdentity:
if not session_cookie:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
try:
@ -58,8 +237,23 @@ def require_session(
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
) from None
if payload.get("scope") != "fleet":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
if payload.get("scope") == "fleet":
identity = _passcode_identity()
else:
try:
identity = SessionIdentity.model_validate(payload)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
) from None
await _revalidate_with_m2gpt(identity)
return identity
@router.get("/session")
async def session(identity: Annotated[SessionIdentity, Depends(require_session)]) -> dict[str, Any]:
return identity.public()
SessionDependency = Depends(require_session)

View file

@ -5,10 +5,10 @@ from datetime import date
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from .auth import SessionDependency
from .auth import SessionDependency, SessionIdentity, require_session
from .http import client, upstream_error
router = APIRouter(dependencies=[SessionDependency])
@ -61,7 +61,11 @@ async def _latest_audit_date() -> str:
@router.get("/wallet/{operator_id}", response_model=None)
async def wallet(operator_id: str) -> dict[str, Any] | JSONResponse:
async def wallet(
operator_id: str, session: SessionIdentity = Depends(require_session)
) -> dict[str, Any] | JSONResponse:
if not session.fleet_admin and operator_id != session.operator_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden")
try:
async with client(os.environ["LEDGER_URL"], _headers()) as http:
balance = await http.get(f"/balance/{operator_id}")

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import base64
from collections.abc import Callable
import httpx
@ -15,6 +16,8 @@ SECRET_VALUES = {
"mem-secret-key",
"ledger-secret-key",
"forgejo-secret-token",
"m2gpt-password-secret",
"m2gpt-introspection-secret",
}
@ -30,6 +33,8 @@ def env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FORGEJO_TOKEN", "forgejo-secret-token")
monkeypatch.setenv("REGISTRY_REPO", "m2/market-registry")
monkeypatch.setenv("TENANT", "m2-core")
monkeypatch.setenv("M2GPT_URL", "https://gpt.example")
monkeypatch.setenv("M2GPT_INTROSPECTION_KEY", "m2gpt-introspection-secret")
@pytest.fixture
@ -46,6 +51,14 @@ def _authed(test_client: TestClient) -> None:
assert response.status_code == 204
def _jwt(claims: dict) -> str:
def encode(part: dict) -> str:
raw = json.dumps(part, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{encode({'alg': 'none'})}.{encode(claims)}."
def _listing() -> dict:
return {
"name": "PDF Export",
@ -87,6 +100,17 @@ def _ok_transport(request: httpx.Request) -> httpx.Response:
)
if request.url.host == "ledger.example":
assert request.headers["X-API-Key"] == "ledger-secret-key"
if request.url.path == "/balance/tenant-acme":
return httpx.Response(
200,
json={
"operator_id": "tenant-acme",
"balance": 31,
"as_of": "2026-07-02T10:00:00Z",
},
)
if request.url.path == "/balance/other-tenant":
return httpx.Response(200, json={"operator_id": "other-tenant", "balance": 13})
if request.url.path == "/balance/m2bd":
return httpx.Response(
200,
@ -111,6 +135,23 @@ def _ok_transport(request: httpx.Request) -> httpx.Response:
]
},
)
if request.url.path == "/tx" and request.url.params.get("operator_id") == "tenant-acme":
return httpx.Response(
200,
json={
"transactions": [
{
"ts": "2026-07-02T09:00:00Z",
"from_id": "mint",
"to_id": "tenant-acme",
"amount": 31,
"reason": "grant",
}
]
},
)
if request.url.path == "/tx" and request.url.params.get("operator_id") == "other-tenant":
return httpx.Response(200, json={"transactions": []})
if request.url.path == "/tx":
return httpx.Response(
200,
@ -148,6 +189,55 @@ def _ok_transport(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"unexpected": str(request.url)})
def _gpt_transport(request: httpx.Request) -> httpx.Response:
if request.url.host == "gpt.example":
if request.url.path == "/admin/v1/auth/login":
body = json.loads(request.content)
if (
body == {
"email": "operator@example.com",
"password": "m2gpt-password-secret",
}
):
return httpx.Response(
200,
json={
"access_token": _jwt(
{
"email": "operator@example.com",
"role": "operator",
"tenant_id": "tenant-acme",
"fleet_admin": False,
}
)
},
)
if (
body == {
"email": "admin@example.com",
"password": "m2gpt-password-secret",
}
):
return httpx.Response(
200,
json={
"access_token": _jwt(
{
"email": "admin@example.com",
"role": "fleet_admin",
"tenant_id": "admin-tenant",
"fleet_admin": True,
}
)
},
)
return httpx.Response(401, json={"error": "bad_credentials"})
if request.url.path == "/admin/v1/auth/introspect":
assert request.headers["X-Introspection-Key"] == "m2gpt-introspection-secret"
return httpx.Response(200, json={"active": True})
return _ok_transport(request)
def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]) -> None:
test_client = client(httpx.MockTransport(_ok_transport))
assert test_client.get("/api/search?q=pdf").status_code == 401
@ -156,6 +246,92 @@ def test_login_and_auth_gate(client: Callable[[httpx.MockTransport], TestClient]
assert test_client.get("/api/search?q=pdf&limit=2").status_code == 200
def test_auth_config_defaults_to_both(client: Callable[[httpx.MockTransport], TestClient]) -> None:
test_client = client(httpx.MockTransport(_ok_transport))
response = test_client.get("/api/config")
assert response.status_code == 200
assert response.json() == {"auth_mode": "both"}
def test_gpt_login_binds_session_and_wallet(
client: Callable[[httpx.MockTransport], TestClient],
) -> None:
test_client = client(httpx.MockTransport(_gpt_transport))
response = test_client.post(
"/api/login",
json={"email": "operator@example.com", "password": "m2gpt-password-secret"},
)
assert response.status_code == 204
session = test_client.get("/api/session")
assert session.status_code == 200
assert session.json() == {
"email": "operator@example.com",
"role": "operator",
"tenant_id": "tenant-acme",
"fleet_admin": False,
"operator_id": "tenant-acme",
"auth_method": "gpt",
}
wallet = test_client.get("/api/wallet/tenant-acme")
assert wallet.status_code == 200
assert wallet.json()["balance"] == 31
def test_gpt_login_wrong_password_401(
client: Callable[[httpx.MockTransport], TestClient],
) -> None:
test_client = client(httpx.MockTransport(_gpt_transport))
response = test_client.post(
"/api/login",
json={"email": "operator@example.com", "password": "wrong"},
)
assert response.status_code == 401
def test_gpt_non_admin_foreign_wallet_403(
client: Callable[[httpx.MockTransport], TestClient],
) -> None:
test_client = client(httpx.MockTransport(_gpt_transport))
response = test_client.post(
"/api/login",
json={"email": "operator@example.com", "password": "m2gpt-password-secret"},
)
assert response.status_code == 204
assert test_client.get("/api/wallet/other-tenant").status_code == 403
def test_gpt_fleet_admin_can_view_foreign_wallet(
client: Callable[[httpx.MockTransport], TestClient],
) -> None:
test_client = client(httpx.MockTransport(_gpt_transport))
response = test_client.post(
"/api/login",
json={"email": "admin@example.com", "password": "m2gpt-password-secret"},
)
assert response.status_code == 204
wallet = test_client.get("/api/wallet/other-tenant")
assert wallet.status_code == 200
assert wallet.json()["balance"] == 13
def test_passcode_mode_still_allows_passcode_only(
client: Callable[[httpx.MockTransport], TestClient], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("AUTH_MODE", "passcode")
test_client = client(httpx.MockTransport(_gpt_transport))
assert (
test_client.post(
"/api/login",
json={"email": "operator@example.com", "password": "m2gpt-password-secret"},
).status_code
== 401
)
_authed(test_client)
assert test_client.get("/api/wallet/m2bd").status_code == 200
def test_catalog_wallet_economy_governance_happy_paths(
client: Callable[[httpx.MockTransport], TestClient],
) -> None:

View file

@ -56,6 +56,21 @@ export type Governance = {
listings: Array<{ listing_id: string; status: string; url: string }>;
};
export type AuthMode = 'passcode' | 'gpt' | 'both';
export type Config = {
auth_mode: AuthMode;
};
export type Session = {
email?: string | null;
role: string;
tenant_id?: string | null;
fleet_admin: boolean;
operator_id: string;
auth_method: 'passcode' | 'gpt';
};
export type PanelName = 'search' | 'listing' | 'wallet' | 'economy' | 'governance' | 'login';
export class ApiError extends Error {
@ -111,6 +126,18 @@ export const api = {
body: JSON.stringify({ passcode }),
});
},
loginGpt(email: string, password: string) {
return request<void>('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
},
config() {
return request<Config>('/api/config');
},
session() {
return request<Session>('/api/session');
},
search(q: string, limit = 10) {
const params = new URLSearchParams({ q, limit: String(limit) });
return request<ListingSummary[]>(`/api/search?${params}`);

View file

@ -2,11 +2,13 @@ import React, { FormEvent, useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
ApiError,
AuthMode,
Economy,
Governance,
ListingDetail,
ListingSummary,
PanelName,
Session,
Wallet,
api,
} from './api';
@ -96,11 +98,32 @@ function PanelError({ panel, error }: { panel: PanelName; error: unknown }) {
function App() {
const route = useRoute();
const [operator, setOperator] = useState(() => localStorage.getItem(OPERATOR_STORAGE_KEY) || OPERATOR_PRESETS[0]);
const [session, setSession] = useState<Session | null>(null);
useEffect(() => {
localStorage.setItem(OPERATOR_STORAGE_KEY, operator);
}, [operator]);
useEffect(() => {
if (route.name === 'login') return;
let cancelled = false;
api
.session()
.then((payload) => {
if (cancelled) return;
setSession(payload);
if (!payload.fleet_admin) setOperator(payload.operator_id);
})
.catch(() => {
if (!cancelled) setSession(null);
});
return () => {
cancelled = true;
};
}, [route.name]);
const walletOperator = session && !session.fleet_admin ? session.operator_id : operator;
if (route.name === 'login') {
return <LoginPage />;
}
@ -121,13 +144,13 @@ function App() {
<NavLink href="#/economy" active={route.name === 'economy'} label="Economy" />
<NavLink href="#/governance" active={route.name === 'governance'} label="Governance" />
</nav>
<OperatorPicker value={operator} onChange={setOperator} />
{session?.fleet_admin && <OperatorPicker value={operator} onChange={setOperator} />}
</header>
<main className="main-grid">
{route.name === 'search' && <SearchPage />}
{route.name === 'listing' && <ListingPage listingId={route.id} />}
{route.name === 'wallet' && <WalletPage operator={operator} />}
{route.name === 'wallet' && <WalletPage operator={walletOperator} />}
{route.name === 'economy' && <EconomyPage />}
{route.name === 'governance' && <GovernancePage />}
</main>
@ -145,12 +168,30 @@ function NavLink({ href, active, label }: { href: string; active: boolean; label
function LoginPage() {
const [passcode, setPasscode] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [authMode, setAuthMode] = useState<AuthMode>('both');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [busy, setBusy] = useState<'passcode' | 'gpt' | null>(null);
async function submit(event: FormEvent) {
useEffect(() => {
let cancelled = false;
api
.config()
.then((payload) => {
if (!cancelled) setAuthMode(payload.auth_mode);
})
.catch(() => {
if (!cancelled) setAuthMode('both');
});
return () => {
cancelled = true;
};
}, []);
async function submitPasscode(event: FormEvent) {
event.preventDefault();
setBusy(true);
setBusy('passcode');
setError(null);
try {
@ -159,10 +200,28 @@ function LoginPage() {
} catch (err) {
setError(err instanceof ApiError && err.status === 401 ? 'Passcode rejected.' : 'Login failed.');
} finally {
setBusy(false);
setBusy(null);
}
}
async function submitGpt(event: FormEvent) {
event.preventDefault();
setBusy('gpt');
setError(null);
try {
await api.loginGpt(email, password);
window.location.hash = '#/';
} catch (err) {
setError(err instanceof ApiError && err.status === 401 ? 'Credentials rejected.' : 'Login failed.');
} finally {
setBusy(null);
}
}
const showPasscode = authMode === 'passcode' || authMode === 'both';
const showGpt = authMode === 'gpt' || authMode === 'both';
return (
<main className="login-page">
<section className="login-panel">
@ -173,19 +232,49 @@ function LoginPage() {
<small>machine.machine</small>
</span>
</div>
<form onSubmit={submit} className="login-form">
<label htmlFor="passcode">Fleet passcode</label>
<input
id="passcode"
autoFocus
type="password"
value={passcode}
onChange={(event) => setPasscode(event.target.value)}
autoComplete="current-password"
/>
{error && <p className="form-error">{error}</p>}
<button disabled={busy || passcode.trim().length === 0}>{busy ? 'Checking...' : 'Enter'}</button>
</form>
<div className="login-options">
{showGpt && (
<form onSubmit={submitGpt} className="login-form">
<label htmlFor="email">m2-gpt email</label>
<input
id="email"
autoFocus={showGpt}
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="username"
/>
<label htmlFor="password">m2-gpt password</label>
<input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
/>
<button disabled={busy !== null || email.trim().length === 0 || password.length === 0}>
{busy === 'gpt' ? 'Checking...' : 'Sign in'}
</button>
</form>
)}
{showPasscode && (
<form onSubmit={submitPasscode} className="login-form">
<label htmlFor="passcode">Fleet passcode</label>
<input
id="passcode"
autoFocus={!showGpt}
type="password"
value={passcode}
onChange={(event) => setPasscode(event.target.value)}
autoComplete="current-password"
/>
<button disabled={busy !== null || passcode.trim().length === 0}>
{busy === 'passcode' ? 'Checking...' : 'Enter'}
</button>
</form>
)}
</div>
{error && <p className="form-error">{error}</p>}
</section>
</main>
);

View file

@ -450,13 +450,20 @@ td {
.login-panel {
display: grid;
gap: 1.5rem;
width: min(100%, 420px);
width: min(100%, 520px);
padding: 1.5rem;
}
.login-options {
display: grid;
gap: 1rem;
}
.login-form {
display: grid;
gap: 0.75rem;
border-top: 1px solid var(--line);
padding-top: 1rem;
}
.login-form label {