diff --git a/apps/kitchen_mate/frontend/src/api/kitchens.ts b/apps/kitchen_mate/frontend/src/api/kitchens.ts index 15b7fd5..1689c8c 100644 --- a/apps/kitchen_mate/frontend/src/api/kitchens.ts +++ b/apps/kitchen_mate/frontend/src/api/kitchens.ts @@ -97,6 +97,26 @@ export async function listKitchenRecipes( return handleResponse(res); } +export async function updateMemberRole( + kitchenId: string, + userId: string, + role: "admin" | "member" +): Promise { + const res = await fetch( + `/api/kitchens/${encodeURIComponent(kitchenId)}/members/${encodeURIComponent(userId)}`, + { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + } + ); + if (!res.ok && res.status !== 204) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new KitchenError(body.detail ?? res.statusText, res.status); + } +} + export async function removeKitchenRecipe( kitchenId: string, kitchenRecipeId: string diff --git a/apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx b/apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx index 6da1b16..12b5bfc 100644 --- a/apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx +++ b/apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx @@ -5,6 +5,7 @@ import { listKitchenRecipes, addMember, removeMember, + updateMemberRole, removeKitchenRecipe, KitchenError, } from "../api/kitchens"; @@ -28,6 +29,7 @@ export function KitchenDetailPage() { const [inviteMessage, setInviteMessage] = useState(null); const [inviteError, setInviteError] = useState(null); const [removingMember, setRemovingMember] = useState(null); + const [changingRole, setChangingRole] = useState(null); // Recipe management const [removingRecipe, setRemovingRecipe] = useState(null); @@ -66,6 +68,28 @@ export function KitchenDetailPage() { } }; + const handleChangeRole = async (userId: string, newRole: "admin" | "member") => { + if (!id) return; + setChangingRole(userId); + try { + await updateMemberRole(id, userId, newRole); + setKitchen((prev) => + prev + ? { + ...prev, + members: prev.members.map((m) => + m.user_id === userId ? { ...m, role: newRole } : m + ), + } + : prev + ); + } catch (err) { + setError(err instanceof KitchenError ? err.message : "Failed to update role"); + } finally { + setChangingRole(null); + } + }; + const handleRemoveMember = async (userId: string) => { if (!id) return; setRemovingMember(userId); @@ -140,13 +164,33 @@ export function KitchenDetailPage() { )} {isAdmin && member.user_id !== user?.id && ( - +
+ {member.user_id !== kitchen.created_by && ( + + )} + +
)} ))} diff --git a/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py b/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py index 95e6da6..d07d783 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py @@ -295,6 +295,21 @@ async def remove_member(kitchen_id: str, target_user_id: str) -> bool: return True +async def update_member_role(kitchen_id: str, target_user_id: str, new_role: str) -> bool: + """Update a member's role. Returns False if member not found.""" + async with get_session() as session: + result = await session.execute( + select(KitchenMemberModel) + .where(KitchenMemberModel.kitchen_id == kitchen_id) + .where(KitchenMemberModel.user_id == target_user_id) + ) + member = result.scalar_one_or_none() + if member is None: + return False + member.role = new_role + return True + + async def process_pending_invites(user_id: str, email: str) -> int: """Resolve pending kitchen invites for an email address. diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py b/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py index 6bec291..35554ca 100644 --- a/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py +++ b/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py @@ -18,6 +18,7 @@ remove_kitchen_recipe, remove_member, share_recipe_to_kitchen, + update_member_role, ) from kitchen_mate.schemas import ( AddMemberRequest, @@ -29,6 +30,7 @@ KitchenSummaryResponse, ListKitchenRecipesResponse, ShareToKitchenRequest, + UpdateMemberRoleRequest, ) router = APIRouter() @@ -167,11 +169,42 @@ async def remove_member_endpoint( """Remove a member from a kitchen (admin only).""" await _require_admin(kitchen_id, user) + kitchen = await get_kitchen(kitchen_id, user.id) + if kitchen is None: + raise HTTPException(status_code=404, detail="Kitchen not found") + if target_user_id == kitchen.created_by: + raise HTTPException(status_code=403, detail="Cannot remove the kitchen creator") + removed = await remove_member(kitchen_id, target_user_id) if not removed: raise HTTPException(status_code=404, detail="Member not found") +@router.patch( + "/kitchens/{kitchen_id}/members/{target_user_id}", + status_code=204, + dependencies=[Depends(_require_multi_tenant)], +) +async def update_member_role_endpoint( + kitchen_id: str, + target_user_id: str, + body: UpdateMemberRoleRequest, + user: Annotated[User, Depends(get_user)], +) -> None: + """Change a member's role (admin only). The kitchen creator's role cannot be changed.""" + await _require_admin(kitchen_id, user) + + kitchen = await get_kitchen(kitchen_id, user.id) + if kitchen is None: + raise HTTPException(status_code=404, detail="Kitchen not found") + if target_user_id == kitchen.created_by: + raise HTTPException(status_code=403, detail="Cannot change the role of the kitchen creator") + + updated = await update_member_role(kitchen_id, target_user_id, body.role) + if not updated: + raise HTTPException(status_code=404, detail="Member not found") + + @router.post( "/kitchens/{kitchen_id}/recipes", response_model=KitchenRecipeResponse, diff --git a/apps/kitchen_mate/src/kitchen_mate/schemas.py b/apps/kitchen_mate/src/kitchen_mate/schemas.py index 8e811ef..9165d14 100644 --- a/apps/kitchen_mate/src/kitchen_mate/schemas.py +++ b/apps/kitchen_mate/src/kitchen_mate/schemas.py @@ -339,6 +339,18 @@ class AddMemberResponse(BaseModel): message: str = Field(description="Human-readable status message") +class UpdateMemberRoleRequest(BaseModel): + """Request body for updating a kitchen member's role.""" + + role: str = Field(description="New role: 'admin' or 'member'") + + @model_validator(mode="after") + def validate_role(self) -> "UpdateMemberRoleRequest": + if self.role not in ("admin", "member"): + raise ValueError("role must be 'admin' or 'member'") + return self + + class ShareToKitchenRequest(BaseModel): """Request body for sharing a recipe with a kitchen."""