Multiple admins

Adds Player.is_admin (creator starts with it; backfilled by migration).
Admin-key-authenticated POST /admin/set-admin grants/revokes it from the
admin panel; the creator's privileges can't be revoked. Admin-gated
backend routes (dev mode, skip character creation) and frontend controls
(corner menu, lobby start, scene start, end story) now check is_admin
instead of is_creator, and admins get the admin_key in their state blob
so granted players can open the admin panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 16:03:14 -07:00
parent e24e6d3b46
commit 5ad9c9db07
12 changed files with 203 additions and 27 deletions

View File

@@ -1216,7 +1216,12 @@ def test_admin_key_returned_at_create_but_hidden_from_state():
assert created["admin_key"]
game_id = created["id"]
player_id = client.post(f"/api/game/{game_id}/join", data={"name": "Carlos"}).json()["id"]
# First joiner is the creator (an Admin), so they DO see the key
creator_id = client.post(f"/api/game/{game_id}/join", data={"name": "Carlos"}).json()["id"]
state = client.get(f"/api/game/{game_id}/player/{creator_id}/state").json()
assert state["game"]["admin_key"] == created["admin_key"]
# Regular players don't
player_id = client.post(f"/api/game/{game_id}/join", data={"name": "Swabbie"}).json()["id"]
state = client.get(f"/api/game/{game_id}/player/{player_id}/state").json()
assert "admin_key" not in state["game"]
finally:
@@ -1356,3 +1361,75 @@ def test_dev_mode_default_env(session, monkeypatch):
assert crud.create_game(session).dev_mode is False
monkeypatch.setenv("PIRATS_DEV_MODE", "true")
assert crud.create_game(session).dev_mode is True
def test_grant_and_revoke_admin():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine, Session
from pirats.main import app
from pirats import crud
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
game = crud.create_game(session)
creator = crud.add_player(session, game.id, "Creator", is_creator=True)
mate = crud.add_player(session, game.id, "Mate")
assert creator.is_admin
assert not mate.is_admin
def get_session_override():
yield session
from pirats.database import get_session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
try:
# Admin-only endpoints reject non-admins
response = client.post(f"/api/game/{game.id}/player/{mate.id}/dev-mode", data={"enabled": True})
assert response.status_code == 403
# Wrong key can't grant
response = client.post(f"/api/game/{game.id}/admin/set-admin",
data={"key": "nope", "target_player_id": mate.id, "is_admin": True})
assert response.status_code == 403
# Grant with the real key
response = client.post(f"/api/game/{game.id}/admin/set-admin",
data={"key": game.admin_key, "target_player_id": mate.id, "is_admin": True})
assert response.status_code == 200
session.refresh(mate)
assert mate.is_admin
# The new admin can use admin-only endpoints and sees the admin key in state
response = client.post(f"/api/game/{game.id}/player/{mate.id}/dev-mode", data={"enabled": True})
assert response.status_code == 200
response = client.get(f"/api/game/{game.id}/player/{mate.id}/state")
assert response.json()["game"]["admin_key"] == game.admin_key
# ...but non-admins still don't
response = client.get(f"/api/game/{game.id}/player/{creator.id}/state")
assert "admin_key" in response.json()["game"] # creator is admin too
# Revoke works on regular admins, never on the creator
response = client.post(f"/api/game/{game.id}/admin/set-admin",
data={"key": game.admin_key, "target_player_id": mate.id, "is_admin": False})
assert response.status_code == 200
session.refresh(mate)
assert not mate.is_admin
response = client.post(f"/api/game/{game.id}/admin/set-admin",
data={"key": game.admin_key, "target_player_id": creator.id, "is_admin": False})
assert response.status_code == 400
session.refresh(creator)
assert creator.is_admin
# Once revoked, the state blob hides the key again
response = client.get(f"/api/game/{game.id}/player/{mate.id}/state")
assert "admin_key" not in response.json()["game"]
finally:
app.dependency_overrides.clear()