diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index 6d86c3658..38f6f56c3 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -12,7 +12,12 @@ @confirm="deleteRecipe()" > - {{ $t("recipe.delete-confirmation") }} + + recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined, ); + const isAdminAndNotOwner = computed(() => { + return ( + $auth.user.value?.admin + && $auth.user.value?.id !== recipeRef.value?.userId + ); + }); + const canDelete = computed(() => { + const user = $auth.user.value; + const recipe = recipeRef.value; + return user && recipe && (user.admin || user.id === recipe.userId); + }); + + // Get Default Menu Items Specified in Props + for (const [key, value] of Object.entries(props.useItems)) { + if (!value) continue; + + // Skip delete if not allowed + if (key === "delete" && !canDelete.value) continue; + + const item = defaultItems[key]; + if (item && (item.isPublic || isOwnGroup.value)) { + state.menuItems.push(item); + } + } async function getShoppingLists() { const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" }); @@ -521,6 +540,8 @@ export default defineNuxtComponent({ icon, planTypeOptions, firstDayOfWeek, + isAdminAndNotOwner, + canDelete, }; }, }); diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index f3e3c2485..3a174488a 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -472,6 +472,7 @@ "comment": "Comment", "comments": "Comments", "delete-confirmation": "Are you sure you want to delete this recipe?", + "admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?", "delete-recipe": "Delete Recipe", "description": "Description", "disable-amount": "Disable Ingredient Amounts", diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 0040ff88a..af827feff 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -64,6 +64,12 @@ class RecipeService(RecipeServiceBase): raise exceptions.NoEntryFound("Recipe not found.") return recipe + def can_delete(self, recipe: Recipe) -> bool: + if self.user.admin: + return True + else: + return self.can_update(recipe) + def can_update(self, recipe: Recipe) -> bool: if recipe.settings is None: raise exceptions.UnexpectedNone("Recipe Settings is None") @@ -423,7 +429,7 @@ class RecipeService(RecipeServiceBase): def delete_one(self, slug_or_id: str | UUID) -> Recipe: recipe = self.get_one(slug_or_id) - if not self.can_update(recipe): + if not self.can_delete(recipe): raise exceptions.PermissionDenied("You do not have permission to delete this recipe.") data = self.group_recipes.delete(recipe.id, "id") diff --git a/poetry.lock b/poetry.lock index 8a8b7e5e8..9a8104b38 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3269,29 +3269,30 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.12.6" +version = "0.12.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:59b48d8581989e0527b64c3297e672357c03b78d58cf1b228037a49915316277"}, - {file = "ruff-0.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:412518260394e8a6647a0c610062cac48ff230d39b9df57faae93aa77123e90c"}, - {file = "ruff-0.12.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b56a3f51a27d0db8141d5b4b095c2849b24f639539a05d201f72f8d83f829a78"}, - {file = "ruff-0.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ef9e292957bd6a868ce4e5f57931d0583814a363add2adedae3a1c9854b7ad9"}, - {file = "ruff-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c3fd9955d3009c33e60bb596ea7bc66832de34d621883061114bb3b6114d358"}, - {file = "ruff-0.12.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e7456efef8dd6957843de60a245152e34a842210d8b13381d5f3e7540d17935"}, - {file = "ruff-0.12.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c99e62bae20c7e1a8d4de84f96754e9732d0831614ed165415ed2c4f4aa83864"}, - {file = "ruff-0.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d47ff2b300da87df8437e1b35291349faaceb666d8349edef733b6562d29264f"}, - {file = "ruff-0.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8883ab5e9506574a6a2abacb5da34d416fdd8434151b35421ba3f79ca9a14a11"}, - {file = "ruff-0.12.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3cfbd192c312669fb22cd4bf8c700e8b4b1dced7ce034e581459c0e375486fa"}, - {file = "ruff-0.12.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c1d87f2b1abf330281b3972d6bf34d366ee84b3077df66a89169e2d81b291891"}, - {file = "ruff-0.12.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3f32aaa9b5ed69de80693abeecf9961cd97851cadf7850081461261d0e6551b6"}, - {file = "ruff-0.12.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:de5185f19289a800c16d6ec8a9ba0b8b911b4640a4927b487f48fb51634ce315"}, - {file = "ruff-0.12.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80f9d56205f6f6c4a1039c79d9acc0a9c104915f4fc0fc0385170decc72f6e4c"}, - {file = "ruff-0.12.6-py3-none-win32.whl", hash = "sha256:b553271d6ed5611fcbe5f6752852eef695f2a77c0405b3a16fd507e5a057f5b0"}, - {file = "ruff-0.12.6-py3-none-win_amd64.whl", hash = "sha256:48b73d4acef6768bfe9912e8f623ec87677bcfb6dc748ac406ebff06a84a6d70"}, - {file = "ruff-0.12.6-py3-none-win_arm64.whl", hash = "sha256:cd2c9c898a11f1441778d1cf9e358244cf5f4f2f11e93ff03c1a6c6759f4b15d"}, + {file = "ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303"}, + {file = "ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb"}, + {file = "ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4"}, + {file = "ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77"}, + {file = "ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f"}, + {file = "ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69"}, + {file = "ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71"}, ] [[package]] diff --git a/tests/fixtures/fixture_users.py b/tests/fixtures/fixture_users.py index 0c621acb8..1eccf79e0 100644 --- a/tests/fixtures/fixture_users.py +++ b/tests/fixtures/fixture_users.py @@ -226,6 +226,13 @@ def unique_user(session: Session, api_client: TestClient): yield from _unique_user(session, api_client) +@fixture(scope="module") +def unique_admin(session: Session, api_client: TestClient, unique_user: utils.TestUser): + admin_user = next(_unique_user(session, api_client)) + admin_user.repos.users.patch(admin_user.user_id, {"admin": True, "group_id": unique_user.group_id}) + yield admin_user + + @fixture(scope="module") def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generator[list[utils.TestUser], None, None]: group_name = utils.random_string() diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py b/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py index cd587d795..4334c0e55 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py @@ -215,6 +215,42 @@ def test_delete_recipes_from_other_households( assert response.status_code == 404 +@pytest.mark.parametrize("is_private_household", [True, False]) +@pytest.mark.parametrize("household_lock_recipe_edits", [True, False]) +def test_admin_delete_recipes_from_other_households( + api_client: TestClient, + unique_admin: TestUser, + h2_user: TestUser, + is_private_household: bool, + household_lock_recipe_edits: bool, +): + household = h2_user.repos.households.get_one(h2_user.household_id) + assert household and household.preferences + household.preferences.private_household = is_private_household + household.preferences.lock_recipe_edits_from_other_households = household_lock_recipe_edits + h2_user.repos.household_preferences.update(household.id, household.preferences) + + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token) + assert response.status_code == 201 + h2_recipe = h2_user.repos.recipes.get_one(response.json()) + assert h2_recipe and h2_recipe.id + h2_recipe_id = str(h2_recipe.id) + + response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_admin.token) + assert response.status_code == 200 + recipe_json = response.json() + assert recipe_json["id"] == h2_recipe_id + + # Admin users should always be able to delete recipes from other households + # regardless of household_lock_recipe_edits setting + response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_admin.token) + assert response.status_code == 200 + + # confirm the recipe was deleted + response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_admin.token) + assert response.status_code == 404 + + @pytest.mark.parametrize("is_private_household", [True, False]) @pytest.mark.parametrize("household_lock_recipe_edits", [True, False]) def test_user_can_update_last_made_on_other_household( diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py index 3b6372281..f1b1c1ec9 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -1,7 +1,11 @@ from datetime import UTC, datetime +from uuid import UUID from fastapi.testclient import TestClient +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_settings import RecipeSettings from tests.utils import api_routes from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -135,3 +139,44 @@ def test_other_user_cant_lock_recipe(api_client: TestClient, user_tuple: list[Te recipe["settings"]["locked"] = True response = api_client.put(api_routes.recipes + f"/{recipe_name}", json=recipe, headers=usr_2.token) assert response.status_code == 403 + + +def test_other_user_cant_delete_recipe(api_client: TestClient, user_tuple: list[TestUser]): + slug = random_string(10) + unique_user, other_user = user_tuple + + unique_user.repos.recipes.create( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=slug, + settings=RecipeSettings(locked=True), + ) + ) + + response = api_client.delete(api_routes.recipes_slug(slug), headers=other_user.token) + assert response.status_code == 403 + + +def test_admin_can_delete_locked_recipe_owned_by_another_user( + api_client: TestClient, unfiltered_database: AllRepositories, unique_user: TestUser, admin_user: TestUser +): + slug = random_string(10) + unique_user.repos.recipes.create( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=slug, + settings=RecipeSettings(locked=True), + ) + ) + + # Make sure admin belongs to same group/household as user + admin_data = unfiltered_database.users.get_one(admin_user.user_id) + assert admin_data + admin_data.group_id = UUID(unique_user.group_id) + admin_data.household_id = UUID(unique_user.household_id) + unfiltered_database.users.update(admin_user.user_id, admin_data) + + response = api_client.delete(api_routes.recipes_slug(slug), headers=admin_user.token) + assert response.status_code == 200