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:
32
TODO.md
32
TODO.md
@@ -1,21 +1,31 @@
|
|||||||
A roughly triaged list of features and improvements needed for the app, in descending order of criticality.
|
## Major features
|
||||||
|
|
||||||
- [ ] **Technique swapping.** During character creation, players can offer to swap techniques with another player, as long as the technique they're offering wasn't created by the player they're offering to swap with. The swap offer should contain no information other than that a swap has been offered (and who offered it). This requires tracking who created each technique, which we should be doing anyway since it's illegal for a player to wind up with their own techniques and I'm not sure the current shuffler cares about that. I want two options here: First option, players start with all of their own techniques and the game can't proceed to technique assignment to cards until they've successfully swapped them all away and everyone has clicked a "ready" button. Second, a button in the hamburger menu that the Admin player can click to fully auto-assign techniques, which randomly distributes them to eligible players and automatically assigns them to J/Q/K. This button should only appear during technique distribution. It does not require dev mode, just admin privileges.
|
- [ ] **Technique swapping.** During character creation, players can offer to swap techniques with another player, as long as the technique they're offering wasn't created by the player they're offering to swap with. The swap offer should contain no information other than that a swap has been offered (and who offered it). This requires tracking who created each technique, which we should be doing anyway since it's illegal for a player to wind up with their own techniques and I'm not sure the current shuffler cares about that. I want two options here: First option, players start with all of their own techniques and the game can't proceed to technique assignment to cards until they've successfully swapped them all away and everyone has clicked a "ready" button. Second, a button in the hamburger menu that the Admin player can click to fully auto-assign techniques, which randomly distributes them to eligible players and automatically assigns them to J/Q/K. This button should only appear during technique distribution. It does not require dev mode, just admin privileges.
|
||||||
|
|
||||||
- [ ] **Light mode theme**. I like the "Lantern & Brine" look (see git log 0744893e7b82fe for your reasoning/description), and want to keep it as an option, but Yeld is generally speaking a lighter, more whimsical setting. I'd like the light theme to reflect a world of blue skies, high adventure, crashing waves, and bright red Xs on parchment treasure maps. Think "Wind Waker", "One Piece", or even "Skies of Arcadia". Add an option to the hamburger menu to toggle between light and dark themes. Light should probably be the default. Remember to update the CSS in the rulebook as well.
|
- [ ] **Light mode theme**. I like the "Lantern & Brine" look (see git log 0744893e7b82fe for your reasoning/description), and want to keep it as an option, but Yeld is generally speaking a lighter, more whimsical setting. I'd like the light theme to reflect a world of blue skies, high adventure, crashing waves, and bright red Xs on parchment treasure maps. Think "Wind Waker", "One Piece", or even "Skies of Arcadia". Add an option to the hamburger menu to toggle between light and dark themes. Light should probably be the default. Remember to update the CSS in the rulebook as well.
|
||||||
|
|
||||||
- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin.
|
- [ ] **Rollback of game state.** This may require some updates to how game state is handled in the backend (my understanding is that the frontend always gets full state updates rather than deltas, so it should be unaffected). Feel free to propose significant overhauls/refactors to enable this cleanly, I am not attached to the current database architecture at all. Also feel free to ask clarifying questions before going ahead with implementation. I think a clean UI for this would involve the event log. Each event gets a "rollback" button to roll back the state of the game to a checkpoint just after that event was posted. Until a new event is posted, the later entries in the log remain in a greyed out state so that the rollback can be undone if it was accidental. These buttons appear to the admin player if dev mode is on, and also to Deep players, but only going back as far as the start of the current scene if they aren't the admin.
|
||||||
|
|
||||||
- [ ] **Multiple admins.** The Admin player should be able to grant other players Admin privileges in the admin panel.
|
- [ ] **Uncommit feature in character creation**. While character creation is ongoing, add buttons to allow players to unlock/revise any choice that can be reasonably unlocked/revised
|
||||||
|
|
||||||
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
|
## Minor Polish
|
||||||
|
|
||||||
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
|
||||||
|
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
|
||||||
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection, doing some basic sanitizing of free inputs from players, and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary state updates.
|
|
||||||
|
|
||||||
- [ ] **Player minimum.** You need at least three players to start a game.
|
|
||||||
|
|
||||||
- [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes.
|
|
||||||
|
|
||||||
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
|
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
|
||||||
|
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
|
||||||
|
- [ ] **More detailed card tooltips.** Currently cards have a tooltip that show what they represent when played on obstacles, but they don't show what they represent *as* obstacles, which is relevant when issuing challenges to other Pi-Rats.
|
||||||
|
|
||||||
|
## Preventing blocked play
|
||||||
|
|
||||||
|
- [x] **Multiple admins.** The Admin player should be able to grant other players Admin privileges in the admin panel.
|
||||||
|
- [ ] **Player minimum.** You need at least three players to start a game, or to start a new scene if a player dropped out mid-game.
|
||||||
|
- [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins.
|
||||||
|
|
||||||
|
## Session management
|
||||||
|
- [ ] **Add invite link to Admin panel**
|
||||||
|
- [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh).
|
||||||
|
- [ ] **Add crew/player/rat name to page title, if available**. This will make it easier to distinguish tabs/browser history entries
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
|
<div class="player-chip {p.id === state.player.id ? 'current-user' : ''}">
|
||||||
<span class="avatar-icon">🐀</span>
|
<span class="avatar-icon">🐀</span>
|
||||||
<span class="name">{p.name}</span>
|
<span class="name">{p.name}</span>
|
||||||
{#if p.is_creator}<span class="creator-badge">Creator</span>{/if}
|
{#if p.is_creator}<span class="creator-badge">Creator</span>
|
||||||
|
{:else if p.is_admin}<span class="creator-badge">Admin</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,9 +55,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if state.player.is_creator}
|
{#if state.player.is_admin}
|
||||||
<div class="link-item admin-link-item">
|
<div class="link-item admin-link-item">
|
||||||
<h4 class="gold-text">⚠️ Creator Admin Magic Link:</h4>
|
<h4 class="gold-text">⚠️ Admin Magic Link:</h4>
|
||||||
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
|
<p class="info-text">Save this admin link! You can use it to recover character sheets if another player loses their connection or link.</p>
|
||||||
<div class="copy-container">
|
<div class="copy-container">
|
||||||
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/admin" class="copy-input admin-input" id="admin-link-copy">
|
<input type="text" readonly value="{window.location.origin}/#/game/{state.game.id}/admin" class="copy-input admin-input" id="admin-link-copy">
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lobby-action">
|
<div class="lobby-action">
|
||||||
{#if state.player.is_creator}
|
{#if state.player.is_admin}
|
||||||
{#if state.players.length >= 1}
|
{#if state.players.length >= 1}
|
||||||
<button on:click={startGame}
|
<button on:click={startGame}
|
||||||
disabled={starting}
|
disabled={starting}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
$: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
|
$: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
|
||||||
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
$: deepPlayer = state.players.find(p => p.role === 'deep');
|
||||||
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
||||||
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep');
|
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_admin || state.player.role === 'deep');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-setup-view text-center">
|
<div class="scene-setup-view text-center">
|
||||||
|
|||||||
@@ -344,7 +344,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if state.player.is_creator && state.game.phase === 'between_scenes'}
|
{#if state.player.is_admin && state.game.phase === 'between_scenes'}
|
||||||
<div class="end-story-box">
|
<div class="end-story-box">
|
||||||
<p class="info-text" style="font-size: 0.85rem;">All good stories end. When the crew agrees the legend is complete:</p>
|
<p class="info-text" style="font-size: 0.85rem;">All good stories end. When the crew agrees the legend is complete:</p>
|
||||||
<button on:click={finishGame} class="btn btn-danger">🏴☠️ End the Story</button>
|
<button on:click={finishGame} class="btn btn-danger">🏴☠️ End the Story</button>
|
||||||
|
|||||||
@@ -40,6 +40,19 @@
|
|||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setAdmin(playerId, makeAdmin) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${gameId}/admin/set-admin`, 'POST', {
|
||||||
|
key: adminKey,
|
||||||
|
target_player_id: playerId,
|
||||||
|
is_admin: makeAdmin,
|
||||||
|
});
|
||||||
|
await loadAdminData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="admin-page">
|
<div class="admin-page">
|
||||||
@@ -71,6 +84,7 @@
|
|||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Rank</th>
|
<th>Rank</th>
|
||||||
|
<th>Admin</th>
|
||||||
<th>Re-join Link</th>
|
<th>Re-join Link</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -92,6 +106,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>{p.rank}</td>
|
<td>{p.rank}</td>
|
||||||
|
<td>
|
||||||
|
{#if p.is_creator}
|
||||||
|
🗝 Creator
|
||||||
|
{:else if p.is_admin}
|
||||||
|
<button on:click={() => setAdmin(p.id, false)} class="btn btn-secondary btn-small">Revoke Admin</button>
|
||||||
|
{:else}
|
||||||
|
<button on:click={() => setAdmin(p.id, true)} class="btn btn-secondary btn-small">Grant Admin</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="link-copy-action">
|
<div class="link-copy-action">
|
||||||
<a href={rejoinLink(p.id)} class="btn btn-secondary btn-small">Open</a>
|
<a href={rejoinLink(p.id)} class="btn btn-secondary btn-small">Open</a>
|
||||||
|
|||||||
@@ -94,9 +94,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
|
||||||
|
$: if (state?.player?.is_admin && state?.game?.admin_key) {
|
||||||
|
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
|
||||||
|
}
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
const items = [];
|
const items = [];
|
||||||
if (state?.player?.is_creator) {
|
if (state?.player?.is_admin) {
|
||||||
items.push({
|
items.push({
|
||||||
label: `🧪 Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`,
|
label: `🧪 Dev Mode: ${state.game.dev_mode ? 'On' : 'Off'}`,
|
||||||
action: toggleDevMode,
|
action: toggleDevMode,
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -
|
|||||||
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
||||||
player_name=name,
|
player_name=name,
|
||||||
is_creator=is_creator,
|
is_creator=is_creator,
|
||||||
|
is_admin=is_creator, # the creator starts as an Admin; more can be granted from the admin panel
|
||||||
rank=2, # default starting rank (will be set properly when starting character creation)
|
rank=2, # default starting rank (will be set properly when starting character creation)
|
||||||
hand_cards="[]",
|
hand_cards="[]",
|
||||||
is_ready=False,
|
is_ready=False,
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
|
|||||||
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
|
events, events_have_more = crud.get_events_page(db, game_id, limit=EVENT_PAGE_SIZE)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"game": game.model_dump(exclude={"admin_key"}),
|
# admin_key stays hidden except from Admins, who need it to open the admin panel
|
||||||
|
"game": game.model_dump(exclude=(set() if player.is_admin else {"admin_key"})),
|
||||||
"player": player.model_dump(),
|
"player": player.model_dump(),
|
||||||
"players": [p.model_dump() for p in game.players],
|
"players": [p.model_dump() for p in game.players],
|
||||||
"obstacles": [o.model_dump() for o in game.obstacles],
|
"obstacles": [o.model_dump() for o in game.obstacles],
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add Player.is_admin
|
||||||
|
|
||||||
|
Revision ID: 8b2f4c9d1e07
|
||||||
|
Revises: 6071db83ba81
|
||||||
|
Create Date: 2026-06-12 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision = '8b2f4c9d1e07'
|
||||||
|
down_revision = '6071db83ba81'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
# Existing creators keep their privileges under the new flag
|
||||||
|
op.execute("UPDATE player SET is_admin = is_creator")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_admin')
|
||||||
@@ -34,6 +34,7 @@ class Player(SQLModel, table=True):
|
|||||||
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
||||||
previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
previous_role: Optional[str] = Field(default=None) # "pirat", "deep", or None
|
||||||
is_creator: bool = Field(default=False)
|
is_creator: bool = Field(default=False)
|
||||||
|
is_admin: bool = Field(default=False) # Admin privileges; the creator starts with them and can grant them to others
|
||||||
|
|
||||||
# Character sheet questions
|
# Character sheet questions
|
||||||
avatar_look: str = Field(default="")
|
avatar_look: str = Field(default="")
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ from . import crud
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
def _get_creator(db: Session, game_id: str, player_id: str):
|
def _get_admin(db: Session, game_id: str, player_id: str):
|
||||||
"""Resolves (game, player), requiring the player to be the game's creator."""
|
"""Resolves (game, player), requiring the player to be an Admin."""
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if not game or not player or player.game_id != game.id:
|
if not game or not player or player.game_id != game.id:
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
if not player.is_creator:
|
if not player.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Only the game creator can do that.")
|
raise HTTPException(status_code=403, detail="Only an Admin can do that.")
|
||||||
return game, player
|
return game, player
|
||||||
|
|
||||||
# Creator toggles Dev Mode for the whole table (reveals testing shortcuts)
|
# Admin toggles Dev Mode for the whole table (reveals testing shortcuts)
|
||||||
@router.post("/game/{game_id}/player/{player_id}/dev-mode")
|
@router.post("/game/{game_id}/player/{player_id}/dev-mode")
|
||||||
def set_dev_mode(
|
def set_dev_mode(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
@@ -25,7 +25,7 @@ def set_dev_mode(
|
|||||||
enabled: bool = Form(...),
|
enabled: bool = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
game, player = _get_creator(db, game_id, player_id)
|
game, player = _get_admin(db, game_id, player_id)
|
||||||
if game.dev_mode != enabled:
|
if game.dev_mode != enabled:
|
||||||
game.dev_mode = enabled
|
game.dev_mode = enabled
|
||||||
db.add(game)
|
db.add(game)
|
||||||
@@ -41,7 +41,7 @@ def skip_character_creation_route(
|
|||||||
fills: str = Form("{}"), # JSON: player_id -> suggested values for free-text fields
|
fills: str = Form("{}"), # JSON: player_id -> suggested values for free-text fields
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
game, player = _get_creator(db, game_id, player_id)
|
game, player = _get_admin(db, game_id, player_id)
|
||||||
if not game.dev_mode:
|
if not game.dev_mode:
|
||||||
raise HTTPException(status_code=403, detail="Dev Mode must be on to skip character creation.")
|
raise HTTPException(status_code=403, detail="Dev Mode must be on to skip character creation.")
|
||||||
try:
|
try:
|
||||||
@@ -77,6 +77,8 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
"is_dead": p.is_dead,
|
"is_dead": p.is_dead,
|
||||||
"is_ghost": p.is_ghost,
|
"is_ghost": p.is_ghost,
|
||||||
"role": p.role,
|
"role": p.role,
|
||||||
|
"is_creator": p.is_creator,
|
||||||
|
"is_admin": p.is_admin,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -90,3 +92,30 @@ def creator_admin_panel(request: Request, game_id: str, key: str, db: Session =
|
|||||||
"players": players_data,
|
"players": players_data,
|
||||||
"base_url": base_url
|
"base_url": base_url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Grant or revoke another player's Admin privileges (admin-key authenticated, like the panel itself)
|
||||||
|
@router.post("/game/{game_id}/admin/set-admin")
|
||||||
|
def set_player_admin(
|
||||||
|
game_id: str,
|
||||||
|
key: str = Form(...),
|
||||||
|
target_player_id: str = Form(...),
|
||||||
|
is_admin: bool = Form(...),
|
||||||
|
db: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
game = crud.get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
if game.admin_key != key:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden: Invalid admin key")
|
||||||
|
target = crud.get_player(db, target_player_id)
|
||||||
|
if not target or target.game_id != game.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
if target.is_creator and not is_admin:
|
||||||
|
return JSONResponse({"error": "The game creator's Admin privileges can't be revoked."}, status_code=400)
|
||||||
|
if target.is_admin != is_admin:
|
||||||
|
target.is_admin = is_admin
|
||||||
|
db.add(target)
|
||||||
|
db.commit()
|
||||||
|
verb = "granted" if is_admin else "revoked"
|
||||||
|
crud.add_game_event(db, game.id, f"🗝 Admin privileges {verb} for {target.name}.", kind="info")
|
||||||
|
return {"status": "ok", "is_admin": target.is_admin}
|
||||||
|
|||||||
@@ -1216,7 +1216,12 @@ def test_admin_key_returned_at_create_but_hidden_from_state():
|
|||||||
assert created["admin_key"]
|
assert created["admin_key"]
|
||||||
|
|
||||||
game_id = created["id"]
|
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()
|
state = client.get(f"/api/game/{game_id}/player/{player_id}/state").json()
|
||||||
assert "admin_key" not in state["game"]
|
assert "admin_key" not in state["game"]
|
||||||
finally:
|
finally:
|
||||||
@@ -1356,3 +1361,75 @@ def test_dev_mode_default_env(session, monkeypatch):
|
|||||||
assert crud.create_game(session).dev_mode is False
|
assert crud.create_game(session).dev_mode is False
|
||||||
monkeypatch.setenv("PIRATS_DEV_MODE", "true")
|
monkeypatch.setenv("PIRATS_DEV_MODE", "true")
|
||||||
assert crud.create_game(session).dev_mode is 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user