T301: m2-gpt identity login (central credentials consumer)
This commit is contained in:
parent
5dd73eb4cd
commit
a77b0dbaef
7 changed files with 536 additions and 37 deletions
|
|
@ -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}]}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue