feat(client-tokens): add client API tokens with QR pairing flow

Long-lived, revocable, scope-restricted tokens for external clients
(mobile apps, retro handhelds, third-party tools). Includes:

- Backend: model, migration, DB handler, auth integration (rmm_ prefix
  routing in HybridAuthBackend), CRUD + pairing + exchange endpoints,
  rate limiting, scope intersection enforcement, admin oversight
- Frontend: settings page with token management table, stepped
  create/deliver dialog (config -> copy/pair), QR code with RomM logo,
  admin token table, standalone /pair page for QR scan landing
- /pair page supports custom-scheme callbacks for app deep linking,
  falls back to displaying code for manual entry
- 33 backend tests across 5 classes (CRUD, auth, isolation, pairing,
  admin)
This commit is contained in:
nendo
2026-03-10 16:06:07 +09:00
parent 3ebeb10b1c
commit e0b25fbc6c
25 changed files with 2558 additions and 2 deletions

View File

@@ -0,0 +1,60 @@
"""Add client_tokens table for long-lived API tokens
Revision ID: 0072_client_tokens
Revises: 0071_sibling_roms_fs_name
Create Date: 2026-03-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0072_client_tokens"
down_revision = "0071_sibling_roms_fs_name"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"client_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("hashed_token", sa.String(length=64), nullable=False),
sa.Column("scopes", sa.String(length=1000), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("last_used_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("client_tokens") as batch_op:
batch_op.create_index(
batch_op.f("ix_client_tokens_hashed_token"),
["hashed_token"],
unique=True,
)
batch_op.create_index(
batch_op.f("ix_client_tokens_user_id"),
["user_id"],
)
def downgrade() -> None:
with op.batch_alter_table("client_tokens") as batch_op:
batch_op.drop_index(batch_op.f("ix_client_tokens_user_id"))
batch_op.drop_index(batch_op.f("ix_client_tokens_hashed_token"))
op.drop_table("client_tokens")

View File

@@ -0,0 +1,290 @@
import json
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, Request, status
from pydantic import BaseModel
from decorators.auth import protected_route
from endpoints.responses.client_token import (
ClientTokenAdminSchema,
ClientTokenCreateSchema,
ClientTokenPairSchema,
ClientTokenSchema,
)
from handler.auth.base_handler import generate_client_token, hash_client_token
from handler.auth.constants import Scope
from handler.database import db_client_token_handler, db_user_handler
from handler.redis_handler import sync_cache
from models.client_token import ClientToken
from utils.router import APIRouter
router = APIRouter(
prefix="/client-tokens",
tags=["client-tokens"],
)
MAX_TOKENS_PER_USER = 25
PAIR_CODE_LENGTH = 8
PAIR_CODE_TTL_SECONDS = 60
RATE_LIMIT_MAX_ATTEMPTS = 5
RATE_LIMIT_WINDOW_SECONDS = 60
PAIR_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
EXPIRY_MAP = {
"30d": timedelta(days=30),
"90d": timedelta(days=90),
"1y": timedelta(days=365),
}
class ClientTokenCreatePayload(BaseModel):
name: str
scopes: list[str]
expires_in: str | None = None
class ClientTokenExchangePayload(BaseModel):
code: str
def _generate_pair_code() -> str:
return "".join(secrets.choice(PAIR_ALPHABET) for _ in range(PAIR_CODE_LENGTH))
def _parse_expiry(expires_in: str | None) -> datetime | None:
if expires_in is None or expires_in == "never":
return None
delta = EXPIRY_MAP.get(expires_in)
if delta is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid expires_in value: {expires_in}. "
f"Valid values: {', '.join(EXPIRY_MAP.keys())}, never",
)
return datetime.now(timezone.utc) + delta
def _validate_scopes(requested: list[str], user_scopes: list[Scope]) -> None:
user_scope_values = {str(s) for s in user_scopes}
invalid = set(requested) - user_scope_values
if invalid:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requested scopes exceed your permissions: "
f"{', '.join(sorted(invalid))}",
)
def _check_rate_limit(request: Request) -> None:
client_ip = request.client.host if request.client else "unknown"
rate_key = f"client-token-rate:{client_ip}"
current = sync_cache.get(rate_key)
if current and int(current) >= RATE_LIMIT_MAX_ATTEMPTS:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many exchange attempts. Try again later.",
)
pipe = sync_cache.pipeline()
pipe.incr(rate_key)
pipe.expire(rate_key, RATE_LIMIT_WINDOW_SECONDS)
pipe.execute()
def _build_create_schema(token: ClientToken, raw_token: str) -> ClientTokenCreateSchema:
return ClientTokenCreateSchema(
id=token.id,
name=token.name,
scopes=token.scopes.split(),
expires_at=token.expires_at,
last_used_at=token.last_used_at,
created_at=token.created_at,
user_id=token.user_id,
raw_token=raw_token,
)
def _build_schema(token: ClientToken) -> ClientTokenSchema:
return ClientTokenSchema(
id=token.id,
name=token.name,
scopes=token.scopes.split(),
expires_at=token.expires_at,
last_used_at=token.last_used_at,
created_at=token.created_at,
user_id=token.user_id,
)
def _build_admin_schema(token: ClientToken) -> ClientTokenAdminSchema:
return ClientTokenAdminSchema(
id=token.id,
name=token.name,
scopes=token.scopes.split(),
expires_at=token.expires_at,
last_used_at=token.last_used_at,
created_at=token.created_at,
user_id=token.user_id,
username=token.user.username,
)
@protected_route(router.post, "", [Scope.ME_WRITE], status_code=status.HTTP_201_CREATED)
def create_token(
request: Request,
payload: ClientTokenCreatePayload,
) -> ClientTokenCreateSchema:
user = request.user
_validate_scopes(payload.scopes, user.oauth_scopes)
count = db_client_token_handler.count_tokens_by_user(user.id)
if count >= MAX_TOKENS_PER_USER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum of {MAX_TOKENS_PER_USER} tokens per user reached",
)
raw_token = generate_client_token()
hashed = hash_client_token(raw_token)
expires_at = _parse_expiry(payload.expires_in)
token = ClientToken(
user_id=user.id,
name=payload.name,
hashed_token=hashed,
scopes=" ".join(payload.scopes),
expires_at=expires_at,
)
token = db_client_token_handler.add_token(token)
return _build_create_schema(token, raw_token)
@protected_route(router.get, "", [Scope.ME_READ])
def list_tokens(request: Request) -> list[ClientTokenSchema]:
tokens = db_client_token_handler.get_tokens_by_user(request.user.id)
return [_build_schema(t) for t in tokens]
@protected_route(router.delete, "/{token_id}", [Scope.ME_WRITE])
def delete_token(request: Request, token_id: int) -> None:
rows = db_client_token_handler.delete_token(
token_id=token_id, user_id=request.user.id
)
if rows == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Token not found",
)
@protected_route(router.put, "/{token_id}/regenerate", [Scope.ME_WRITE])
def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema:
raw_token = generate_client_token()
new_hash = hash_client_token(raw_token)
token = db_client_token_handler.update_hashed_token(
token_id=token_id,
new_hash=new_hash,
user_id=request.user.id,
)
if token is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Token not found",
)
return _build_create_schema(token, raw_token)
@protected_route(router.post, "/{token_id}/pair", [Scope.ME_WRITE])
def pair_token(request: Request, token_id: int) -> ClientTokenPairSchema:
token = db_client_token_handler.get_token(
token_id=token_id, user_id=request.user.id
)
if token is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Token not found",
)
code = _generate_pair_code()
redis_key = f"pair:{code}"
sync_cache.setex(
redis_key,
PAIR_CODE_TTL_SECONDS,
json.dumps({"token_id": token_id, "user_id": request.user.id}),
)
return ClientTokenPairSchema(code=code, expires_in=PAIR_CODE_TTL_SECONDS)
@router.get(
"/pair/{code}/status",
status_code=status.HTTP_200_OK,
)
def pair_status(code: str) -> None:
normalized = code.replace("-", "").upper()
redis_key = f"pair:{normalized}"
if not sync_cache.exists(redis_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
def _exchange(request: Request, code: str) -> ClientTokenCreateSchema:
_check_rate_limit(request)
normalized = code.replace("-", "").upper()
redis_key = f"pair:{normalized}"
data = sync_cache.get(redis_key)
if not data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid or expired pairing code",
)
sync_cache.delete(redis_key)
params = json.loads(data)
user = db_user_handler.get_user(params["user_id"])
if user is None or not user.enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token owner is disabled",
)
raw_token = generate_client_token()
new_hash = hash_client_token(raw_token)
token = db_client_token_handler.update_hashed_token(
token_id=params["token_id"],
new_hash=new_hash,
user_id=params["user_id"],
)
if token is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Token no longer exists",
)
return _build_create_schema(token, raw_token)
@router.post("/exchange")
def exchange_pair_code(
request: Request,
payload: ClientTokenExchangePayload,
) -> ClientTokenCreateSchema:
return _exchange(request, payload.code)
@protected_route(router.get, "/all", [Scope.USERS_READ])
def list_all_tokens(request: Request) -> list[ClientTokenAdminSchema]:
tokens = db_client_token_handler.get_all_tokens()
return [_build_admin_schema(t) for t in tokens]
@protected_route(router.delete, "/{token_id}/admin", [Scope.USERS_WRITE])
def admin_delete_token(request: Request, token_id: int) -> None:
rows = db_client_token_handler.delete_token(token_id=token_id)
if rows == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Token not found",
)

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from .base import BaseModel
class ClientTokenSchema(BaseModel):
id: int
name: str
scopes: list[str]
expires_at: datetime | None
last_used_at: datetime | None
created_at: datetime
user_id: int
class Config:
from_attributes = True
class ClientTokenCreateSchema(ClientTokenSchema):
raw_token: str
class ClientTokenAdminSchema(ClientTokenSchema):
username: str
class ClientTokenPairSchema(BaseModel):
code: str
expires_in: int

View File

@@ -1,3 +1,5 @@
import hashlib
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
@@ -32,6 +34,14 @@ from logger.logger import log
oct_key = OctKey.import_key(ROMM_AUTH_SECRET_KEY)
def generate_client_token() -> str:
return "rmm_" + secrets.token_hex(32)
def hash_client_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
class AuthHandler:
def __init__(self) -> None:
self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

View File

@@ -1,10 +1,15 @@
from datetime import datetime, timezone
from fastapi.security.http import HTTPBasic
from starlette.authentication import AuthCredentials, AuthenticationBackend
from starlette.requests import HTTPConnection
from config import KIOSK_MODE
from handler.auth import auth_handler, oauth_handler
from handler.auth.base_handler import hash_client_token
from handler.database import db_client_token_handler, db_user_handler
from models.user import User
from utils.datetime import to_utc
from .constants import READ_SCOPES
@@ -40,6 +45,31 @@ class HybridAuthBackend(AuthenticationBackend):
# Check if bearer auth header is valid
if scheme.lower() == "bearer":
# Client API tokens use the rmm_ prefix
if token.startswith("rmm_"):
hashed = hash_client_token(token)
client_token = db_client_token_handler.get_token_by_hash(hashed)
if client_token is None:
return None
if client_token.expires_at and to_utc(
client_token.expires_at
) < datetime.now(timezone.utc):
return None
user = db_user_handler.get_user(client_token.user_id)
if user is None or not user.enabled:
return None
token_scopes = set(client_token.scopes.split())
effective_scopes = list(token_scopes & set(user.oauth_scopes))
db_client_token_handler.update_last_used(client_token.id)
user.set_last_active()
conn.state.client_token_id = client_token.id
return (AuthCredentials(effective_scopes), user)
# OAuth JWT bearer tokens
(
user,
claims,

View File

@@ -1,3 +1,4 @@
from .client_tokens_handler import DBClientTokensHandler
from .collections_handler import DBCollectionsHandler
from .device_save_sync_handler import DBDeviceSaveSyncHandler
from .devices_handler import DBDevicesHandler
@@ -10,6 +11,7 @@ from .states_handler import DBStatesHandler
from .stats_handler import DBStatsHandler
from .users_handler import DBUsersHandler
db_client_token_handler = DBClientTokensHandler()
db_collection_handler = DBCollectionsHandler()
db_device_handler = DBDevicesHandler()
db_device_save_sync_handler = DBDeviceSaveSyncHandler()

View File

@@ -0,0 +1,143 @@
from collections.abc import Sequence
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session, joinedload
from decorators.database import begin_session
from models.client_token import ClientToken
from utils.datetime import to_utc
from .base_handler import DBBaseHandler
LAST_USED_DEBOUNCE = timedelta(minutes=5)
class DBClientTokensHandler(DBBaseHandler):
@begin_session
def add_token(
self,
token: ClientToken,
session: Session = None, # type: ignore
) -> ClientToken:
return session.merge(token)
@begin_session
def get_token_by_hash(
self,
hashed_token: str,
session: Session = None, # type: ignore
) -> ClientToken | None:
return session.scalar(
select(ClientToken).where(ClientToken.hashed_token == hashed_token)
)
@begin_session
def get_tokens_by_user(
self,
user_id: int,
session: Session = None, # type: ignore
) -> Sequence[ClientToken]:
return session.scalars(
select(ClientToken)
.where(ClientToken.user_id == user_id)
.order_by(ClientToken.created_at.desc())
).all()
@begin_session
def get_all_tokens(
self,
session: Session = None, # type: ignore
) -> Sequence[ClientToken]:
return (
session.scalars(
select(ClientToken)
.options(joinedload(ClientToken.user))
.order_by(ClientToken.created_at.desc())
)
.unique()
.all()
)
@begin_session
def delete_token(
self,
token_id: int,
user_id: int | None = None,
session: Session = None, # type: ignore
) -> int:
stmt = delete(ClientToken).where(ClientToken.id == token_id)
if user_id is not None:
stmt = stmt.where(ClientToken.user_id == user_id)
result = session.execute(stmt.execution_options(synchronize_session="evaluate"))
return result.rowcount
@begin_session
def update_last_used(
self,
token_id: int,
session: Session = None, # type: ignore
) -> None:
now = datetime.now(timezone.utc)
token = session.get(ClientToken, token_id)
if token is None:
return
if (
token.last_used_at
and (now - to_utc(token.last_used_at)) < LAST_USED_DEBOUNCE
):
return
session.execute(
update(ClientToken)
.where(ClientToken.id == token_id)
.values(last_used_at=now)
.execution_options(synchronize_session="evaluate")
)
@begin_session
def update_hashed_token(
self,
token_id: int,
new_hash: str,
user_id: int | None = None,
session: Session = None, # type: ignore
) -> ClientToken | None:
stmt = (
update(ClientToken)
.where(ClientToken.id == token_id)
.values(hashed_token=new_hash, last_used_at=None)
.execution_options(synchronize_session="evaluate")
)
if user_id is not None:
stmt = stmt.where(ClientToken.user_id == user_id)
result = session.execute(stmt)
if result.rowcount == 0:
return None
return session.get(ClientToken, token_id)
@begin_session
def count_tokens_by_user(
self,
user_id: int,
session: Session = None, # type: ignore
) -> int:
return (
session.scalar(
select(func.count())
.select_from(ClientToken)
.where(ClientToken.user_id == user_id)
)
or 0
)
@begin_session
def get_token(
self,
token_id: int,
user_id: int | None = None,
session: Session = None, # type: ignore
) -> ClientToken | None:
stmt = select(ClientToken).where(ClientToken.id == token_id)
if user_id is not None:
stmt = stmt.where(ClientToken.user_id == user_id)
return session.scalar(stmt)

View File

@@ -25,6 +25,7 @@ from config import (
SENTRY_DSN,
)
from endpoints.auth import router as auth_router
from endpoints.client_tokens import router as client_tokens_router
from endpoints.collections import router as collections_router
from endpoints.configs import router as configs_router
from endpoints.device import router as device_router
@@ -96,6 +97,8 @@ if not IS_PYTEST_RUN and not DISABLE_CSRF_PROTECTION:
secret=ROMM_AUTH_SECRET_KEY,
exempt_urls=[
re.compile(r"^/api/token.*"),
re.compile(r"^/api/client-tokens/exchange"),
re.compile(r"^/api/client-tokens/pair/.+/status"),
re.compile(r"^/ws"),
re.compile(r"^/netplay"),
],
@@ -121,6 +124,7 @@ app.middleware("http")(set_context_middleware)
app.include_router(heartbeat_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
app.include_router(user_router, prefix="/api")
app.include_router(client_tokens_router, prefix="/api")
app.include_router(device_router, prefix="/api")
app.include_router(platform_router, prefix="/api")
app.include_router(rom_router, prefix="/api")

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import TIMESTAMP, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
if TYPE_CHECKING:
from models.user import User
class ClientToken(BaseModel):
__tablename__ = "client_tokens"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(255))
hashed_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
scopes: Mapped[str] = mapped_column(String(1000))
expires_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
last_used_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
user: Mapped[User] = relationship(lazy="joined")

View File

@@ -21,6 +21,7 @@ from utils.database import CustomJSON
if TYPE_CHECKING:
from models.assets import Save, Screenshot, State
from models.client_token import ClientToken
from models.collection import Collection, SmartCollection
from models.device import Device
from models.rom import RomNote, RomUser
@@ -83,6 +84,9 @@ class User(BaseModel, SimpleUser):
devices: Mapped[list["Device"]] = relationship(
lazy="raise", back_populates="user", cascade="all, delete-orphan"
)
client_tokens: Mapped[list["ClientToken"]] = relationship(
lazy="raise", back_populates="user"
)
@classmethod
def kiosk_mode_user(cls) -> User:

View File

@@ -18,6 +18,7 @@ from handler.database import (
db_user_handler,
)
from models.assets import Save, Screenshot, State
from models.client_token import ClientToken
from models.device import Device
from models.device_save_sync import DeviceSaveSync
from models.platform import Platform
@@ -36,6 +37,7 @@ def setup_database():
@pytest.fixture(autouse=True)
def clear_database():
with session.begin() as s:
s.query(ClientToken).delete(synchronize_session="evaluate")
s.query(DeviceSaveSync).delete(synchronize_session="evaluate")
s.query(Device).delete(synchronize_session="evaluate")
s.query(Save).delete(synchronize_session="evaluate")

View File

@@ -0,0 +1,718 @@
from datetime import timedelta
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from main import app
from endpoints.auth import ACCESS_TOKEN_EXPIRE_SECONDS
from handler.auth import oauth_handler
from handler.auth.base_handler import hash_client_token
from handler.database import db_client_token_handler, db_user_handler
from handler.redis_handler import sync_cache
from models.client_token import ClientToken
from models.user import Role, User
@pytest.fixture
def client():
with TestClient(app) as client:
yield client
@pytest.fixture
def editor_access_token(editor_user):
return oauth_handler.create_oauth_token(
data={
"sub": editor_user.username,
"iss": "romm:oauth",
"scopes": " ".join(editor_user.oauth_scopes),
"type": "access",
},
expires_delta=timedelta(seconds=ACCESS_TOKEN_EXPIRE_SECONDS),
)
@pytest.fixture
def viewer_access_token(viewer_user):
return oauth_handler.create_oauth_token(
data={
"sub": viewer_user.username,
"iss": "romm:oauth",
"scopes": " ".join(viewer_user.oauth_scopes),
"type": "access",
},
expires_delta=timedelta(seconds=ACCESS_TOKEN_EXPIRE_SECONDS),
)
@pytest.fixture(autouse=True)
def clear_cache():
yield
sync_cache.flushall()
class TestClientTokenCRUD:
def test_create_token(self, client, access_token, admin_user):
response = client.post(
"/api/client-tokens",
json={
"name": "My Device",
"scopes": ["roms.read", "assets.read"],
"expires_in": "90d",
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
body = response.json()
assert body["name"] == "My Device"
assert body["raw_token"].startswith("rmm_")
assert len(body["raw_token"]) == 68
assert set(body["scopes"]) == {"roms.read", "assets.read"}
assert body["expires_at"] is not None
assert body["user_id"] == admin_user.id
def test_create_token_minimal(self, client, access_token, admin_user):
response = client.post(
"/api/client-tokens",
json={
"name": "Never Expires",
"scopes": ["roms.read"],
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
body = response.json()
assert body["expires_at"] is None
assert body["raw_token"].startswith("rmm_")
def test_list_tokens(self, client, access_token, admin_user):
for name in ["Token A", "Token B"]:
client.post(
"/api/client-tokens",
json={"name": name, "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
response = client.get(
"/api/client-tokens",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
tokens = response.json()
assert len(tokens) == 2
names = {t["name"] for t in tokens}
assert names == {"Token A", "Token B"}
for t in tokens:
assert "raw_token" not in t
def test_delete_token(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "To Delete", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
response = client.delete(
f"/api/client-tokens/{token_id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
list_resp = client.get(
"/api/client-tokens",
headers={"Authorization": f"Bearer {access_token}"},
)
assert len(list_resp.json()) == 0
def test_delete_token_not_found(self, client, access_token, admin_user):
response = client.delete(
"/api/client-tokens/99999",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_regenerate_token(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={
"name": "Regenerable",
"scopes": ["roms.read"],
"expires_in": "30d",
},
headers={"Authorization": f"Bearer {access_token}"},
)
old_raw = create_resp.json()["raw_token"]
token_id = create_resp.json()["id"]
regen_resp = client.put(
f"/api/client-tokens/{token_id}/regenerate",
headers={"Authorization": f"Bearer {access_token}"},
)
assert regen_resp.status_code == status.HTTP_200_OK
body = regen_resp.json()
new_raw = body["raw_token"]
assert new_raw.startswith("rmm_")
assert new_raw != old_raw
assert body["name"] == "Regenerable"
assert body["scopes"] == ["roms.read"]
assert body["expires_at"] is not None
# Old token should no longer work
old_hash = hash_client_token(old_raw)
assert db_client_token_handler.get_token_by_hash(old_hash) is None
# New token should work
new_hash = hash_client_token(new_raw)
assert db_client_token_handler.get_token_by_hash(new_hash) is not None
def test_create_token_limit(self, client, access_token, admin_user):
for i in range(25):
resp = client.post(
"/api/client-tokens",
json={"name": f"Token {i}", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert resp.status_code == status.HTTP_201_CREATED
resp = client.post(
"/api/client-tokens",
json={"name": "Token 26", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
def test_create_token_invalid_expiry(self, client, access_token, admin_user):
response = client.post(
"/api/client-tokens",
json={
"name": "Bad Expiry",
"scopes": ["roms.read"],
"expires_in": "999x",
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
class TestClientTokenAuth:
def test_authenticate_with_client_token(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Auth Test", "scopes": ["roms.read", "platforms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
raw_token = create_resp.json()["raw_token"]
# Use the client token to hit a protected endpoint
response = client.get(
"/api/platforms",
headers={"Authorization": f"Bearer {raw_token}"},
)
assert response.status_code == status.HTTP_200_OK
def test_expired_token_rejected(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={
"name": "Expired",
"scopes": ["roms.read", "platforms.read"],
"expires_in": "30d",
},
headers={"Authorization": f"Bearer {access_token}"},
)
raw_token = create_resp.json()["raw_token"]
token_id = create_resp.json()["id"]
# Manually set expires_at to the past
from tests.conftest import session
with session.begin() as s:
from datetime import datetime, timezone
s.query(ClientToken).filter_by(id=token_id).update(
{"expires_at": datetime(2020, 1, 1, tzinfo=timezone.utc)}
)
response = client.get(
"/api/platforms",
headers={"Authorization": f"Bearer {raw_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_revoked_token_rejected(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Revoked", "scopes": ["roms.read", "platforms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
raw_token = create_resp.json()["raw_token"]
token_id = create_resp.json()["id"]
client.delete(
f"/api/client-tokens/{token_id}",
headers={"Authorization": f"Bearer {access_token}"},
)
response = client.get(
"/api/platforms",
headers={"Authorization": f"Bearer {raw_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_scope_enforcement(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Read Only", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
raw_token = create_resp.json()["raw_token"]
# roms.read should allow listing platforms? No -- need platforms.read
response = client.get(
"/api/platforms",
headers={"Authorization": f"Bearer {raw_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_scope_intersection_on_demotion(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={
"name": "Admin Token",
"scopes": ["users.write", "roms.read", "platforms.read"],
},
headers={"Authorization": f"Bearer {access_token}"},
)
raw_token = create_resp.json()["raw_token"]
# Demote user to viewer
db_user_handler.update_user(admin_user.id, {"role": Role.VIEWER})
# users.write should no longer be effective
response = client.get(
"/api/users",
headers={"Authorization": f"Bearer {raw_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# Restore admin role for other tests
db_user_handler.update_user(admin_user.id, {"role": Role.ADMIN})
def test_disabled_user_rejected(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Disabled", "scopes": ["roms.read", "platforms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
raw_token = create_resp.json()["raw_token"]
db_user_handler.update_user(admin_user.id, {"enabled": False})
response = client.get(
"/api/platforms",
headers={"Authorization": f"Bearer {raw_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# Re-enable for other tests
db_user_handler.update_user(admin_user.id, {"enabled": True})
def test_invalid_token_format(self, client, admin_user):
response = client.get(
"/api/platforms",
headers={"Authorization": "Bearer rmm_invalidgarbage"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_scopes_subset_validation(self, client, viewer_access_token, viewer_user):
response = client.post(
"/api/client-tokens",
json={
"name": "Overreach",
"scopes": ["users.write"],
},
headers={"Authorization": f"Bearer {viewer_access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestClientTokenUserIsolation:
def test_list_only_own_tokens(
self,
client,
access_token,
editor_access_token,
admin_user,
editor_user,
):
client.post(
"/api/client-tokens",
json={"name": "Admin Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
client.post(
"/api/client-tokens",
json={"name": "Editor Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
admin_list = client.get(
"/api/client-tokens",
headers={"Authorization": f"Bearer {access_token}"},
).json()
editor_list = client.get(
"/api/client-tokens",
headers={"Authorization": f"Bearer {editor_access_token}"},
).json()
assert len(admin_list) == 1
assert admin_list[0]["name"] == "Admin Token"
assert len(editor_list) == 1
assert editor_list[0]["name"] == "Editor Token"
def test_cannot_delete_other_users_token(
self,
client,
access_token,
editor_access_token,
admin_user,
editor_user,
):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Editor's Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
token_id = create_resp.json()["id"]
# Admin tries to delete via user endpoint (not admin endpoint)
response = client.delete(
f"/api/client-tokens/{token_id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
editor_tokens = client.get(
"/api/client-tokens",
headers={"Authorization": f"Bearer {editor_access_token}"},
).json()
assert len(editor_tokens) == 1
def test_cannot_regenerate_other_users_token(
self,
client,
access_token,
editor_access_token,
admin_user,
editor_user,
):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Editor's Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
token_id = create_resp.json()["id"]
response = client.put(
f"/api/client-tokens/{token_id}/regenerate",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_admin_can_list_all_tokens(
self,
client,
access_token,
editor_access_token,
admin_user,
editor_user,
):
client.post(
"/api/client-tokens",
json={"name": "Admin Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
client.post(
"/api/client-tokens",
json={"name": "Editor Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
response = client.get(
"/api/client-tokens/all",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
tokens = response.json()
assert len(tokens) == 2
usernames = {t["username"] for t in tokens}
assert usernames == {"test_admin", "test_editor"}
class TestClientTokenPairing:
def test_pair_creates_code(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Pair Test", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
response = client.post(
f"/api/client-tokens/{token_id}/pair",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert len(body["code"]) == 8
assert body["expires_in"] == 60
def test_exchange_returns_token(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Exchange Test", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
old_raw = create_resp.json()["raw_token"]
pair_resp = client.post(
f"/api/client-tokens/{token_id}/pair",
headers={"Authorization": f"Bearer {access_token}"},
)
code = pair_resp.json()["code"]
exchange_resp = client.post(
"/api/client-tokens/exchange",
json={"code": code},
)
assert exchange_resp.status_code == status.HTTP_200_OK
new_raw = exchange_resp.json()["raw_token"]
assert new_raw.startswith("rmm_")
assert new_raw != old_raw
# Old credential should be dead
old_hash = hash_client_token(old_raw)
assert db_client_token_handler.get_token_by_hash(old_hash) is None
# New credential should work
new_hash = hash_client_token(new_raw)
assert db_client_token_handler.get_token_by_hash(new_hash) is not None
def test_exchange_expired_code(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Expired Code", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
pair_resp = client.post(
f"/api/client-tokens/{token_id}/pair",
headers={"Authorization": f"Bearer {access_token}"},
)
code = pair_resp.json()["code"]
# Delete the Redis key to simulate expiry
sync_cache.delete(f"pair:{code}")
exchange_resp = client.post(
"/api/client-tokens/exchange",
json={"code": code},
)
assert exchange_resp.status_code == status.HTTP_404_NOT_FOUND
def test_exchange_replay_rejected(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Replay Test", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
pair_resp = client.post(
f"/api/client-tokens/{token_id}/pair",
headers={"Authorization": f"Bearer {access_token}"},
)
code = pair_resp.json()["code"]
# First exchange succeeds
resp1 = client.post(
"/api/client-tokens/exchange",
json={"code": code},
)
assert resp1.status_code == status.HTTP_200_OK
# Second exchange with same code fails
resp2 = client.post(
"/api/client-tokens/exchange",
json={"code": code},
)
assert resp2.status_code == status.HTTP_404_NOT_FOUND
def test_exchange_invalid_code(self, client):
response = client.post(
"/api/client-tokens/exchange",
json={"code": "ZZZZZZZZ"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_exchange_case_insensitive(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Case Test", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
pair_resp = client.post(
f"/api/client-tokens/{token_id}/pair",
headers={"Authorization": f"Bearer {access_token}"},
)
code = pair_resp.json()["code"]
exchange_resp = client.post(
"/api/client-tokens/exchange",
json={"code": code.lower()},
)
assert exchange_resp.status_code == status.HTTP_200_OK
def test_pair_status_pending(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Status Test", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
pair_resp = client.post(
f"/api/client-tokens/{token_id}/pair",
headers={"Authorization": f"Bearer {access_token}"},
)
code = pair_resp.json()["code"]
status_resp = client.get(f"/api/client-tokens/pair/{code}/status")
assert status_resp.status_code == status.HTTP_200_OK
def test_pair_status_after_exchange(self, client, access_token, admin_user):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Status After", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
pair_resp = client.post(
f"/api/client-tokens/{token_id}/pair",
headers={"Authorization": f"Bearer {access_token}"},
)
code = pair_resp.json()["code"]
client.post(
"/api/client-tokens/exchange",
json={"code": code},
)
status_resp = client.get(f"/api/client-tokens/pair/{code}/status")
assert status_resp.status_code == status.HTTP_404_NOT_FOUND
def test_exchange_rate_limit(self, client):
for _ in range(5):
client.post(
"/api/client-tokens/exchange",
json={"code": "BADCODE1"},
)
response = client.post(
"/api/client-tokens/exchange",
json={"code": "BADCODE2"},
)
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
class TestClientTokenAdmin:
def test_admin_list_all(
self,
client,
access_token,
editor_access_token,
admin_user,
editor_user,
):
client.post(
"/api/client-tokens",
json={"name": "Admin's", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
client.post(
"/api/client-tokens",
json={"name": "Editor's", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
response = client.get(
"/api/client-tokens/all",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
tokens = response.json()
assert len(tokens) == 2
for t in tokens:
assert "username" in t
def test_admin_revoke_other_user_token(
self,
client,
access_token,
editor_access_token,
admin_user,
editor_user,
):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Editor Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
token_id = create_resp.json()["id"]
raw_token = create_resp.json()["raw_token"]
response = client.delete(
f"/api/client-tokens/{token_id}/admin",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
# Verify token no longer authenticates
hashed = hash_client_token(raw_token)
assert db_client_token_handler.get_token_by_hash(hashed) is None
def test_non_admin_cannot_list_all(self, client, editor_access_token, editor_user):
response = client.get(
"/api/client-tokens/all",
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_non_admin_cannot_admin_revoke(
self,
client,
access_token,
editor_access_token,
admin_user,
editor_user,
):
create_resp = client.post(
"/api/client-tokens",
json={"name": "Admin's Token", "scopes": ["roms.read"]},
headers={"Authorization": f"Bearer {access_token}"},
)
token_id = create_resp.json()["id"]
response = client.delete(
f"/api/client-tokens/{token_id}/admin",
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN

View File

@@ -108,6 +108,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -3824,6 +3825,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -4355,6 +4357,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4712,6 +4715,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5516,6 +5520,7 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -6151,6 +6156,7 @@
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -7355,6 +7361,7 @@
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"peer": true,
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
@@ -8158,6 +8165,7 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -9001,6 +9009,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9217,6 +9226,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -9364,6 +9374,7 @@
"integrity": "sha512-Q4SC/4TqbNvaZIFb9YsfBqkGlYHbJJJ6uU3CnRBZqLUF3s5eCMVZAaV4GkTbehIH/bhSj42lMXztOwc71u6rVw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vuetify/loader-shared": "^2.1.2",
"debug": "^4.3.3",
@@ -9390,6 +9401,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -9412,6 +9424,7 @@
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
@@ -9509,6 +9522,7 @@
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.2.tgz",
"integrity": "sha512-cVQa4+5iQpDs00ToMUnWRHlMdv1d5tEH2wcZIthqSCmBipQAG4rQKE55zFwZFYlPyiDhUVY1RcAFtXCuHNcCww==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/johnleider"
@@ -9841,6 +9855,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -9924,6 +9939,7 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { inject, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import RSection from "@/components/common/RSection.vue";
import clientTokenApi, {
type ClientTokenAdminSchema,
} from "@/services/api/client-token";
import type { Events } from "@/types/emitter";
import { formatTimestamp } from "@/utils";
const { t, locale } = useI18n();
const tokenSearch = ref("");
const tokens = ref<ClientTokenAdminSchema[]>([]);
const emitter = inject<Emitter<Events>>("emitter");
const HEADERS = [
{
title: "User",
align: "start",
sortable: true,
key: "username",
},
{
title: "Token Name",
align: "start",
sortable: true,
key: "name",
},
{
title: "Scopes",
align: "start",
sortable: false,
key: "scopes",
},
{
title: "Expires",
align: "start",
sortable: true,
key: "expires_at",
},
{
title: "Last used",
align: "start",
sortable: true,
key: "last_used_at",
},
{ title: "", align: "end", key: "actions", sortable: false },
] as const;
function fetchTokens() {
clientTokenApi
.fetchAllTokens()
.then(({ data }) => {
tokens.value = data;
})
.catch((error) => {
console.error(error);
});
}
async function adminDelete(tokenId: number) {
await clientTokenApi
.adminDeleteToken(tokenId)
.then(() => {
tokens.value = tokens.value.filter((t) => t.id !== tokenId);
emitter?.emit("snackbarShow", {
msg: t("settings.client-token-deleted"),
icon: "mdi-check",
color: "romm-green",
timeout: 4000,
});
})
.catch(({ response, message }) => {
emitter?.emit("snackbarShow", {
msg: `Unable to revoke token: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
});
});
}
onMounted(fetchTokens);
</script>
<template>
<RSection
icon="mdi-key-variant"
:title="t('settings.client-api-tokens')"
class="ma-2"
>
<template #content>
<v-text-field
v-model="tokenSearch"
prepend-inner-icon="mdi-magnify"
:label="t('common.search')"
single-line
hide-details
clearable
rounded="0"
density="comfortable"
class="bg-surface"
/>
<v-data-table-virtual
:style="{ 'max-height': '40dvh' }"
:search="tokenSearch"
:headers="HEADERS"
:items="tokens"
:sort-by="[{ key: 'username', order: 'asc' }]"
fixed-header
fixed-footer
density="comfortable"
class="rounded bg-background"
hide-default-footer
>
<template #item.scopes="{ item }">
<v-chip
v-for="scope in item.scopes"
:key="scope"
size="x-small"
class="mr-1"
label
>
{{ scope }}
</v-chip>
</template>
<template #item.expires_at="{ item }">
{{
item.expires_at
? formatTimestamp(item.expires_at, locale)
: t("settings.client-token-expiry-never")
}}
</template>
<template #item.last_used_at="{ item }">
{{
item.last_used_at ? formatTimestamp(item.last_used_at, locale) : "-"
}}
</template>
<template #item.actions="{ item }">
<v-btn
size="small"
variant="text"
class="text-romm-red"
@click="adminDelete(item.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
</v-data-table-virtual>
</template>
</RSection>
</template>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { inject, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import CreateClientTokenDialog from "@/components/Settings/ClientApiTokens/Dialog/CreateClientToken.vue";
import DeleteClientTokenDialog from "@/components/Settings/ClientApiTokens/Dialog/DeleteClientToken.vue";
import RSection from "@/components/common/RSection.vue";
import clientTokenApi, {
type ClientTokenSchema,
} from "@/services/api/client-token";
import type { Events } from "@/types/emitter";
import { formatTimestamp } from "@/utils";
const { t, locale } = useI18n();
const tokenSearch = ref("");
const tokens = ref<ClientTokenSchema[]>([]);
const emitter = inject<Emitter<Events>>("emitter");
const HEADERS = [
{
title: "Name",
align: "start",
sortable: true,
key: "name",
},
{
title: "Scopes",
align: "start",
sortable: false,
key: "scopes",
},
{
title: "Expires",
align: "start",
sortable: true,
key: "expires_at",
},
{
title: "Last used",
align: "start",
sortable: true,
key: "last_used_at",
},
{ title: "", align: "end", key: "actions", sortable: false },
] as const;
function fetchTokens() {
clientTokenApi
.fetchTokens()
.then(({ data }) => {
tokens.value = data;
})
.catch((error) => {
console.error(error);
});
}
onMounted(fetchTokens);
emitter?.on("showCreateClientTokenDialog", () => {
// Refetch after dialog closes via snackbar events
});
function onTokenCreated() {
fetchTokens();
}
function onTokenDeleted(tokenId: number) {
tokens.value = tokens.value.filter((t) => t.id !== tokenId);
}
</script>
<template>
<RSection
icon="mdi-key-variant"
:title="t('settings.client-api-tokens')"
class="ma-2"
>
<template #content>
<v-text-field
v-model="tokenSearch"
prepend-inner-icon="mdi-magnify"
:label="t('common.search')"
single-line
hide-details
clearable
rounded="0"
density="comfortable"
class="bg-surface"
/>
<v-data-table-virtual
:style="{ 'max-height': '75dvh' }"
:search="tokenSearch"
:headers="HEADERS"
:items="tokens"
:sort-by="[{ key: 'name', order: 'asc' }]"
fixed-header
fixed-footer
density="comfortable"
class="rounded bg-background"
hide-default-footer
>
<template #header.actions>
<v-btn
prepend-icon="mdi-plus"
variant="outlined"
density="compact"
class="text-primary"
@click="emitter?.emit('showCreateClientTokenDialog', null)"
>
{{ t("common.create") }}
</v-btn>
</template>
<template #item.name="{ item }">
<v-list-item class="pa-0" min-width="120px">
{{ item.name }}
</v-list-item>
</template>
<template #item.scopes="{ item }">
<v-chip
v-for="scope in item.scopes"
:key="scope"
size="x-small"
class="mr-1"
label
>
{{ scope }}
</v-chip>
</template>
<template #item.expires_at="{ item }">
{{
item.expires_at
? formatTimestamp(item.expires_at, locale)
: t("settings.client-token-expiry-never")
}}
</template>
<template #item.last_used_at="{ item }">
{{
item.last_used_at ? formatTimestamp(item.last_used_at, locale) : "-"
}}
</template>
<template #item.actions="{ item }">
<v-btn-group divided density="compact" variant="text">
<v-btn
size="small"
title="Regenerate"
@click="emitter?.emit('showRegenerateClientTokenDialog', item)"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
<v-btn
class="text-romm-red"
size="small"
title="Delete"
@click="emitter?.emit('showDeleteClientTokenDialog', item)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-btn-group>
</template>
</v-data-table-virtual>
</template>
</RSection>
<CreateClientTokenDialog @created="onTokenCreated" />
<DeleteClientTokenDialog @deleted="onTokenDeleted" />
</template>

View File

@@ -0,0 +1,561 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import qrcode from "qrcode";
import { computed, inject, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useDisplay } from "vuetify";
import RDialog from "@/components/common/RDialog.vue";
import clientTokenApi, {
type ClientTokenSchema,
} from "@/services/api/client-token";
import storeAuth from "@/stores/auth";
import type { Events } from "@/types/emitter";
const emit = defineEmits<{ created: [] }>();
const { t } = useI18n();
const { lgAndUp } = useDisplay();
const auth = storeAuth();
const emitter = inject<Emitter<Events>>("emitter");
const show = ref(false);
const step = ref<"config" | "delivery" | "copy" | "pair">("config");
const loading = ref(false);
const tokenName = ref("");
const selectedScopes = ref<string[]>([]);
const selectedExpiry = ref("never");
const rawToken = ref("");
const tokenId = ref<number | null>(null);
const pairCode = ref("");
const pairExpiresIn = ref(0);
const pairTimer = ref<ReturnType<typeof setInterval> | null>(null);
const pairCountdown = ref(0);
const pairStatus = ref<"pending" | "claimed" | "expired">("pending");
const pairLoading = ref(false);
const isRegenerate = ref(false);
const regenerateToken = ref<ClientTokenSchema | null>(null);
const expiryOptions = [
{ title: t("settings.client-token-expiry-30d"), value: "30d" },
{ title: t("settings.client-token-expiry-90d"), value: "90d" },
{ title: t("settings.client-token-expiry-1y"), value: "1y" },
{ title: t("settings.client-token-expiry-never"), value: "never" },
];
const scopeColumns = computed(() => {
const sortScopes = (scopes: string[]) => {
const prefixOrder = new Map<string, number>();
scopes.forEach((s) => {
const prefix = s.split(".").slice(0, -1).join(".");
if (!prefixOrder.has(prefix)) prefixOrder.set(prefix, prefixOrder.size);
});
return [...scopes].sort((a, b) => {
const pa = a.split(".").slice(0, -1).join(".");
const pb = b.split(".").slice(0, -1).join(".");
if (pa !== pb)
return (prefixOrder.get(pa) ?? 0) - (prefixOrder.get(pb) ?? 0);
return a.localeCompare(b);
});
};
const personal = sortScopes(
userScopes.value.filter((s) => /^(me|assets|devices|roms\.user)\./.test(s)),
);
const admin = sortScopes(
userScopes.value.filter((s) => /^(users|tasks)\./.test(s)),
);
const library = sortScopes(
userScopes.value.filter(
(s) =>
/^(platforms|firmware|collections)\./.test(s) ||
(/^roms\./.test(s) && !/^roms\.user\./.test(s)),
),
);
const grouped = new Set([...personal, ...admin, ...library]);
const other = userScopes.value.filter((s) => !grouped.has(s));
const left: { label: string; scopes: string[] }[] = [];
if (personal.length > 0) left.push({ label: "Personal", scopes: personal });
if (admin.length > 0) left.push({ label: "Administration", scopes: admin });
if (other.length > 0) left.push({ label: "Other", scopes: other });
const right: { label: string; scopes: string[] }[] = [];
if (library.length > 0) right.push({ label: "Library", scopes: library });
return { left, right };
});
const userScopes = computed(() => auth.scopes);
const configValid = computed(
() => tokenName.value.trim().length > 0 && selectedScopes.value.length > 0,
);
const formattedPairCode = computed(() => {
if (!pairCode.value) return "";
const c = pairCode.value;
return c.slice(0, 4) + "-" + c.slice(4);
});
const dialogTitle = computed(() => {
if (step.value === "config") return "Create New API Token";
if (step.value === "delivery") {
return isRegenerate.value ? "Regenerate Token" : "Deliver Token";
}
if (step.value === "copy") return "Copy Token";
return "Pair Device";
});
emitter?.on("showCreateClientTokenDialog", () => {
resetDialog();
isRegenerate.value = false;
regenerateToken.value = null;
selectedScopes.value = [...userScopes.value];
show.value = true;
});
emitter?.on("showRegenerateClientTokenDialog", (token) => {
resetDialog();
isRegenerate.value = true;
regenerateToken.value = token;
tokenName.value = token.name;
selectedScopes.value = [...token.scopes];
show.value = true;
step.value = "delivery";
doRegenerate();
});
function resetDialog() {
step.value = "config";
tokenName.value = "";
selectedScopes.value = [];
selectedExpiry.value = "never";
rawToken.value = "";
tokenId.value = null;
pairCode.value = "";
pairStatus.value = "pending";
pairCountdown.value = 0;
loading.value = false;
pairLoading.value = false;
clearPairTimer();
}
function clearPairTimer() {
if (pairTimer.value) {
clearInterval(pairTimer.value);
pairTimer.value = null;
}
}
async function createToken() {
loading.value = true;
try {
const { data } = await clientTokenApi.createToken({
name: tokenName.value.trim(),
scopes: selectedScopes.value,
expires_in:
selectedExpiry.value === "never" ? undefined : selectedExpiry.value,
});
rawToken.value = data.raw_token;
tokenId.value = data.id;
step.value = "delivery";
emit("created");
emitter?.emit("snackbarShow", {
msg: t("settings.client-token-created"),
icon: "mdi-check-bold",
color: "green",
});
} catch (error: any) {
emitter?.emit("snackbarShow", {
msg: error.response?.data?.detail || "Failed to create token",
icon: "mdi-close-circle",
color: "red",
});
} finally {
loading.value = false;
}
}
async function doRegenerate() {
if (!regenerateToken.value) return;
loading.value = true;
try {
const { data } = await clientTokenApi.regenerateToken(
regenerateToken.value.id,
);
rawToken.value = data.raw_token;
tokenId.value = data.id;
emit("created");
emitter?.emit("snackbarShow", {
msg: t("settings.client-token-regenerated"),
icon: "mdi-check-bold",
color: "green",
});
} catch (error: any) {
emitter?.emit("snackbarShow", {
msg: error.response?.data?.detail || "Failed to regenerate token",
icon: "mdi-close-circle",
color: "red",
});
show.value = false;
} finally {
loading.value = false;
}
}
async function copyToken() {
try {
await navigator.clipboard.writeText(rawToken.value);
emitter?.emit("snackbarShow", {
msg: t("settings.client-token-copied"),
icon: "mdi-check-bold",
color: "green",
});
} catch {
// Fallback: select the text
}
}
async function startPairing() {
if (!tokenId.value) return;
step.value = "pair";
pairLoading.value = true;
pairStatus.value = "pending";
try {
const { data } = await clientTokenApi.pairToken(tokenId.value);
pairCode.value = data.code;
pairExpiresIn.value = data.expires_in;
pairCountdown.value = data.expires_in;
pairLoading.value = false;
await nextTick();
renderQR(data.code);
startPairPolling();
} catch (error: any) {
pairLoading.value = false;
emitter?.emit("snackbarShow", {
msg: error.response?.data?.detail || "Failed to generate pairing code",
icon: "mdi-close-circle",
color: "red",
});
step.value = "delivery";
}
}
function startPairPolling() {
clearPairTimer();
pairTimer.value = setInterval(async () => {
pairCountdown.value -= 1;
if (pairCountdown.value <= 0) {
clearPairTimer();
pairStatus.value = "expired";
return;
}
if (pairCountdown.value % 3 === 0) {
try {
await clientTokenApi.pollPairStatus(pairCode.value);
} catch {
clearPairTimer();
if (pairCountdown.value > 0) {
pairStatus.value = "claimed";
emitter?.emit("snackbarShow", {
msg: t("settings.client-token-pair-claimed"),
icon: "mdi-check-bold",
color: "green",
});
} else {
pairStatus.value = "expired";
}
}
}
}, 1000);
}
async function regeneratePairCode() {
pairStatus.value = "pending";
pairLoading.value = true;
try {
const { data } = await clientTokenApi.pairToken(tokenId.value!);
pairCode.value = data.code;
pairExpiresIn.value = data.expires_in;
pairCountdown.value = data.expires_in;
pairLoading.value = false;
await nextTick();
renderQR(data.code);
startPairPolling();
} catch (error: any) {
pairLoading.value = false;
emitter?.emit("snackbarShow", {
msg: error.response?.data?.detail || "Failed to regenerate code",
icon: "mdi-close-circle",
color: "red",
});
}
}
function renderQR(code: string) {
const displayCode = code.slice(0, 4) + "-" + code.slice(4);
const pairUrl = `${window.location.origin}/pair?code=${displayCode}`;
const canvas = document.getElementById("pair-qr-code") as HTMLCanvasElement;
if (!canvas) return;
const size = lgAndUp.value ? 250 : 200;
qrcode.toCanvas(
canvas,
pairUrl,
{
margin: 2,
width: size,
errorCorrectionLevel: "H",
},
() => {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const logo = new Image();
logo.src = "/assets/logos/romm_logo_xbox_one_circle.svg";
logo.onload = () => {
const logoSize = canvas.width * 0.24;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = logoSize / 2 + 4;
// Circular white background
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.drawImage(
logo,
cx - logoSize / 2,
cy - logoSize / 2,
logoSize,
logoSize,
);
};
},
);
}
function closeDialog() {
clearPairTimer();
show.value = false;
}
watch(show, (val) => {
if (!val) clearPairTimer();
});
</script>
<template>
<RDialog
v-model="show"
:icon="isRegenerate ? 'mdi-refresh' : 'mdi-key-plus'"
:width="lgAndUp ? '50vw' : '95vw'"
@close="closeDialog"
>
<template #header>
<v-toolbar-title class="ml-2">{{ dialogTitle }}</v-toolbar-title>
</template>
<template #content>
<!-- Step 1: Configuration -->
<div v-if="step === 'config'" class="pa-4">
<v-text-field
v-model="tokenName"
:label="`${t('settings.client-token-name')} *`"
variant="outlined"
density="comfortable"
class="mb-2"
/>
<v-select
v-model="selectedExpiry"
:label="t('settings.client-token-select-expiry')"
:items="expiryOptions"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
class="mb-2"
/>
<div class="text-subtitle-2 mb-2">
{{ t("settings.client-token-scopes") }}
</div>
<v-row no-gutters>
<v-col cols="12" md="6" class="pr-2">
<template v-for="group in scopeColumns.left" :key="group.label">
<div class="text-caption text-medium-emphasis mb-1 mt-2">
{{ group.label }}
</div>
<v-checkbox
v-for="scope in group.scopes"
:key="scope"
v-model="selectedScopes"
:label="scope"
:value="scope"
density="compact"
hide-details
/>
</template>
</v-col>
<v-col cols="12" md="6" class="pr-2">
<template v-for="group in scopeColumns.right" :key="group.label">
<div class="text-caption text-medium-emphasis mb-1 mt-2">
{{ group.label }}
</div>
<v-checkbox
v-for="scope in group.scopes"
:key="scope"
v-model="selectedScopes"
:label="scope"
:value="scope"
density="compact"
hide-details
/>
</template>
</v-col>
</v-row>
</div>
<!-- Step 2: Delivery method choice -->
<div v-if="step === 'delivery'" class="pa-4 text-center">
<p class="text-body-1 mb-2">
{{
isRegenerate
? "Your previous token has been revoked and a new one generated. Choose how to deliver it:"
: "Your token has been created. Choose how to deliver it:"
}}
</p>
<p class="text-body-2 mb-4 text-warning">
{{ t("settings.client-token-delivery-hint") }}
</p>
<v-row class="justify-center" no-gutters>
<v-col cols="auto" class="mx-2">
<v-btn
size="large"
variant="tonal"
prepend-icon="mdi-content-copy"
@click="step = 'copy'"
>
Copy Token
</v-btn>
</v-col>
<v-col cols="auto" class="mx-2">
<v-btn
size="large"
variant="tonal"
prepend-icon="mdi-qrcode"
@click="startPairing"
>
Pair Device
</v-btn>
</v-col>
</v-row>
</div>
<!-- Step 2a: Copy token -->
<div v-if="step === 'copy'" class="pa-4">
<p class="text-body-2 mb-4 text-warning">
{{ t("settings.client-token-delivery-hint") }}
</p>
<v-text-field
:model-value="rawToken"
readonly
variant="outlined"
density="comfortable"
style="font-family: monospace"
>
<template #append-inner>
<v-icon
class="text-primary"
style="cursor: pointer"
@click="copyToken"
>
mdi-content-copy
</v-icon>
</template>
</v-text-field>
</div>
<!-- Step 2b: Pair device -->
<div v-if="step === 'pair'" class="pa-4 text-center">
<div v-if="pairLoading" class="py-8">
<v-progress-circular indeterminate />
</div>
<div v-else-if="pairStatus === 'pending'">
<canvas id="pair-qr-code" class="mx-auto d-block" />
<div
class="text-h5 font-weight-bold mt-2"
style="font-family: monospace"
>
{{ formattedPairCode }}
</div>
<div class="text-body-2 mt-2">{{ pairCountdown }}s</div>
</div>
<div v-else-if="pairStatus === 'claimed'" class="py-8">
<v-icon size="64" color="green">mdi-check-circle</v-icon>
<p class="text-h6 mt-4">
{{ t("settings.client-token-pair-claimed") }}
</p>
</div>
<div v-else-if="pairStatus === 'expired'" class="py-8">
<v-icon size="64" color="warning">mdi-timer-off</v-icon>
<p class="text-body-1 mt-4">
{{ t("settings.client-token-pair-expired") }}
</p>
<v-btn
class="mt-4"
variant="tonal"
prepend-icon="mdi-refresh"
@click="regeneratePairCode"
>
Regenerate Code
</v-btn>
</div>
</div>
</template>
<template #footer>
<v-row class="justify-center my-2" no-gutters>
<v-btn-group divided density="compact">
<template v-if="step === 'config'">
<v-btn class="bg-toplayer" @click="closeDialog">
{{ t("common.cancel") }}
</v-btn>
<v-btn
class="bg-toplayer text-primary"
:disabled="!configValid"
:loading="loading"
@click="createToken"
>
{{ t("common.create") }}
</v-btn>
</template>
<template v-else-if="step === 'delivery'">
<v-btn class="bg-toplayer" @click="closeDialog"> Close </v-btn>
</template>
<template v-else-if="step === 'copy'">
<v-btn class="bg-toplayer" @click="step = 'delivery'"> Back </v-btn>
<v-btn class="bg-toplayer" @click="closeDialog"> Close </v-btn>
</template>
<template v-else-if="step === 'pair'">
<v-btn class="bg-toplayer" @click="step = 'delivery'"> Back </v-btn>
<v-btn class="bg-toplayer" @click="closeDialog"> Close </v-btn>
</template>
</v-btn-group>
</v-row>
</template>
</RDialog>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useDisplay } from "vuetify";
import RDialog from "@/components/common/RDialog.vue";
import clientTokenApi, {
type ClientTokenSchema,
} from "@/services/api/client-token";
import type { Events } from "@/types/emitter";
const emit = defineEmits<{ deleted: [id: number] }>();
const { t } = useI18n();
const { lgAndUp } = useDisplay();
const token = ref<ClientTokenSchema | null>(null);
const show = ref(false);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showDeleteClientTokenDialog", (tokenToDelete) => {
token.value = tokenToDelete;
show.value = true;
});
async function deleteToken() {
if (!token.value) return;
await clientTokenApi
.deleteToken(token.value.id)
.then(() => {
emit("deleted", token.value!.id);
emitter?.emit("snackbarShow", {
msg: t("settings.client-token-deleted"),
icon: "mdi-check",
color: "romm-green",
timeout: 4000,
});
})
.catch(({ response, message }) => {
emitter?.emit("snackbarShow", {
msg: `Unable to delete token: ${
response?.data?.detail || response?.statusText || message
}`,
icon: "mdi-close-circle",
color: "red",
});
});
show.value = false;
}
function closeDialog() {
show.value = false;
}
</script>
<template>
<RDialog
v-if="token"
v-model="show"
icon="mdi-delete"
:width="lgAndUp ? '45vw' : '95vw'"
@close="closeDialog"
>
<template #content>
<v-row class="justify-center align-center pa-4" no-gutters>
<span>{{ t("settings.client-token-confirm-delete") }}</span>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-chip label class="text-primary">{{ token.name }}</v-chip>
</v-row>
</template>
<template #footer>
<v-row class="justify-center my-2" no-gutters>
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="closeDialog">
{{ t("common.cancel") }}
</v-btn>
<v-btn class="bg-toplayer text-romm-red" @click="deleteToken">
{{ t("common.confirm") }}
</v-btn>
</v-btn-group>
</v-row>
</template>
</RDialog>
</template>

View File

@@ -153,6 +153,18 @@ function onClose() {
>
{{ t("scan.metadata-sources") }}
</v-list-item>
<v-list-item
v-if="scopes.includes('me.write')"
:tabindex="tabIndex"
class="mt-1"
rounded
:to="{ name: ROUTES.CLIENT_API_TOKENS }"
append-icon="mdi-key-variant"
aria-label="Client API Tokens"
role="listitem"
>
{{ t("settings.client-api-tokens") }}
</v-list-item>
<v-list-item
v-if="scopes.includes('users.write')"
:tabindex="tabIndex"

View File

@@ -13,6 +13,23 @@
"boxart-physical": "Physical",
"boxart-style": "Boxart style",
"canceled": "Canceled",
"client-api-tokens": "Client API Tokens",
"client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.",
"client-token-confirm-regenerate": "This will invalidate the current credential. Any device using the old token will need to be re-paired.",
"client-token-copied": "Token copied to clipboard",
"client-token-created": "Token created successfully",
"client-token-deleted": "Token revoked successfully",
"client-token-delivery-hint": "This token will not be shown again.",
"client-token-expiry-30d": "30 days",
"client-token-expiry-90d": "90 days",
"client-token-expiry-1y": "1 year",
"client-token-expiry-never": "Never",
"client-token-name": "Token name",
"client-token-pair-claimed": "Device paired successfully!",
"client-token-pair-expired": "Pairing code expired",
"client-token-regenerated": "Token regenerated successfully",
"client-token-scopes": "Permissions",
"client-token-select-expiry": "Expiration",
"cleanup": "Cleanup",
"cleanup-all": "Clean up all",
"cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.",

View File

@@ -33,8 +33,10 @@ export const ROUTES = {
USER_INTERFACE: "user-interface",
LIBRARY_MANAGEMENT: "library-management",
METADATA_SOURCES: "metadata-sources",
CLIENT_API_TOKENS: "client-api-tokens",
ADMINISTRATION: "administration",
SERVER_STATS: "server-stats",
PAIR: "pair",
NOT_FOUND: "404",
CONSOLE_HOME: "console-home",
CONSOLE_PLATFORM: "console-platform",
@@ -226,6 +228,14 @@ const routes = [
},
component: () => import("@/views/Settings/MetadataSources.vue"),
},
{
path: "client-api-tokens",
name: ROUTES.CLIENT_API_TOKENS,
meta: {
title: i18n.global.t("settings.client-api-tokens"),
},
component: () => import("@/views/Settings/ClientApiTokens.vue"),
},
{
path: "administration",
name: ROUTES.ADMINISTRATION,
@@ -249,6 +259,11 @@ const routes = [
},
],
},
{
path: "/pair",
name: ROUTES.PAIR,
component: () => import("@/views/Pair.vue"),
},
// Console mode (separate UI namespace under /console)
{
path: "/console",
@@ -313,6 +328,7 @@ const router = createRouter({
});
const routePermissions: RoutePermissions[] = [
{ path: ROUTES.CLIENT_API_TOKENS, requiredScopes: ["me.write"] },
{ path: ROUTES.SCAN, requiredScopes: ["platforms.write"] },
{ path: ROUTES.LIBRARY_MANAGEMENT, requiredScopes: ["platforms.write"] },
{ path: ROUTES.ADMINISTRATION, requiredScopes: ["users.write"] },
@@ -324,7 +340,8 @@ function checkRoutePermissions(route: string, user: User | null): boolean {
route === ROUTES.LOGIN ||
route === ROUTES.SETUP ||
route === ROUTES.RESET_PASSWORD ||
route === ROUTES.REGISTER
route === ROUTES.REGISTER ||
route === ROUTES.PAIR
)
return true;
@@ -358,7 +375,8 @@ router.beforeEach(async (to, _from, next) => {
!user.value &&
currentRoute !== ROUTES.LOGIN &&
currentRoute !== ROUTES.RESET_PASSWORD &&
currentRoute !== ROUTES.REGISTER
currentRoute !== ROUTES.REGISTER &&
currentRoute !== ROUTES.PAIR
) {
return next({
name: ROUTES.LOGIN,

View File

@@ -0,0 +1,88 @@
import api from "@/services/api";
export interface ClientTokenSchema {
id: number;
name: string;
scopes: string[];
expires_at: string | null;
last_used_at: string | null;
created_at: string;
user_id: number;
}
export interface ClientTokenCreateSchema extends ClientTokenSchema {
raw_token: string;
}
export interface ClientTokenAdminSchema extends ClientTokenSchema {
username: string;
}
export interface ClientTokenPairSchema {
code: string;
expires_in: number;
}
async function createToken({
name,
scopes,
expires_in,
}: {
name: string;
scopes: string[];
expires_in?: string;
}) {
return api.post<ClientTokenCreateSchema>("/client-tokens", {
name,
scopes,
expires_in,
});
}
async function fetchTokens() {
return api.get<ClientTokenSchema[]>("/client-tokens");
}
async function deleteToken(tokenId: number) {
return api.delete(`/client-tokens/${tokenId}`);
}
async function regenerateToken(tokenId: number) {
return api.put<ClientTokenCreateSchema>(
`/client-tokens/${tokenId}/regenerate`,
);
}
async function pairToken(tokenId: number) {
return api.post<ClientTokenPairSchema>(`/client-tokens/${tokenId}/pair`);
}
async function pollPairStatus(code: string) {
return api.get(`/client-tokens/pair/${code}/status`);
}
async function exchangeCode(code: string) {
return api.post<ClientTokenCreateSchema>("/client-tokens/exchange", {
code,
});
}
async function fetchAllTokens() {
return api.get<ClientTokenAdminSchema[]>("/client-tokens/all");
}
async function adminDeleteToken(tokenId: number) {
return api.delete(`/client-tokens/${tokenId}/admin`);
}
export default {
createToken,
fetchTokens,
deleteToken,
regenerateToken,
pairToken,
pollPairStatus,
exchangeCode,
fetchAllTokens,
adminDeleteToken,
};

View File

@@ -1,4 +1,5 @@
import type { SaveSchema, StateSchema } from "@/__generated__";
import type { ClientTokenSchema } from "@/services/api/client-token";
import type { Collection, SmartCollection } from "@/stores/collections";
import type { Platform } from "@/stores/platforms";
import type { SimpleRom } from "@/stores/roms";
@@ -73,6 +74,9 @@ export type Events = {
saveSelected: SaveSchema;
openEmulatorJSCacheDialog: null;
stateSelected: StateSchema;
showCreateClientTokenDialog: null;
showRegenerateClientTokenDialog: ClientTokenSchema;
showDeleteClientTokenDialog: ClientTokenSchema;
showAboutDialog: null;
showNoteDialog: SimpleRom;
playGame: number;

107
frontend/src/views/Pair.vue Normal file
View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import axios from "axios";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const code = computed(() => (route.query.code as string) || "");
const callback = computed(() => (route.query.callback as string) || "");
const status = ref<"idle" | "exchanging" | "success" | "error">("idle");
const rawToken = ref("");
const errorMessage = ref("");
function isCustomScheme(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol !== "http:" && parsed.protocol !== "https:";
} catch {
return false;
}
}
async function exchange(pairCode: string): Promise<string> {
const resp = await axios.post<{ raw_token: string }>(
"/api/client-tokens/exchange",
{ code: pairCode },
);
return resp.data.raw_token;
}
onMounted(async () => {
if (!code.value) {
status.value = "error";
errorMessage.value = "No pairing code provided.";
return;
}
if (callback.value) {
if (!isCustomScheme(callback.value)) {
status.value = "error";
errorMessage.value =
"Only custom URL schemes are supported for callbacks (no http/https).";
return;
}
status.value = "exchanging";
try {
const token = await exchange(code.value);
const separator = callback.value.includes("?") ? "&" : "?";
window.location.href = `${callback.value}${separator}token=${encodeURIComponent(token)}`;
} catch (err: any) {
status.value = "error";
errorMessage.value =
err.response?.data?.detail || "Failed to exchange pairing code.";
}
return;
}
// No callback -- just display the code for manual entry
status.value = "idle";
});
const formattedCode = computed(() => {
const c = code.value.replace("-", "").toUpperCase();
if (c.length === 8) return c.slice(0, 4) + "-" + c.slice(4);
return code.value;
});
</script>
<template>
<v-app>
<v-main
class="d-flex align-center justify-center"
style="min-height: 100dvh"
>
<v-card max-width="450" class="pa-8 text-center" variant="outlined">
<template v-if="status === 'exchanging'">
<v-progress-circular indeterminate size="48" class="mb-4" />
<p class="text-body-1">Exchanging pairing code...</p>
</template>
<template v-else-if="status === 'error'">
<v-icon size="64" color="error" class="mb-4">mdi-alert-circle</v-icon>
<p class="text-body-1">{{ errorMessage }}</p>
</template>
<template v-else>
<v-icon size="48" class="mb-4">mdi-key-variant</v-icon>
<p class="text-body-1 mb-4">
Enter this code in your device to complete pairing:
</p>
<div
class="text-h3 font-weight-bold mb-4"
style="font-family: monospace; letter-spacing: 0.1em"
>
{{ formattedCode }}
</div>
<p class="text-body-2 text-medium-emphasis">
This code expires shortly. Return to the web interface to generate a
new one if needed.
</p>
</template>
</v-card>
</v-main>
</v-app>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import Tasks from "@/components/Settings/Administration/Tasks.vue";
import TokensTable from "@/components/Settings/Administration/Tokens/TokensTable.vue";
import UsersTable from "@/components/Settings/Administration/Users/UsersTable.vue";
import storeAuth from "@/stores/auth";
@@ -7,5 +8,6 @@ const auth = storeAuth();
</script>
<template>
<UsersTable />
<TokensTable v-if="auth.scopes.includes('users.read')" class="mt-6" />
<Tasks v-if="auth.scopes.includes('tasks.run')" class="mt-6" />
</template>

View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
import ClientTokensTable from "@/components/Settings/ClientApiTokens/ClientTokensTable.vue";
</script>
<template>
<ClientTokensTable />
</template>