diff --git a/backend/app/deps.py b/backend/app/deps.py index ee64307..40d6088 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -113,3 +113,20 @@ def _dependency( return ctx return _dependency + + +def require_head_coach( + ctx: CurrentMembership = Depends(get_current_membership), +) -> CurrentMembership: + """T-043 decision 3: head-coach-only routes (remove a member, change a + member's role_on_team). The head coach is the team's CREATOR + (Team.created_by), a separate concept from role_on_team, so this is + its own dependency rather than another require_role_on_team() value: + a non-creator coach and a player both get 403 here, on exactly the + same terms (CLAUDE.md rule 5: enforced in the API, independent of + which role_on_team the caller otherwise holds).""" + if ctx.team.created_by != ctx.user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Only the head coach can do this" + ) + return ctx diff --git a/backend/app/models/platform.py b/backend/app/models/platform.py index 4861b2f..28e93af 100644 --- a/backend/app/models/platform.py +++ b/backend/app/models/platform.py @@ -48,7 +48,20 @@ class Team(Base): colors_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) age_group: Mapped[str | None] = mapped_column(String(50), nullable=True) level: Mapped[str | None] = mapped_column(String(50), nullable=True) + # T-043 (founder decision 2026-07-16): role-scoped join codes. Two + # codes per team now, each resolving to a fixed role_on_team for + # whoever joins with it, independent of that account's own global + # `role`. `join_code` is the ORIGINAL column from T-003 (doc 03 + # section 2), REPURPOSED in place rather than renamed or replaced, so + # every pre-existing team's code and every row referencing it survive + # the migration untouched: it is now specifically the PLAYER code. + # `coach_join_code` is new (migration 0004), generated the same way, + # and its uniqueness is checked against BOTH columns (see + # app/routers/teams.py _unique_code) so a submitted code can never + # match a row in one column and a different row in the other: any + # code that validates resolves to exactly one team and one role. join_code: Mapped[str] = mapped_column(String(12), unique=True, index=True, nullable=False) + coach_join_code: Mapped[str] = mapped_column(String(12), unique=True, index=True, nullable=False) created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_utcnow, nullable=False diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5a6eeee..e105069 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -4,7 +4,15 @@ from app.config import COOKIE_SECURE, JWT_TTL_SECONDS, SESSION_COOKIE_NAME from app.deps import get_current_user_optional, get_db from app.models import TeamMember, User -from app.schemas import LoginRequest, MeOut, MembershipOut, RegisterRequest, TeamOut, UserOut +from app.schemas import ( + CoachMembershipOut, + CoachTeamOut, + LoginRequest, + MembershipOut, + RegisterRequest, + TeamOut, + UserOut, +) from app.security import hash_password, verify_password from app.tokens import create_session_token @@ -72,13 +80,19 @@ def logout(response: Response) -> dict[str, bool]: return {"ok": True} -@router.get("/me", response_model=MeOut) +@router.get("/me", response_model=None) def me( current_user: User | None = Depends(get_current_user_optional), db: Session = Depends(get_db), -) -> MeOut: +) -> dict: + # response_model=None (see schemas.py MeOut docstring): each + # membership below is dumped through MembershipOut or + # CoachMembershipOut individually, picked per-row by that row's own + # role_on_team, so a coach's own team(s) carry both join codes and a + # player's carry neither key at all (T-043 decision 2). A single + # shared response_model cannot express that per-row split. if current_user is None: - return MeOut(user=None, memberships=[]) + return {"user": None, "memberships": []} memberships = ( db.query(TeamMember) @@ -86,14 +100,24 @@ def me( .order_by(TeamMember.joined_at.asc()) .all() ) - return MeOut( - user=UserOut.model_validate(current_user), - memberships=[ - MembershipOut( + membership_outs = [] + for m in memberships: + membership_out: MembershipOut | CoachMembershipOut + if m.role_on_team == "coach": + membership_out = CoachMembershipOut( + team=CoachTeamOut.model_validate(m.team), + role_on_team=m.role_on_team, # type: ignore[arg-type] + joined_at=m.joined_at, + ) + else: + membership_out = MembershipOut( team=TeamOut.model_validate(m.team), role_on_team=m.role_on_team, # type: ignore[arg-type] joined_at=m.joined_at, ) - for m in memberships - ], - ) + membership_outs.append(membership_out.model_dump(mode="json")) + + return { + "user": UserOut.model_validate(current_user).model_dump(mode="json"), + "memberships": membership_outs, + } diff --git a/backend/app/routers/teams.py b/backend/app/routers/teams.py index a76bcc4..81540d7 100644 --- a/backend/app/routers/teams.py +++ b/backend/app/routers/teams.py @@ -1,9 +1,49 @@ +"""Team creation, role-scoped join, and head-coach member management +(doc 03 section 2; T-043, founder decision 2026-07-16). + +Join codes: a team carries two, `join_code` (the player code, doc 03's +original column, repurposed in place) and `coach_join_code` (added by +migration 0004). Joining with a code assigns THAT code's role on the +team, never the joiner's own account `role` (app/models/User.role is only +ever consulted for team CREATION below, exactly as before). Both codes +are coach-only in every response shape here: CoachTeamOut is built only +when the caller's own role_on_team (post-join, for join_team; from the +resolved membership, for current_team; from the just-created coach +membership, for create_team) is "coach"; a player-shaped response uses +plain TeamOut, which has no join-code field at all (see schemas.py). + +Head-coach member management: the head coach is the team's creator +(Team.created_by). require_head_coach (app/deps.py) 403s anyone else, +coach or player, on the two mutation routes below. The plain member list +is visible to any coach (require_role_on_team("coach")), same as the +Brief section 3 "Roster ... Full" coach capability shape, but 403s a +player outright: there is no view for this that a player is meant to see +at all (no PNG exists for this ticket; Brief section 8: build the +smallest honest surface, not an invented one). +""" + from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import or_ from sqlalchemy.orm import Session -from app.deps import CurrentMembership, get_current_membership, get_current_user, get_db +from app.deps import ( + CurrentMembership, + get_current_membership, + get_current_user, + get_db, + require_head_coach, + require_role_on_team, +) from app.models import Team, TeamMember, User -from app.schemas import TeamCreateRequest, TeamJoinRequest, TeamOut +from app.schemas import ( + CoachTeamOut, + TeamCreateRequest, + TeamJoinRequest, + TeamMemberOut, + TeamMemberRoleUpdateRequest, + TeamOut, +) +from app.scoped import TeamScope, get_team_scope from app.security import generate_join_code router = APIRouter(prefix="/api/teams", tags=["teams"]) @@ -11,10 +51,19 @@ _JOIN_CODE_ATTEMPTS = 20 -def _unique_join_code(db: Session) -> str: +def _unique_code(db: Session) -> str: + """A candidate must be free in BOTH `join_code` and `coach_join_code` + across every team: this is what guarantees a submitted code resolves + unambiguously to exactly one (team, role) pair in join_team below, + doc 03 section 2's single-namespace uniqueness extended to two + namespaces that must never overlap.""" for _ in range(_JOIN_CODE_ATTEMPTS): candidate = generate_join_code() - taken = db.query(Team.id).filter(Team.join_code == candidate).first() + taken = ( + db.query(Team.id) + .filter(or_(Team.join_code == candidate, Team.coach_join_code == candidate)) + .first() + ) if taken is None: return candidate # Astronomically unlikely at pilot scale (6 chars, 32-symbol alphabet). @@ -24,12 +73,25 @@ def _unique_join_code(db: Session) -> str: ) -@router.post("", response_model=TeamOut, status_code=status.HTTP_201_CREATED) +def _team_out(team: Team, role_on_team: str) -> dict: + """The one place that decides TeamOut vs CoachTeamOut. response_model + is None on every route that calls this (same reasoning as + app/routers/roster.py get_roster): a player-shaped dict must have no + join_code/coach_join_code KEY at all, not a null one, and a shared + response_model would either drop CoachTeamOut's extra fields (if + typed TeamOut) or backfill them as null onto a player payload (if + typed CoachTeamOut) depending on which way the coercion ran.""" + if role_on_team == "coach": + return CoachTeamOut.model_validate(team).model_dump(mode="json") + return TeamOut.model_validate(team).model_dump(mode="json") + + +@router.post("", response_model=None, status_code=status.HTTP_201_CREATED) def create_team( payload: TeamCreateRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), -) -> Team: +) -> dict: # Team creation is a coach action. This checks the account's global # role because no team_members row exists yet to carry role_on_team; # every route created afterward scopes off get_current_membership @@ -39,13 +101,24 @@ def create_team( status_code=status.HTTP_403_FORBIDDEN, detail="Only coaches can create a team" ) + # Both codes are picked before the team row exists at all, so no + # not-null column is ever briefly unset across a flush: `_unique_code` + # only sees committed/flushed rows, so the two calls could in + # principle agree (astronomically unlikely, 6 chars over a 32-symbol + # alphabet) without the explicit != check below. + join_code = _unique_code(db) + coach_join_code = _unique_code(db) + while coach_join_code == join_code: + coach_join_code = _unique_code(db) + team = Team( name=payload.name, age_group=payload.age_group, level=payload.level, colors_json=payload.colors_json, created_by=current_user.id, - join_code=_unique_join_code(db), + join_code=join_code, + coach_join_code=coach_join_code, ) db.add(team) db.flush() # assigns team.id for the membership row below @@ -53,17 +126,24 @@ def create_team( db.add(TeamMember(team_id=team.id, user_id=current_user.id, role_on_team="coach")) db.commit() db.refresh(team) - return team + # The creator is the head coach, always a coach member: always the + # coach-shaped payload, never built from ctx (none exists yet here). + return _team_out(team, "coach") -@router.post("/join", response_model=TeamOut) +@router.post("/join", response_model=None) def join_team( payload: TeamJoinRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), -) -> Team: +) -> dict: code = payload.join_code.strip().upper() + + role_on_team = "player" team = db.query(Team).filter(Team.join_code == code).first() + if team is None: + team = db.query(Team).filter(Team.coach_join_code == code).first() + role_on_team = "coach" if team is None: # Wrong code fails cleanly: 404, no hint about which part is wrong, # no stack trace, no partial state written. @@ -79,16 +159,91 @@ def join_team( status_code=status.HTTP_409_CONFLICT, detail="Already a member of this team" ) - db.add( - TeamMember(team_id=team.id, user_id=current_user.id, role_on_team=current_user.role) - ) + # role_on_team comes ENTIRELY from which code column matched above + # (T-043 decision 1): current_user.role (the account's own global + # role) never decides anything here, unlike the pre-T-043 behavior. + db.add(TeamMember(team_id=team.id, user_id=current_user.id, role_on_team=role_on_team)) db.commit() db.refresh(team) - return team + return _team_out(team, role_on_team) -@router.get("/current", response_model=TeamOut) -def current_team(ctx: CurrentMembership = Depends(get_current_membership)) -> Team: +@router.get("/current", response_model=None) +def current_team(ctx: CurrentMembership = Depends(get_current_membership)) -> dict: # team_id comes from the caller's own membership row, never from a # client-supplied parameter (CLAUDE.md rule 4). - return ctx.team + return _team_out(ctx.team, ctx.role_on_team) + + +# --------------------------------------------------------------------------- +# Head-coach member management (T-043 decision 3). The list itself is any +# coach's to view; the mutations are the creator's alone (require_head_coach). +# --------------------------------------------------------------------------- + + +def _member_to_out(member: TeamMember, user: User, created_by: int) -> TeamMemberOut: + return TeamMemberOut( + id=member.id, + user_id=member.user_id, + display_name=user.display_name, + role_on_team=member.role_on_team, # type: ignore[arg-type] + is_head_coach=member.user_id == created_by, + joined_at=member.joined_at, + ) + + +@router.get("/members", response_model=list[TeamMemberOut]) +def list_members( + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> list[TeamMemberOut]: + members = scope.query(TeamMember).order_by(TeamMember.joined_at.asc()).all() + users = { + u.id: u + for u in db.query(User).filter(User.id.in_([m.user_id for m in members])).all() + } + return [_member_to_out(m, users[m.user_id], ctx.team.created_by) for m in members] + + +@router.delete("/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_member( + member_id: int, + ctx: CurrentMembership = Depends(require_head_coach), + scope: TeamScope = Depends(get_team_scope), +) -> None: + member = scope.get(TeamMember, member_id) + if member is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + if member.user_id == ctx.user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot remove yourself from the team", + ) + scope.delete(member) + scope.commit() + + +@router.patch("/members/{member_id}/role", response_model=TeamMemberOut) +def update_member_role( + member_id: int, + payload: TeamMemberRoleUpdateRequest, + ctx: CurrentMembership = Depends(require_head_coach), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> TeamMemberOut: + member = scope.get(TeamMember, member_id) + if member is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + if member.user_id == ctx.user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="You cannot change your own role" + ) + + member.role_on_team = payload.role_on_team + scope.commit() + scope.refresh(member) + + user = db.get(User, member.user_id) + assert user is not None # FK guarantees this + return _member_to_out(member, user, ctx.team.created_by) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index db0ad32..da01c2f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -50,10 +50,21 @@ class TeamCreateRequest(BaseModel): class TeamJoinRequest(BaseModel): + """A joiner submits ONE code; which role they get on the team is + resolved entirely from which of the team's two join-code columns the + code matches (T-043 founder decision 2026-07-16), never from the + joiner's own account role. See app/routers/teams.py join_team.""" + join_code: str = Field(min_length=1, max_length=12) class TeamOut(BaseModel): + """Player-safe team shape: no join-code field at all (T-043 decision + 2: both codes are coach-only, and must be ABSENT from a player + payload, not null). See CoachTeamOut below for the coach-only + superset, same RosterOut/CoachRosterOut split app/routers/roster.py + already uses for fit_warnings (CLAUDE.md rule 5).""" + model_config = ConfigDict(from_attributes=True) id: int @@ -61,11 +72,22 @@ class TeamOut(BaseModel): age_group: str | None level: str | None colors_json: dict | None - join_code: str created_by: int created_at: datetime +class CoachTeamOut(TeamOut): + """Adds both join codes, only ever built for a coach caller + (app/routers/teams.py): `join_code` is the player code (the original + T-003 column, repurposed in place, see app/models/platform.py Team), + `coach_join_code` is the new one. Never validated/dumped through the + plain TeamOut type, so these two extra keys are never silently + dropped by a shared response_model (see the module docstring header).""" + + join_code: str + coach_join_code: str + + class MembershipOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -74,9 +96,57 @@ class MembershipOut(BaseModel): joined_at: datetime +class CoachMembershipOut(MembershipOut): + """Same coach-only substitution as CoachTeamOut, one level up: used + for a membership whose role_on_team is 'coach' so GET /api/auth/me's + memberships list carries both join codes for a coach's own team(s) + and neither key at all for a player's.""" + + team: CoachTeamOut + + +class TeamMemberOut(BaseModel): + """GET /api/teams/members row (T-043 decision 3: head-coach member + management). Coach-only end to end (the route itself 403s a player, + require_role_on_team("coach")), so no player-vs-coach split is needed + on this model the way TeamOut/CoachTeamOut needs one. + + `is_head_coach` is derived server-side from `Team.created_by`, never + a client-supplied field, so the frontend can gate its remove/role + controls on it without re-deriving the creator check itself (it must + still be enforced again on the mutation routes; this field only + drives which controls the UI renders, per CLAUDE.md rule 5's + UI-is-not-enough principle).""" + + id: int + user_id: int + display_name: str + role_on_team: RoleOnTeam + is_head_coach: bool + joined_at: datetime + + +class TeamMemberRoleUpdateRequest(BaseModel): + """PATCH /api/teams/members/{id}/role body. Head-coach-only + (app/deps.py require_head_coach); changing a member's own role is + rejected regardless of this payload's contents (T-043 decision 3: + "not their own").""" + + model_config = ConfigDict(extra="forbid") + + role_on_team: RoleOnTeam + + class MeOut(BaseModel): """GET /api/auth/me always returns 200: user is null when signed out. - See app/deps.py get_current_user_optional for why this is not a 401.""" + See app/deps.py get_current_user_optional for why this is not a 401. + + Documents the wire shape only: the route itself is response_model=None + and returns an already-serialized dict (same pattern as + app/routers/roster.py get_roster), because `memberships` mixes + MembershipOut and CoachMembershipOut entries per-row depending on each + membership's own role_on_team, which a single shared response_model + cannot express without silently coercing one shape into the other.""" user: UserOut | None memberships: list[MembershipOut] diff --git a/backend/migrations/versions/0004_role_scoped_join_codes.py b/backend/migrations/versions/0004_role_scoped_join_codes.py new file mode 100644 index 0000000..7ed92ab --- /dev/null +++ b/backend/migrations/versions/0004_role_scoped_join_codes.py @@ -0,0 +1,85 @@ +"""role-scoped join codes (T-043, founder decision 2026-07-16): a team now +has TWO join codes, a player code and a coach code, and joining with a +code assigns THAT role on the team regardless of the joiner's own account +role. + +`teams.join_code` (the T-003 column, doc 03 section 2) is REPURPOSED in +place, not renamed or replaced: every existing team's code and every row +referencing it survive this migration untouched. It is now specifically +the PLAYER code. `coach_join_code` is the new column added here, added +nullable first, backfilled with a freshly generated code per existing +team (so `make migrate` on a database that already has teams in it never +leaves a team without a coach code), then made NOT NULL + unique to match +`join_code`'s own constraint shape. + +Generation reuses the exact alphabet/length app/security.py's +generate_join_code() uses (not an import of that function itself: Alembic +migrations run against whatever the schema looked like the day they were +written, and should not call forward into application code that could +change shape later), and checks uniqueness against BOTH columns so a +generated coach code can never collide with any existing player code or +vice versa: doc 03 section 2's single-namespace uniqueness becomes a +two-column uniqueness check, which is what app/routers/teams.py's own +_unique_code helper also enforces going forward for newly created teams. + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-07-16 00:00:00.000000 + +""" + +import secrets +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0004" +down_revision: Union[str, None] = "0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Mirrors app/security.py's _JOIN_CODE_ALPHABET / _JOIN_CODE_LENGTH exactly +# (excludes 0/O and 1/I so a coach can read a code aloud unambiguously). +_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +_LENGTH = 6 +_ATTEMPTS = 50 + + +def _generate_unique_code(taken: set[str]) -> str: + for _ in range(_ATTEMPTS): + candidate = "".join(secrets.choice(_ALPHABET) for _ in range(_LENGTH)) + if candidate not in taken: + return candidate + raise RuntimeError("Could not allocate a unique coach join code during migration 0004") + + +def upgrade() -> None: + with op.batch_alter_table("teams") as batch_op: + batch_op.add_column(sa.Column("coach_join_code", sa.String(length=12), nullable=True)) + + conn = op.get_bind() + rows = conn.execute(sa.text("SELECT id, join_code FROM teams")).fetchall() + + # Seed the "taken" set with every existing player code so a backfilled + # coach code can never collide with one, then grow it as each coach + # code is generated so two existing teams' backfills can never + # collide with each other either. + taken = {row.join_code for row in rows} + for row in rows: + code = _generate_unique_code(taken) + taken.add(code) + conn.execute( + sa.text("UPDATE teams SET coach_join_code = :code WHERE id = :id"), + {"code": code, "id": row.id}, + ) + + with op.batch_alter_table("teams") as batch_op: + batch_op.alter_column("coach_join_code", existing_type=sa.String(length=12), nullable=False) + op.create_index("ix_teams_coach_join_code", "teams", ["coach_join_code"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_teams_coach_join_code", table_name="teams") + with op.batch_alter_table("teams") as batch_op: + batch_op.drop_column("coach_join_code") diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py index e992295..7100f9d 100644 --- a/backend/tests/test_permissions.py +++ b/backend/tests/test_permissions.py @@ -396,34 +396,201 @@ def test_players_are_additive_only__every_player_write_route_is_a_create( # --------------------------------------------------------------------------- -# Known ambiguity (T-040 instruction: do not change current behavior, pin -# and report it): join codes. The design README/Brief section 3 table does -# not list join codes as coach-only, but the UI (TeamMeta.tsx) only shows -# the join-code block to a coach. The API was never told to withhold it: -# TeamOut.join_code is returned to any team member by both GET -# /api/teams/current and GET /api/auth/me. docs/agent/STATE.md's open -# founder questions list already flags this ("Confirm before T-040 locks -# the pattern"). This test PINS the current, unchanged behavior so a -# future change is a deliberate, visible diff here, not an accidental one. +# Row (T-043 decision 2, resolves the ambiguity T-040 pinned above): join +# codes are coach-only in the API, not just the UI. Both +# GET /api/teams/current and GET /api/auth/me carry BOTH join_code (player +# code) and coach_join_code for a coach caller, and NEITHER key at all +# (absent, not null) for a player caller. This replaces +# test_join_code_is_returned_to_a_player_by_the_api_ambiguity_pinned_not_enforced, +# which pinned the OLD (unenforced) behavior; T-043 is the deliberate, +# visible diff that test's own docstring said a future change would be. # --------------------------------------------------------------------------- -def test_join_code_is_returned_to_a_player_by_the_api_ambiguity_pinned_not_enforced( - client: TestClient, -) -> None: +def test_join_codes__coach_sees_both_codes_player_sees_neither_key(client: TestClient) -> None: coach = _coach_with_team() player = _player_on_team(coach, email="player@example.com") - join_code = coach.get("/api/teams/current").json()["join_code"] - assert len(join_code) == 6 + coach_team = coach.get("/api/teams/current").json() + assert len(coach_team["join_code"]) == 6 + assert len(coach_team["coach_join_code"]) == 6 + assert coach_team["join_code"] != coach_team["coach_join_code"] - # Current (unchanged) behavior: a player's own /api/teams/current - # response also carries join_code. The UI is the only layer that - # withholds it from a player today (TeamMeta.tsx renders the block - # only when role_on_team == "coach"). See docs/agent/STATE.md open - # founder question 2 and this ticket's final report. + coach_me = coach.get("/api/auth/me").json() + coach_me_team = coach_me["memberships"][0]["team"] + assert coach_me_team["join_code"] == coach_team["join_code"] + assert coach_me_team["coach_join_code"] == coach_team["coach_join_code"] + + # Player: both keys entirely absent, not null, from both endpoints + # (CLAUDE.md rule 5, same "absent not null" contract test_roster_routes.py + # / test_roster.py's fit_warnings proof already establishes). player_team = player.get("/api/teams/current").json() - assert player_team["join_code"] == join_code + assert "join_code" not in player_team + assert "coach_join_code" not in player_team player_me = player.get("/api/auth/me").json() - assert player_me["memberships"][0]["team"]["join_code"] == join_code + player_me_team = player_me["memberships"][0]["team"] + assert "join_code" not in player_me_team + assert "coach_join_code" not in player_me_team + + +# --------------------------------------------------------------------------- +# Row (T-043 decision 1): role-scoped join codes. Joining with the player +# code always assigns role_on_team "player", and joining with the coach +# code always assigns role_on_team "coach", regardless of the joining +# account's OWN global `role` (a coach-role account can join as a player, +# and a player-role account can join as a coach, whichever code they use). +# --------------------------------------------------------------------------- + + +def test_join_codes_are_role_scoped__account_role_never_decides(client: TestClient) -> None: + coach = _coach_with_team() + player_code = coach.get("/api/teams/current").json()["join_code"] + coach_code = coach.get("/api/teams/current").json()["coach_join_code"] + + # A COACH-role account joining with the PLAYER code becomes a player + # on this team. + coach_account = TestClient(app) + _register(coach_account, email="second-coach@example.com", role="coach", display_name="Second Coach") + joined_as_player = coach_account.post("/api/teams/join", json={"join_code": player_code}) + assert joined_as_player.status_code == 200 + assert "join_code" not in joined_as_player.json() # player-shaped response + me = coach_account.get("/api/auth/me").json() + assert me["memberships"][0]["role_on_team"] == "player" + + # A PLAYER-role account joining with the COACH code becomes a coach on + # this team. + player_account = client + _register(player_account, email="became-coach@example.com", role="player", display_name="Became Coach") + joined_as_coach = player_account.post("/api/teams/join", json={"join_code": coach_code}) + assert joined_as_coach.status_code == 200 + body = joined_as_coach.json() + assert body["join_code"] == player_code # coach-shaped response, both codes present + assert body["coach_join_code"] == coach_code + me2 = player_account.get("/api/auth/me").json() + assert me2["memberships"][0]["role_on_team"] == "coach" + + +# --------------------------------------------------------------------------- +# Row (T-043 decision 3): head-coach member management. The head coach is +# the team's CREATOR (Team.created_by), not just any coach. Remove and +# role-change are 403 for a non-head coach AND a player; the member list +# itself is any coach's to view (403 for a player only). +# --------------------------------------------------------------------------- + + +def test_team_member_list__coach_yes_player_403(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com", name="Sam Player") + + listed = coach.get("/api/teams/members") + assert listed.status_code == 200 + names = {row["display_name"] for row in listed.json()} + assert names == {"Coach Test", "Sam Player"} + head = next(row for row in listed.json() if row["display_name"] == "Coach Test") + assert head["is_head_coach"] is True + non_head = next(row for row in listed.json() if row["display_name"] == "Sam Player") + assert non_head["is_head_coach"] is False + + assert player.get("/api/teams/members").status_code == 403 + + +def test_head_coach_can_remove_a_member__non_head_coach_and_player_403( + client: TestClient, +) -> None: + head_coach = _coach_with_team() + coach_code = head_coach.get("/api/teams/current").json()["coach_join_code"] + + # A second coach joins the same team via the coach code (not the + # creator, so not the head coach). + other_coach = TestClient(app) + _register(other_coach, email="other-coach@example.com", role="coach", display_name="Other Coach") + other_coach.post("/api/teams/join", json={"join_code": coach_code}) + + player = _player_on_team(head_coach, email="player@example.com", name="Sam Player") + player_member_id = next( + row["id"] + for row in head_coach.get("/api/teams/members").json() + if row["display_name"] == "Sam Player" + ) + + # A non-head coach cannot remove anyone, even a player. + assert other_coach.delete(f"/api/teams/members/{player_member_id}").status_code == 403 + # A player cannot remove anyone either. + assert player.delete(f"/api/teams/members/{player_member_id}").status_code == 403 + # The row survives both rejected attempts. + assert any( + row["display_name"] == "Sam Player" for row in head_coach.get("/api/teams/members").json() + ) + + # The head coach can remove the player. + removed = head_coach.delete(f"/api/teams/members/{player_member_id}") + assert removed.status_code == 204 + assert not any( + row["display_name"] == "Sam Player" for row in head_coach.get("/api/teams/members").json() + ) + # The removed player no longer has a membership (they would need to + # rejoin with a code to come back). + assert player.get("/api/auth/me").json()["memberships"] == [] + + # The head coach cannot remove themself. + head_member_id = next( + row["id"] + for row in head_coach.get("/api/teams/members").json() + if row["display_name"] == "Coach Test" + ) + assert head_coach.delete(f"/api/teams/members/{head_member_id}").status_code == 400 + + +def test_head_coach_can_change_a_members_role__non_head_coach_and_player_403( + client: TestClient, +) -> None: + head_coach = _coach_with_team() + coach_code = head_coach.get("/api/teams/current").json()["coach_join_code"] + + other_coach = TestClient(app) + _register(other_coach, email="other-coach@example.com", role="coach", display_name="Other Coach") + other_coach.post("/api/teams/join", json={"join_code": coach_code}) + + player = _player_on_team(head_coach, email="player@example.com", name="Sam Player") + player_member_id = next( + row["id"] + for row in head_coach.get("/api/teams/members").json() + if row["display_name"] == "Sam Player" + ) + + # Non-head coach and player attempts are both rejected; role unchanged. + assert ( + other_coach.patch( + f"/api/teams/members/{player_member_id}/role", json={"role_on_team": "coach"} + ).status_code + == 403 + ) + assert ( + player.patch( + f"/api/teams/members/{player_member_id}/role", json={"role_on_team": "coach"} + ).status_code + == 403 + ) + assert player.get("/api/auth/me").json()["memberships"][0]["role_on_team"] == "player" + + # The head coach promotes the player to coach. + promoted = head_coach.patch( + f"/api/teams/members/{player_member_id}/role", json={"role_on_team": "coach"} + ) + assert promoted.status_code == 200 + assert promoted.json()["role_on_team"] == "coach" + assert player.get("/api/auth/me").json()["memberships"][0]["role_on_team"] == "coach" + # The promoted member now sees both join codes, coach-shaped. + assert "coach_join_code" in player.get("/api/teams/current").json() + + # The head coach cannot change their own role. + head_member_id = next( + row["id"] + for row in head_coach.get("/api/teams/members").json() + if row["display_name"] == "Coach Test" + ) + own_role_change = head_coach.patch( + f"/api/teams/members/{head_member_id}/role", json={"role_on_team": "player"} + ) + assert own_role_change.status_code == 400 diff --git a/backend/tests/test_scoped_query_layer.py b/backend/tests/test_scoped_query_layer.py index 48894e5..48d8bd7 100644 --- a/backend/tests/test_scoped_query_layer.py +++ b/backend/tests/test_scoped_query_layer.py @@ -47,8 +47,16 @@ def _make_team_with_coach(db: Session, *, name: str, email: str) -> tuple[Team, db.add(user) db.flush() # Join codes must be unique across teams; derive one from the user id - # (assigned on flush above) so team A and team B never collide. - team = Team(name=name, join_code=f"CODE{user.id:02d}", created_by=user.id) + # (assigned on flush above) so team A and team B never collide. Two + # codes now (T-043): player (join_code) and coach (coach_join_code), + # both required columns, distinct namespaces that must never overlap + # either (app/routers/teams.py _unique_code). + team = Team( + name=name, + join_code=f"CODE{user.id:02d}", + coach_join_code=f"COAC{user.id:02d}", + created_by=user.id, + ) db.add(team) db.flush() db.add(TeamMember(team_id=team.id, user_id=user.id, role_on_team="coach")) diff --git a/backend/tests/test_seed_content.py b/backend/tests/test_seed_content.py index a792d5d..55c48e5 100644 --- a/backend/tests/test_seed_content.py +++ b/backend/tests/test_seed_content.py @@ -253,6 +253,7 @@ def table_counts(session) -> dict[str, int]: age_group="U12", level="rec", join_code="SENT01", + coach_join_code="SENT02", created_by=sentinel_user.id, ) session.add(sentinel_team) diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index f3d8773..bc8a254 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -21,7 +21,7 @@ Model: sonnet default; opus = hard ticket, never downgrade. | T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | done | | T-034 | Identity page (PNG 13, 33, 40-42, 44, 45): 4 scripted animations, 2 static shapes, detail slots, pass-risk, cult corner | 20 | screens | sonnet | T-031 | T-033 | done | | T-040 | Role gating UI + API 403 enforcement, permission test suite both roles (Brief ยง3 table, every row) | 21 | collab | sonnet | T-030..T-034 | T-041 | doing | -| T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | doing | +| T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | done | | T-042 | Sessions: draft builder + picker w/ thumbnails, send, receipts, player view w/ Watch deep-link + Mark as watched (PNG 21-23, 26, 28) | 23 | collab | sonnet | T-031, T-040 | none | todo | | T-043 | Founder decision 2026-07-16: role-scoped join codes (player + coach code, migration), join codes coach-only in API payloads, head coach (creator) removes members + edits member roles, permission tests | founder | platform | sonnet | T-003, T-040 | T-041 | doing | | T-050 | Phone pass: icon rail, stacked grids, portrait boards all surfaces, cross-device save/replay test | 24 | screens | sonnet | T-030..T-042 | none | todo | diff --git a/e2e/auth-teams.spec.ts b/e2e/auth-teams.spec.ts index 1f8fed9..e3975e5 100644 --- a/e2e/auth-teams.spec.ts +++ b/e2e/auth-teams.spec.ts @@ -46,9 +46,11 @@ test("coach creates a team and sees a join code; player joins with it", async ({ await page.getByLabel("Team name").fill(teamName); await page.getByRole("button", { name: "Create team" }).click(); - // --- Coach sees the join code --- + // --- Coach sees the join code (T-043: the player code specifically; + // the coach also sees a separate coach code, asserted in + // e2e/team-management.spec.ts) --- await expect(page.getByRole("heading", { name: teamName })).toBeVisible(); - const joinCodeLocator = page.locator(".join-code strong"); + const joinCodeLocator = page.getByTestId("join-code-player"); await expect(joinCodeLocator).toBeVisible(); const joinCode = (await joinCodeLocator.textContent())?.trim() ?? ""; expect(joinCode).toHaveLength(6); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 6fc62fe..c20c676 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -41,12 +41,15 @@ export function uniqueEmail(prefix: string): string { } /** Registers a fresh coach, creates a team, and waits for the whiteboard - * board to render. Returns the team's join code (coach-only, per the - * README roles table) for tests that also need a player on the same team. */ + * board to render. Returns both of the team's join codes (coach-only, per + * the README roles table): `joinCode` is the PLAYER code (kept as the + * name every pre-T-043 caller of this helper already uses, so every spec + * file that destructures `{ joinCode }` for a player join keeps working + * unchanged), `coachJoinCode` is the new one (T-043 decision 1). */ export async function registerCoach( page: Page, opts: { displayName?: string; teamName?: string } = {} -): Promise<{ email: string; joinCode: string }> { +): Promise<{ email: string; joinCode: string; coachJoinCode: string }> { const email = uniqueEmail("coach"); await page.goto("/"); await page.getByLabel("Name").fill(opts.displayName ?? "Coach Test"); @@ -57,25 +60,49 @@ export async function registerCoach( await page.getByLabel("Team name").fill(opts.teamName ?? `Team ${Date.now()}`); await page.getByRole("button", { name: "Create team" }).click(); await expect(page.getByTestId("board")).toBeVisible(); - const joinCode = (await page.locator(".join-code strong").textContent())?.trim() ?? ""; - return { email, joinCode }; + const joinCode = (await page.getByTestId("join-code-player").textContent())?.trim() ?? ""; + const coachJoinCode = (await page.getByTestId("join-code-coach").textContent())?.trim() ?? ""; + return { email, joinCode, coachJoinCode }; } -/** Registers a fresh player and joins the given team's join code, waiting - * for the whiteboard board to render. */ +/** Registers a fresh player and joins with the given (player) join code, + * waiting for the whiteboard board to render. Joining with a player code + * always assigns role_on_team "player" (T-043 decision 1), regardless of + * the account's own role, which is what this helper's callers rely on. */ export async function registerPlayer( page: Page, joinCode: string, opts: { displayName?: string } = {} ): Promise<{ email: string }> { - const email = uniqueEmail("player"); + const { email } = await registerAndJoinTeam(page, joinCode, { + role: "player", + displayName: opts.displayName ?? "Player Test", + }); + return { email }; +} + +/** Registers a fresh account of the given account role and joins an + * EXISTING team with the given code. The role assigned ON THE TEAM comes + * entirely from which of the team's two codes is passed here (T-043 + * decision 1), not from `opts.role` (the new account's own global role, + * which only decides whether this account could ALSO see a "Create your + * team" form, never which team role a join grants). Used by + * e2e/team-management.spec.ts to prove both directions: a coach-role + * account joining with the player code, and a player-role account joining + * with the coach code. */ +export async function registerAndJoinTeam( + page: Page, + code: string, + opts: { role: "coach" | "player"; displayName?: string } = { role: "player" } +): Promise<{ email: string }> { + const email = uniqueEmail(opts.role); await page.goto("/"); - await page.getByLabel("Name").fill(opts.displayName ?? "Player Test"); + await page.getByLabel("Name").fill(opts.displayName ?? (opts.role === "coach" ? "Coach Test" : "Player Test")); await page.getByLabel("Email").fill(email); await page.getByLabel("Password").fill(PASSWORD); - await page.getByRole("radio", { name: "Player" }).check(); + await page.getByRole("radio", { name: opts.role === "coach" ? "Coach" : "Player" }).check(); await page.getByRole("button", { name: "Create account" }).click(); - await page.getByLabel("Join code").fill(joinCode); + await page.getByLabel("Join code").fill(code); await page.getByRole("button", { name: "Join team" }).click(); await expect(page.getByTestId("board")).toBeVisible(); return { email }; diff --git a/e2e/permissions.spec.ts b/e2e/permissions.spec.ts index c1033a0..9bbec52 100644 --- a/e2e/permissions.spec.ts +++ b/e2e/permissions.spec.ts @@ -77,6 +77,12 @@ async function dragTokenTo(page: Page, id: string, m: { x: number; y: number }) * them leak onto a page they were not designed for either. */ async function assertNoCoachOnlyChrome(page: Page) { await expect(page.locator(".join-code")).toHaveCount(0); + // T-043 decision 2/3: both join codes and the head-coach member + // management toggle are coach-only ambient chrome, same treatment as + // the join-code block above. + await expect(page.getByTestId("join-code-player")).toHaveCount(0); + await expect(page.getByTestId("join-code-coach")).toHaveCount(0); + await expect(page.getByTestId("team-members-toggle")).toHaveCount(0); await expect(page.locator(".fit-warning")).toHaveCount(0); await expect(page.getByTestId("roster-add-player")).toHaveCount(0); await expect(page.getByTestId("player-edit")).toHaveCount(0); diff --git a/e2e/team-management.spec.ts b/e2e/team-management.spec.ts new file mode 100644 index 0000000..f2de89c --- /dev/null +++ b/e2e/team-management.spec.ts @@ -0,0 +1,190 @@ +// Team management journey (T-043, founder decision 2026-07-16). Runs +// under both Playwright projects (mobile portrait, desktop landscape) per +// playwright.config.ts. Three founder decisions, one file: +// 1. Role-scoped join codes: a team has a player code and a coach code; +// whichever code you join with decides your role_on_team, never your +// account's own global role. +// 2. Both codes are coach-only chrome: present for any coach, absent +// (not hidden) for a player, same DOM-absence contract every other +// coach-only surface in this app already follows (see +// e2e/permissions.spec.ts assertNoCoachOnlyChrome). +// 3. Head-coach member management: the team's creator alone can remove +// a member or change their role_on_team; any coach can see the list; +// a player never sees it at all. +// +// backend/tests/test_permissions.py covers the API half of all three +// rows; this file is the UI half plus one direct-API 403 check per the +// ticket's own acceptance line ("the API 403s them anyway"). + +import { + test, + expect, + assertCleanPage, + registerCoach, + registerAndJoinTeam, +} from "./fixtures"; +import type { Page } from "@playwright/test"; + +function trackIssues(page: Page) { + const issues = { consoleErrors: [] as string[], failedRequests: [] as string[], serverErrors: [] as string[] }; + page.on("console", (m) => m.type() === "error" && issues.consoleErrors.push(m.text())); + page.on("requestfailed", (r) => issues.failedRequests.push(`${r.method()} ${r.url()}`)); + page.on("response", (r) => r.status() >= 500 && issues.serverErrors.push(`${r.status()} ${r.url()}`)); + return issues; +} + +async function openTeamMembers(page: Page) { + await page.getByTestId("team-members-toggle").click(); + await expect(page.getByTestId("team-members-panel")).toBeVisible(); +} + +test.describe("team management: role-scoped join codes and head-coach controls", () => { + test("coach sees both join codes; player sees neither key", async ({ browser }) => { + const coachContext = await browser.newContext(); + const coachPage = await coachContext.newPage(); + const coachIssues = trackIssues(coachPage); + const { joinCode, coachJoinCode } = await registerCoach(coachPage, { + displayName: "Coach Meta Test", + }); + await expect(coachPage.getByTestId("join-code-player")).toHaveText(joinCode); + await expect(coachPage.getByTestId("join-code-coach")).toHaveText(coachJoinCode); + expect(joinCode).not.toBe(coachJoinCode); + await assertCleanPage(coachPage, coachIssues); + + const playerContext = await browser.newContext(); + const playerPage = await playerContext.newPage(); + const playerIssues = trackIssues(playerPage); + await registerAndJoinTeam(playerPage, joinCode, { + role: "player", + displayName: "Player Meta Test", + }); + await expect(playerPage.getByText("(player)")).toBeVisible(); + // Absent, not hidden: same DOM-absence contract as every other + // coach-only surface (e2e/permissions.spec.ts). + await expect(playerPage.getByTestId("join-code-player")).toHaveCount(0); + await expect(playerPage.getByTestId("join-code-coach")).toHaveCount(0); + await expect(playerPage.getByTestId("team-members-toggle")).toHaveCount(0); + await assertCleanPage(playerPage, playerIssues); + + await coachContext.close(); + await playerContext.close(); + }); + + test("a code's role wins regardless of the joining account's own role", async ({ browser }) => { + const headContext = await browser.newContext(); + const headPage = await headContext.newPage(); + const headIssues = trackIssues(headPage); + const { joinCode, coachJoinCode } = await registerCoach(headPage, { + displayName: "Head Coach Cross Test", + }); + + // A COACH-role account joins with the PLAYER code: it lands as a + // player on this team, sees no join codes at all. + const coachAsPlayerContext = await browser.newContext(); + const coachAsPlayerPage = await coachAsPlayerContext.newPage(); + const coachAsPlayerIssues = trackIssues(coachAsPlayerPage); + await registerAndJoinTeam(coachAsPlayerPage, joinCode, { + role: "coach", + displayName: "Coach Account Joins As Player", + }); + await expect(coachAsPlayerPage.getByText("(player)")).toBeVisible(); + await expect(coachAsPlayerPage.getByTestId("join-code-player")).toHaveCount(0); + await expect(coachAsPlayerPage.getByTestId("join-code-coach")).toHaveCount(0); + await assertCleanPage(coachAsPlayerPage, coachAsPlayerIssues); + + // A PLAYER-role account joins with the COACH code: it lands as a + // coach on this team, sees both join codes. + const playerAsCoachContext = await browser.newContext(); + const playerAsCoachPage = await playerAsCoachContext.newPage(); + const playerAsCoachIssues = trackIssues(playerAsCoachPage); + await registerAndJoinTeam(playerAsCoachPage, coachJoinCode, { + role: "player", + displayName: "Player Account Joins As Coach", + }); + await expect(playerAsCoachPage.getByText("(coach)")).toBeVisible(); + await expect(playerAsCoachPage.getByTestId("join-code-player")).toHaveText(joinCode); + await expect(playerAsCoachPage.getByTestId("join-code-coach")).toHaveText(coachJoinCode); + await assertCleanPage(playerAsCoachPage, playerAsCoachIssues); + + await assertCleanPage(headPage, headIssues); + + await headContext.close(); + await coachAsPlayerContext.close(); + await playerAsCoachContext.close(); + }); + + test("head coach removes a member and changes a role; a non-head coach sees the list with no controls, and the API 403s them anyway", async ({ + browser, + }) => { + const headContext = await browser.newContext(); + const headPage = await headContext.newPage(); + const headIssues = trackIssues(headPage); + const { joinCode, coachJoinCode } = await registerCoach(headPage, { + displayName: "Head Coach", + }); + + const otherCoachContext = await browser.newContext(); + const otherCoachPage = await otherCoachContext.newPage(); + await registerAndJoinTeam(otherCoachPage, coachJoinCode, { + role: "coach", + displayName: "Other Coach", + }); + + const playerContext = await browser.newContext(); + const playerPage = await playerContext.newPage(); + const playerIssues = trackIssues(playerPage); + await registerAndJoinTeam(playerPage, joinCode, { role: "player", displayName: "Sam Player" }); + + // --- Head coach: full list, controls on every row but their own --- + await openTeamMembers(headPage); + const panel = headPage.getByTestId("team-members-panel"); + await expect(panel).toContainText("Head Coach"); + await expect(panel).toContainText("Other Coach"); + await expect(panel).toContainText("Sam Player"); + + const headRow = headPage.locator('[data-testid^="team-member-row-"]').filter({ hasText: "Head Coach" }); + await expect(headRow.locator('[data-testid^="team-member-remove-"]')).toHaveCount(0); + const playerRow = headPage.locator('[data-testid^="team-member-row-"]').filter({ hasText: "Sam Player" }); + await expect(playerRow.locator('[data-testid^="team-member-remove-"]')).toHaveCount(1); + await expect(playerRow.locator('[data-testid^="team-member-toggle-role-"]')).toHaveCount(1); + + // --- Non-head coach: sees the same list, zero controls anywhere --- + await openTeamMembers(otherCoachPage); + const otherPanel = otherCoachPage.getByTestId("team-members-panel"); + await expect(otherPanel).toContainText("Sam Player"); + await expect(otherCoachPage.locator('[data-testid^="team-member-remove-"]')).toHaveCount(0); + await expect(otherCoachPage.locator('[data-testid^="team-member-toggle-role-"]')).toHaveCount(0); + + // --- The API 403s the non-head coach anyway, not just the UI hiding + // the buttons (CLAUDE.md rule 5) --- + const listedByOtherCoach = await otherCoachPage.request.get("/api/teams/members"); + expect(listedByOtherCoach.ok()).toBe(true); + const someMemberId = (await listedByOtherCoach.json())[0].id as number; + const forbiddenRemove = await otherCoachPage.request.delete(`/api/teams/members/${someMemberId}`); + expect(forbiddenRemove.status()).toBe(403); + const forbiddenRoleChange = await otherCoachPage.request.patch( + `/api/teams/members/${someMemberId}/role`, + { data: { role_on_team: "player" } } + ); + expect(forbiddenRoleChange.status()).toBe(403); + // A player token gets the same 403, not just a non-head coach one. + const forbiddenFromPlayer = await playerPage.request.delete(`/api/teams/members/${someMemberId}`); + expect(forbiddenFromPlayer.status()).toBe(403); + + // --- Head coach changes the player's role to coach, then removes + // the other coach entirely --- + await playerRow.locator('[data-testid^="team-member-toggle-role-"]').click(); + await expect(playerRow).toContainText("coach"); + + const otherCoachRow = headPage.locator('[data-testid^="team-member-row-"]').filter({ hasText: "Other Coach" }); + await otherCoachRow.locator('[data-testid^="team-member-remove-"]').click(); + await expect(panel).not.toContainText("Other Coach"); + + await assertCleanPage(headPage, headIssues); + await assertCleanPage(playerPage, playerIssues); + + await headContext.close(); + await otherCoachContext.close(); + await playerContext.close(); + }); +}); diff --git a/frontend/src/AppShell.css b/frontend/src/AppShell.css index 21f6516..30a3f8e 100644 --- a/frontend/src/AppShell.css +++ b/frontend/src/AppShell.css @@ -126,6 +126,81 @@ white-space: nowrap; } +.join-codes { + display: flex; + flex-direction: column; + gap: 1px; +} + +/* Head-coach member management (T-043 decision 3, no PNG exists for this, + Brief section 8: smallest honest surface, collapsed by default so it + never forces the topbar to overflow). */ +.team-members { + position: relative; +} +.team-members-toggle { + font: inherit; + font-size: 12px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--text-secondary); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; +} +.team-members-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 10; + min-width: 240px; + max-width: 320px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + font-family: var(--body-font); +} +.team-members-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.team-members-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} +.team-members-name { + font-size: 12px; + color: var(--text-primary); +} +.team-members-controls { + display: flex; + gap: 6px; +} +.team-members-btn { + font: inherit; + font-size: 11px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--text-secondary); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; +} +.team-members-btn:disabled { + opacity: 0.6; + cursor: default; +} + /* Phone: sidebar collapses to a 52px vertical icon rail (design README). */ @media (max-width: 700px) { .app-body { diff --git a/frontend/src/TeamMeta.tsx b/frontend/src/TeamMeta.tsx index 2303b6e..ca47fa4 100644 --- a/frontend/src/TeamMeta.tsx +++ b/frontend/src/TeamMeta.tsx @@ -1,13 +1,32 @@ // Compact team identity cluster for the app shell topbar (T-030). Team // creation/join has no designed screen (Brief section 8 gap); this remains -// the smallest functional surface for it (join code, role, logout), just +// the smallest functional surface for it (join codes, role, logout), just // relocated from its own landing page into the topbar now that the // Whiteboard is the landing page. Testids/text kept stable on purpose: the // T-003 platform DoD journey (e2e/auth-teams.spec.ts) asserts on this exact -// shape (a team-name heading, "(role)" text, ".join-code strong", a "Log +// shape (a team-name heading, "(role)" text, a join-code testid, a "Log // out" button, and the join-code block ABSENT from the DOM for players). +// +// T-043 (founder decision 2026-07-16): two codes now, player and coach, +// both coach-only (both keys are simply absent on a player's own +// membership.team, per CLAUDE.md rule 5 / app/schemas.py CoachTeamOut), +// plus a coach-only, collapsed-by-default member list: any coach can see +// who is on the team, but remove/role controls render ONLY for the head +// coach (the team's creator, Team.created_by == the signed-in user's own +// id). The API re-enforces the head-coach check independently +// (app/deps.py require_head_coach); this is only the rendering gate. -import { MembershipOut, UserOut } from "./api"; +import { useCallback, useEffect, useState } from "react"; +import { + ApiError, + MembershipOut, + Role, + TeamMemberOut, + UserOut, + fetchTeamMembers, + removeTeamMember, + updateTeamMemberRole, +} from "./api"; export function TeamMeta({ user, @@ -19,6 +38,7 @@ export function TeamMeta({ onLogout: () => void; }) { const isCoach = membership.role_on_team === "coach"; + const isHeadCoach = isCoach && membership.team.created_by === user.id; return (
@@ -28,14 +48,143 @@ export function TeamMeta({ {user.display_name} ({membership.role_on_team})

{isCoach && ( -

- Join code: {membership.team.join_code} -

+
+

+ Player code: {membership.team.join_code} +

+

+ Coach code: {membership.team.coach_join_code} +

+
)}
+ {isCoach && } ); } + +function TeamMembers({ + currentUserId, + isHeadCoach, +}: { + currentUserId: number; + isHeadCoach: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const [members, setMembers] = useState(null); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + + const refresh = useCallback(async () => { + try { + setMembers(await fetchTeamMembers()); + setError(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Could not load the team members."); + } + }, []); + + useEffect(() => { + if (expanded) refresh(); + }, [expanded, refresh]); + + const handleRemove = useCallback( + async (memberId: number) => { + setBusyId(memberId); + try { + await removeTeamMember(memberId); + await refresh(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Could not remove this member."); + } finally { + setBusyId(null); + } + }, + [refresh] + ); + + const handleRoleChange = useCallback( + async (memberId: number, nextRole: Role) => { + setBusyId(memberId); + try { + await updateTeamMemberRole(memberId, nextRole); + await refresh(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Could not change this member's role."); + } finally { + setBusyId(null); + } + }, + [refresh] + ); + + return ( +
+ + {expanded && ( +
+ {error && ( +

+ {error} +

+ )} +
    + {(members ?? []).map((member) => { + const isSelf = member.user_id === currentUserId; + return ( +
  • + + {member.display_name} ({member.role_on_team} + {member.is_head_coach ? ", head coach" : ""}){isSelf ? " (you)" : ""} + + {isHeadCoach && !isSelf && ( + + + + + )} +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/TeamOnboarding.tsx b/frontend/src/TeamOnboarding.tsx index 46860e6..e8abb0a 100644 --- a/frontend/src/TeamOnboarding.tsx +++ b/frontend/src/TeamOnboarding.tsx @@ -1,6 +1,15 @@ // Minimal token-styled screens for team creation and join-by-code (Brief // section 8: no designed surface exists for either; smallest functional // version, existing component idioms only, no invented navigation). +// +// T-043 (founder decision 2026-07-16): a join code now carries its own +// role (player code vs coach code), so which role an account already has +// no longer decides anything about joining. A coach-role account can +// still ALSO create its own team (team creation stays a coach-account +// action, unchanged), but every account, coach or player, gets the join +// form: a coach-role account needs it to join an existing team as a +// player (or as a second coach), exactly as a player-role account needs +// it to join as a coach if handed the coach code. import { FormEvent, useState } from "react"; import { ApiError, Role, createTeam, joinTeam } from "./api"; @@ -12,10 +21,11 @@ export function TeamOnboarding({ role: Role; onTeamReady: () => void; }) { - return role === "coach" ? ( - - ) : ( - + return ( +
+ {role === "coach" && } + +
); } @@ -81,6 +91,10 @@ function JoinTeamForm({ onTeamReady }: { onTeamReady: () => void }) { return (

Join your team

+

+ Enter the code your coach gave you. Your role on the team comes from the code itself, a + player code or a coach code, not from how you registered. +