Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/kitchen_mate/frontend/src/api/kitchens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ export async function listKitchenRecipes(
return handleResponse<ListKitchenRecipesResponse>(res);
}

export async function updateMemberRole(
kitchenId: string,
userId: string,
role: "admin" | "member"
): Promise<void> {
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
Expand Down
58 changes: 51 additions & 7 deletions apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
listKitchenRecipes,
addMember,
removeMember,
updateMemberRole,
removeKitchenRecipe,
KitchenError,
} from "../api/kitchens";
Expand All @@ -28,6 +29,7 @@ export function KitchenDetailPage() {
const [inviteMessage, setInviteMessage] = useState<string | null>(null);
const [inviteError, setInviteError] = useState<string | null>(null);
const [removingMember, setRemovingMember] = useState<string | null>(null);
const [changingRole, setChangingRole] = useState<string | null>(null);

// Recipe management
const [removingRecipe, setRemovingRecipe] = useState<string | null>(null);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -140,13 +164,33 @@ export function KitchenDetailPage() {
)}
</div>
{isAdmin && member.user_id !== user?.id && (
<button
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingMember === member.user_id}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
{removingMember === member.user_id ? "Removing..." : "Remove"}
</button>
<div className="flex items-center gap-2">
{member.user_id !== kitchen.created_by && (
<button
onClick={() =>
handleChangeRole(
member.user_id,
member.role === "admin" ? "member" : "admin"
)
}
disabled={changingRole === member.user_id}
className="text-xs text-coral hover:text-coral-dark disabled:opacity-50"
>
{changingRole === member.user_id
? "Updating..."
: member.role === "admin"
? "Make member"
: "Make admin"}
</button>
)}
<button
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingMember === member.user_id}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
{removingMember === member.user_id ? "Removing..." : "Remove"}
</button>
</div>
)}
</li>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 33 additions & 0 deletions apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
remove_kitchen_recipe,
remove_member,
share_recipe_to_kitchen,
update_member_role,
)
from kitchen_mate.schemas import (
AddMemberRequest,
Expand All @@ -29,6 +30,7 @@
KitchenSummaryResponse,
ListKitchenRecipesResponse,
ShareToKitchenRequest,
UpdateMemberRoleRequest,
)

router = APIRouter()
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions apps/kitchen_mate/src/kitchen_mate/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading