diff --git a/specs/002-market-web/contracts/web-api.md b/specs/002-market-web/contracts/web-api.md index 3d054ee..33d65c0 100644 --- a/specs/002-market-web/contracts/web-api.md +++ b/specs/002-market-web/contracts/web-api.md @@ -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 --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}]} diff --git a/web/backend/src/m2_market_web/auth.py b/web/backend/src/m2_market_web/auth.py index dc439b3..d7db368 100644 --- a/web/backend/src/m2_market_web/auth.py +++ b/web/backend/src/m2_market_web/auth.py @@ -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) diff --git a/web/backend/src/m2_market_web/ledger.py b/web/backend/src/m2_market_web/ledger.py index 3c54908..f283f84 100644 --- a/web/backend/src/m2_market_web/ledger.py +++ b/web/backend/src/m2_market_web/ledger.py @@ -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}") diff --git a/web/backend/tests/test_backend.py b/web/backend/tests/test_backend.py index 5856ed0..757027f 100644 --- a/web/backend/tests/test_backend.py +++ b/web/backend/tests/test_backend.py @@ -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: diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 26618ef..1a303c3 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -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('/api/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + }, + config() { + return request('/api/config'); + }, + session() { + return request('/api/session'); + }, search(q: string, limit = 10) { const params = new URLSearchParams({ q, limit: String(limit) }); return request(`/api/search?${params}`); diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx index bbfc37a..a416be5 100644 --- a/web/frontend/src/main.tsx +++ b/web/frontend/src/main.tsx @@ -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(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 ; } @@ -121,13 +144,13 @@ function App() { - + {session?.fleet_admin && }
{route.name === 'search' && } {route.name === 'listing' && } - {route.name === 'wallet' && } + {route.name === 'wallet' && } {route.name === 'economy' && } {route.name === 'governance' && }
@@ -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('both'); const [error, setError] = useState(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 (
@@ -173,19 +232,49 @@ function LoginPage() { machine.machine -
- - setPasscode(event.target.value)} - autoComplete="current-password" - /> - {error &&

{error}

} - -
+
+ {showGpt && ( +
+ + setEmail(event.target.value)} + autoComplete="username" + /> + + setPassword(event.target.value)} + autoComplete="current-password" + /> + +
+ )} + {showPasscode && ( +
+ + setPasscode(event.target.value)} + autoComplete="current-password" + /> + +
+ )} +
+ {error &&

{error}

}
); diff --git a/web/frontend/src/styles.css b/web/frontend/src/styles.css index a9e960d..27ec6a6 100644 --- a/web/frontend/src/styles.css +++ b/web/frontend/src/styles.css @@ -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 {