Repository: mealie-recipes/mealie
Pull request: feat: Additional Household Permissions
## What type of PR is this?

_(REQUIRED)_

<!--
  Delete any of the following that do not apply:
 -->

- bug
- cleanup
- feature

## What this PR does / why we need it:

_(REQUIRED)_

This PR adds two household-related permissions:
- Household Preference: Lock Recipe Edits From Other Households
- User Permission: Can Manage Household

### Lock Recipe Edits From Other Households
This flag lets households decide if other households can only view their recipes, or if they can edit them. With this unflagged, any user from any household in the group can edit their recipes.

When set, it behaves like it was implemented in https://github.com/mealie-recipes/mealie/pull/4089 (users from other households can't edit). When unset, edits (including deletes) are allowed (this is a new feature from this PR).

### Can Manage Household
This flag allows users to manage household settings (such as the new lock recipe edits flag). Previously this was tied to the generic `Can Manage` permission, but it makes sense to want users to be able to manage their own households, but not necessarily manage the group.

---

Both flags are added to their respective settings pages:
![image](https://github.com/user-attachments/assets/7a1daa84-06b8-4659-9006-07845da43edb)
![image](https://github.com/user-attachments/assets/4d3d90ae-19ae-458d-ac87-65112f9841d4)
![image](https://github.com/user-attachments/assets/3825bb6f-ca24-4f74-9f26-1e8dc631f9cd)
![image](https://github.com/user-attachments/assets/54acf67a-e88a-4648-926e-6f94d4c8bacd)

I also merged the two household settings pages; one was using a component, and one was just baked into the page, so I replaced the page one with the component. The component was missing all of the help text, so I re-wrote it to support the help text.

For reference, it currently looks like this:
![image](https://github.com/user-attachments/assets/d9c29f45-13b3-4ab1-9418-3167806eff63)

And now it looks like this:
![image](https://github.com/user-attachments/assets/d363c7e8-b208-4ff6-9e32-299d708ea752)


## Which issue(s) this PR fixes:

_(REQUIRED)_

Closes https://github.com/mealie-recipes/mealie/issues/4082

## Special notes for your reviewer:

_(fill-in or delete this section)_

I added a new route to fetch a household by id, since we need to check permissions of the recipe's household (which isn't always your household anymore). I also fixed the inconsistency where getting a recipe by id works, but updating/deleting only works by slug. All recipe routes should accept either slugs or ids now.

## Testing

_(fill-in or delete this section)_

frontend and backend tests

Problem statement:
[Task] - Household Permissions
### What is the problem this task addresses?

Our current permissions model doesn't account for households at all. You're pretty much either a super admin (cross-group), an elevated user, or a regular user.

### Proposed/Possible Solution(s)?

Separate permissions into a few different buckets:
- Group Admin; today's admin, basically a super admin, can manage groups, households for all groups, users for all groups/households, and group data (foods, units, etc.).
- Household Admin; can manage their own household, as well as all users in the household
- User; maintain existing permissions (e.g. manage, organize, invite)

We have two different ways we can approach this:
1. To keep things simple, we can probably just add a user-level permission that lets them do household management tasks (similar to existing user permissions)
2. Otherwise, we probably want to develop more isolated roles, which would be a bigger task, but set us up for easier permissions management down the line.

Hints from issue discussion:

Commit message: cleaned up migration script

Patch to review:
diff --git a/alembic/versions/2024-09-02-21.39.49_be568e39ffdf_added_household_recipe_lock_setting_and_.py b/alembic/versions/2024-09-02-21.39.49_be568e39ffdf_added_household_recipe_lock_setting_and_.py
new file mode 100644
index 00000000000..83f2e55186a
--- /dev/null
+++ b/alembic/versions/2024-09-02-21.39.49_be568e39ffdf_added_household_recipe_lock_setting_and_.py
@@ -0,0 +1,75 @@
+"""added household recipe lock setting and household management user permission
+
+Revision ID: be568e39ffdf
+Revises: feecc8ffb956
+Create Date: 2024-09-02 21:39:49.210355
+
+"""
+
+from textwrap import dedent
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "be568e39ffdf"
+down_revision = "feecc8ffb956"
+branch_labels: str | tuple[str, ...] | None = None
+depends_on: str | tuple[str, ...] | None = None
+
+
+def populate_defaults():
+    if op.get_context().dialect.name == "postgresql":
+        TRUE = "TRUE"
+        FALSE = "FALSE"
+    else:
+        TRUE = "1"
+        FALSE = "0"
+
+    op.execute(
+        dedent(
+            f"""
+            UPDATE household_preferences
+            SET lock_recipe_edits_from_other_households = {TRUE}
+            WHERE lock_recipe_edits_from_other_households IS NULL
+            """
+        )
+    )
+    op.execute(
+        dedent(
+            f"""
+            UPDATE users
+            SET can_manage_household = {FALSE}
+            WHERE can_manage_household IS NULL AND admin = {FALSE}
+            """
+        )
+    )
+    op.execute(
+        dedent(
+            f"""
+            UPDATE users
+            SET can_manage_household = {TRUE}
+            WHERE can_manage_household IS NULL AND admin = {TRUE}
+            """
+        )
+    )
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column(
+        "household_preferences",
+        sa.Column("lock_recipe_edits_from_other_households", sa.Boolean(), nullable=True),
+    )
+    op.add_column("users", sa.Column("can_manage_household", sa.Boolean(), nullable=True))
+    # ### end Alembic commands ###
+
+    populate_defaults()
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column("users", "can_manage_household")
+    op.drop_column("household_preferences", "lock_recipe_edits_from_other_households")
+    # ### end Alembic commands ###
diff --git a/frontend/components/Domain/Household/HouseholdPreferencesEditor.vue b/frontend/components/Domain/Household/HouseholdPreferencesEditor.vue
index ab6b8833f5b..b97342fbb20 100644
--- a/frontend/components/Domain/Household/HouseholdPreferencesEditor.vue
+++ b/frontend/components/Domain/Household/HouseholdPreferencesEditor.vue
@@ -1,7 +1,33 @@
 <template>
   <div v-if="preferences">
-    <BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle>
-    <v-checkbox v-model="preferences.privateHousehold" class="mt-n4" :label="$t('household.private-household')"></v-checkbox>
+    <BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
+    <div class="mb-6">
+      <v-checkbox
+        v-model="preferences.privateHousehold"
+        hide-details
+        dense
+        :label="$t('household.private-household')"
+      />
+      <div class="ml-8">
+        <p class="text-subtitle-2 my-0 py-0">
+          {{ $t("household.private-household-description") }}
+        </p>
+        <DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
+      </div>
+    </div>
+    <div class="mb-6">
+      <v-checkbox
+        v-model="preferences.lockRecipeEditsFromOtherHouseholds"
+        hide-details
+        dense
+        :label="$t('household.lock-recipe-edits-from-other-households')"
+      />
+      <div class="ml-8">
+        <p class="text-subtitle-2 my-0 py-0">
+          {{ $t("household.lock-recipe-edits-from-other-households-description") }}
+        </p>
+      </div>
+    </div>
     <v-select
       v-model="preferences.firstDayOfWeek"
       :prepend-icon="$globals.icons.calendarWeekBegin"
@@ -12,20 +38,25 @@
     />
 
     <BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle>
-    <template v-for="(_, key) in preferences">
-      <v-checkbox
-        v-if="labels[key]"
-        :key="key"
-        v-model="preferences[key]"
-        class="mt-n4"
-        :label="labels[key]"
-      ></v-checkbox>
-    </template>
+    <div class="preference-container">
+      <div v-for="p in recipePreferences" :key="p.key">
+        <v-checkbox
+          v-model="preferences[p.key]"
+          hide-details
+          dense
+          :label="p.label"
+        />
+        <p class="ml-8 text-subtitle-2 my-0 py-0">
+          {{ p.description }}
+        </p>
+      </div>
+    </div>
   </div>
 </template>
 
 <script lang="ts">
 import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
+import { ReadHouseholdPreferences } from "~/lib/api/types/household";
 
 export default defineComponent({
   props: {
@@ -37,14 +68,44 @@ export default defineComponent({
   setup(props, context) {
     const { i18n } = useContext();
 
-    const labels = {
-      recipePublic: i18n.tc("household.allow-users-outside-of-your-household-to-see-your-recipes"),
-      recipeShowNutrition: i18n.tc("group.show-nutrition-information"),
-      recipeShowAssets: i18n.tc("group.show-recipe-assets"),
-      recipeLandscapeView: i18n.tc("group.default-to-landscape-view"),
-      recipeDisableComments: i18n.tc("group.disable-users-from-commenting-on-recipes"),
-      recipeDisableAmount: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
-    };
+    type Preference = {
+      key: keyof ReadHouseholdPreferences;
+      label: string;
+      description: string;
+    }
+
+    const recipePreferences: Preference[] = [
+      {
+        key: "recipePublic",
+        label: i18n.tc("household.allow-users-outside-of-your-household-to-see-your-recipes"),
+        description: i18n.tc("household.allow-users-outside-of-your-household-to-see-your-recipes-description"),
+      },
+      {
+        key: "recipeShowNutrition",
+        label: i18n.tc("group.show-nutrition-information"),
+        description: i18n.tc("group.show-nutrition-information-description"),
+      },
+      {
+        key: "recipeShowAssets",
+        label: i18n.tc("group.show-recipe-assets"),
+        description: i18n.tc("group.show-recipe-assets-description"),
+      },
+      {
+        key: "recipeLandscapeView",
+        label: i18n.tc("group.default-to-landscape-view"),
+        description: i18n.tc("group.default-to-landscape-view-description"),
+      },
+      {
+        key: "recipeDisableComments",
+        label: i18n.tc("group.disable-users-from-commenting-on-recipes"),
+        description: i18n.tc("group.disable-users-from-commenting-on-recipes-description"),
+      },
+      {
+        key: "recipeDisableAmount",
+        label: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
+        description: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
+      },
+    ];
 
     const allDays = [
       {
@@ -88,12 +149,18 @@ export default defineComponent({
 
     return {
       allDays,
-      labels,
       preferences,
+      recipePreferences,
     };
   },
 });
 </script>
 
-<style lang="scss" scoped>
+<style lang="css">
+.preference-container {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+  max-width: 600px;
+}
 </style>
diff --git a/frontend/components/Domain/Recipe/RecipeCard.vue b/frontend/components/Domain/Recipe/RecipeCard.vue
index aa22437e3e5..b6c627c60d3 100644
--- a/frontend/components/Domain/Recipe/RecipeCard.vue
+++ b/frontend/components/Domain/Recipe/RecipeCard.vue
@@ -50,7 +50,7 @@
               :recipe-id="recipeId"
               :use-items="{
                 delete: false,
-                edit: true,
+                edit: false,
                 download: true,
                 mealplanner: true,
                 shoppingList: true,
diff --git a/frontend/components/Domain/Recipe/RecipeCardMobile.vue b/frontend/components/Domain/Recipe/RecipeCardMobile.vue
index 1f9b281adeb..9591e5a2b68 100644
--- a/frontend/components/Domain/Recipe/RecipeCardMobile.vue
+++ b/frontend/components/Domain/Recipe/RecipeCardMobile.vue
@@ -61,7 +61,7 @@
                 :recipe-id="recipeId"
                 :use-items="{
                   delete: false,
-                  edit: true,
+                  edit: false,
                   download: true,
                   mealplanner: true,
                   shoppingList: true,
diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue
index 18915641ae2..dd0a236e4c2 100644
--- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue
+++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue
@@ -64,12 +64,14 @@
 <script lang="ts">
 import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
 import { useLoggedInState } from "~/composables/use-logged-in-state";
+import { useUserApi } from "~/composables/api";
 import { useRecipePermissions } from "~/composables/recipes";
 import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
 import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
 import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
 import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
 import { useStaticRoutes } from "~/composables/api";
+import { HouseholdSummary } from "~/lib/api/types/household";
 import { Recipe } from "~/lib/api/types/recipe";
 import { NoUndefinedField } from "~/lib/api/types/non-generated";
 import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
@@ -100,7 +102,15 @@ export default defineComponent({
     const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
     const { user } = usePageUser();
     const { isOwnGroup } = useLoggedInState();
-    const { canEditRecipe } = useRecipePermissions(props.recipe, user);
+
+    const recipeHousehold = ref<HouseholdSummary>();
+    if (user) {
+      const userApi = useUserApi();
+      userApi.groups.fetchHousehold(props.recipe.householdId).then(({ data }) => {
+        recipeHousehold.value = data || undefined;
+      });
+    }
+    const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
 
     function printRecipe() {
       window.print();
diff --git a/frontend/composables/recipes/use-recipe-permissions.test.ts b/frontend/composables/recipes/use-recipe-permissions.test.ts
index 6dedeb32b37..a962d7af34d 100644
--- a/frontend/composables/recipes/use-recipe-permissions.test.ts
+++ b/frontend/composables/recipes/use-recipe-permissions.test.ts
@@ -1,5 +1,7 @@
 import { describe, test, expect } from "vitest";
+import { ref, Ref } from "@nuxtjs/composition-api";
 import { useRecipePermissions } from "./use-recipe-permissions";
+import { HouseholdSummary } from "~/lib/api/types/household";
 import { Recipe } from "~/lib/api/types/recipe";
 import { UserOut } from "~/lib/api/types/user";
 
@@ -32,35 +34,76 @@ describe("test use recipe permissions", () => {
     ...overrides,
   });
 
+  const createRecipeHousehold = (overrides: Partial<HouseholdSummary>, lockRecipeEdits = false): Ref<HouseholdSummary> => (
+    ref({
+      id: commonHouseholdId,
+      groupId: commonGroupId,
+      name: "My Household",
+      slug: "my-household",
+      preferences: {
+        id: "my-household-preferences-id",
+        lockRecipeEditsFromOtherHouseholds: lockRecipeEdits,
+      },
+      ...overrides,
+    })
+  );
+
   test("when user is null, cannot edit", () => {
-    const result = useRecipePermissions(createRecipe({}), null);
+    const result = useRecipePermissions(createRecipe({}), createRecipeHousehold({}), null);
     expect(result.canEditRecipe.value).toBe(false);
   });
 
   test("when user is recipe owner, can edit", () => {
-    const result = useRecipePermissions(createRecipe({}), createUser({}));
+    const result = useRecipePermissions(createRecipe({}), ref(), createUser({}));
     expect(result.canEditRecipe.value).toBe(true);
   });
 
-  test("when user is not recipe owner, is correct group and household, and recipe is unlocked, can edit", () => {
+  test(
+    "when user is not recipe owner, is correct group and household, recipe is unlocked, and household is unlocked, can edit",
+    () => {
+      const result = useRecipePermissions(
+        createRecipe({}),
+        createRecipeHousehold({}),
+        createUser({ id: "other-user-id" }),
+      );
+      expect(result.canEditRecipe.value).toBe(true);
+    }
+  );
+
+  test(
+    "when user is not recipe owner, is correct group and household, recipe is unlocked, but household is locked, can edit",
+    () => {
+      const result = useRecipePermissions(
+        createRecipe({}),
+        createRecipeHousehold({}, true),
+        createUser({ id: "other-user-id" }),
+      );
+      expect(result.canEditRecipe.value).toBe(true);
+    }
+  );
+
+  test("when user is not recipe owner, and user is other group, cannot edit", () => {
     const result = useRecipePermissions(
       createRecipe({}),
-      createUser({ id: "other-user-id" }),
+      createRecipeHousehold({}),
+      createUser({ id: "other-user-id", groupId: "other-group-id"}),
     );
-    expect(result.canEditRecipe.value).toBe(true);
+    expect(result.canEditRecipe.value).toBe(false);
   });
 
-  test("when user is not recipe owner, and user is other group, cannot edit", () => {
+  test("when user is not recipe owner, and user is other household, and household is unlocked, can edit", () => {
     const result = useRecipePermissions(
       createRecipe({}),
-      createUser({ id: "other-user-id", groupId: "other-group-id"}),
+      createRecipeHousehold({}),
+      createUser({ id: "other-user-id", householdId: "other-household-id" }),
     );
-    expect(result.canEditRecipe.value).toBe(false);
+    expect(result.canEditRecipe.value).toBe(true);
   });
 
-  test("when user is not recipe owner, and user is other household, cannot edit", () => {
+  test("when user is not recipe owner, and user is other household, and household is locked, cannot edit", () => {
     const result = useRecipePermissions(
       createRecipe({}),
+      createRecipeHousehold({}, true),
       createUser({ id: "other-user-id", householdId: "other-household-id" }),
     );
     expect(result.canEditRecipe.value).toBe(false);
@@ -69,13 +112,14 @@ describe("test use recipe permissions", () => {
   test("when user is not recipe owner, and recipe is locked, cannot edit", () => {
     const result = useRecipePermissions(
       createRecipe({}, true),
+      createRecipeHousehold({}),
       createUser({ id: "other-user-id"}),
     );
     expect(result.canEditRecipe.value).toBe(false);
   });
 
-  test("when user is recipe owner, and recipe is locked, can edit", () => {
-    const result = useRecipePermissions(createRecipe({}, true), createUser({}));
+  test("when user is recipe owner, and recipe is locked, and household is locked, can edit", () => {
+    const result = useRecipePermissions(createRecipe({}, true), createRecipeHousehold({}, true), createUser({}));
     expect(result.canEditRecipe.value).toBe(true);
   });
 });
diff --git a/frontend/composables/recipes/use-recipe-permissions.ts b/frontend/composables/recipes/use-recipe-permissions.ts
index bd3af98dca1..d4efbfd058b 100644
--- a/frontend/composables/recipes/use-recipe-permissions.ts
+++ b/frontend/composables/recipes/use-recipe-permissions.ts
@@ -1,34 +1,44 @@
-import { computed } from "@nuxtjs/composition-api";
+import { computed, Ref } from "@nuxtjs/composition-api";
 import { Recipe } from "~/lib/api/types/recipe";
+import { HouseholdSummary } from "~/lib/api/types/household";
 import { UserOut } from "~/lib/api/types/user";
 
-export function useRecipePermissions(recipe: Recipe, user: UserOut | null) {
-    const canEditRecipe = computed(() => {
-        // Check recipe owner
-        if (!user?.id) {
-          return false;
-        }
-        if (user.id === recipe.userId) {
-          return true;
-        }
+export function useRecipePermissions(
+  recipe: Recipe,
+  recipeHousehold: Ref<HouseholdSummary | undefined>,
+  user: UserOut | null,
+) {
+  const canEditRecipe = computed(() => {
+    // Check recipe owner
+    if (!user?.id) {
+      return false;
+    }
+    if (user.id === recipe.userId) {
+      return true;
+    }
 
-        // Check group and household
-        if (user.groupId !== recipe.groupId) {
-          return false;
-        }
-        if (user.householdId !== recipe.householdId) {
-          return false;
-        }
+    // Check group and household
+    if (user.groupId !== recipe.groupId) {
+      return false;
+    }
+    if (user.householdId !== recipe.householdId) {
+      if (!recipeHousehold.value?.preferences) {
+        return false;
+      }
+      if (recipeHousehold.value?.preferences.lockRecipeEditsFromOtherHouseholds) {
+        return false;
+      }
+    }
 
-        // Check recipe
-        if (recipe.settings?.locked) {
-          return false;
-        }
+    // Check recipe
+    if (recipe.settings?.locked) {
+      return false;
+    }
 
-        return true;
-      });
+    return true;
+  });
 
-    return {
-        canEditRecipe,
-    }
+  return {
+    canEditRecipe,
+  }
 }
diff --git a/frontend/composables/use-households.ts b/frontend/composables/use-households.ts
index 618ab75636f..44f9caf8f4e 100644
--- a/frontend/composables/use-households.ts
+++ b/frontend/composables/use-households.ts
@@ -36,6 +36,8 @@ export const useHouseholdSelf = function () {
       if (data) {
         householdSelfRef.value.preferences = data;
       }
+
+      return data || undefined;
     },
   };
 
diff --git a/frontend/composables/use-users/user-form.ts b/frontend/composables/use-users/user-form.ts
index 387f56f49c4..79b889f3f0f 100644
--- a/frontend/composables/use-users/user-form.ts
+++ b/frontend/composables/use-users/user-form.ts
@@ -65,6 +65,12 @@ export const useUserForm = () => {
       type: fieldTypes.BOOLEAN,
       rules: ["required"],
     },
+    {
+      label: i18n.tc("user.user-can-manage-household"),
+      varName: "canManageHousehold",
+      type: fieldTypes.BOOLEAN,
+      rules: ["required"],
+    },
     {
       label: i18n.tc("user.enable-advanced-features"),
       varName: "advanced",
diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json
index 0ee1fadc316..d349c68afc8 100644
--- a/frontend/lang/messages/en-US.json
+++ b/frontend/lang/messages/en-US.json
@@ -241,13 +241,14 @@
     "manage-members": "Manage Members",
     "manage-members-description": "Manage the permissions of the members in your household. {manage} allows the user to access the data-management page, and {invite} allows the user to generate invitation links for other users. Group owners cannot change their own permissions.",
     "manage": "Manage",
+    "manage-household": "Manage Household",
     "invite": "Invite",
     "looking-to-update-your-profile": "Looking to Update Your Profile?",
     "default-recipe-preferences-description": "These are the default settings when a new recipe is created in your group. These can be changed for individual recipes in the recipe settings menu.",
     "default-recipe-preferences": "Default Recipe Preferences",
     "group-preferences": "Group Preferences",
     "private-group": "Private Group",
-    "private-group-description": "Setting your group to private will default all public view options to default. This overrides any individual households or recipes public view settings.",
+    "private-group-description": "Setting your group to private will default all public view options to default. This overrides any individual households or recipes public view settings",
     "enable-public-access": "Enable Public Access",
     "enable-public-access-description": "Make group recipes public by default, and allow visitors to view recipes without logging-in",
     "allow-users-outside-of-your-group-to-see-your-recipes": "Allow users outside of your group to see your recipes",
@@ -285,7 +286,9 @@
     "admin-household-management-text": "Changes to this household will be reflected immediately.",
     "household-id-value": "Household Id: {0}",
     "private-household": "Private Household",
-    "private-household-description": "Setting your household to private will default all public view options to default. This overrides any individual recipes public view settings.",
+    "private-household-description": "Setting your household to private will default all public view options to default. This overrides any individual recipes public view settings",
+    "lock-recipe-edits-from-other-households": "Lock recipe edits from other households",
+    "lock-recipe-edits-from-other-households-description": "When enabled only users in your household can edit recipes created by your household",
     "household-recipe-preferences": "Household Recipe Preferences",
     "default-recipe-preferences-description": "These are the default settings when a new recipe is created in your household. These can be changed for individual recipes in the recipe settings menu.",
     "allow-users-outside-of-your-household-to-see-your-recipes": "Allow users outside of your household to see your recipes",
@@ -987,6 +990,7 @@
     "administrator": "Administrator",
     "user-can-invite-other-to-group": "User can invite others to group",
     "user-can-manage-group": "User can manage group",
+    "user-can-manage-household": "User can manage household",
     "user-can-organize-group-data": "User can organize group data",
     "enable-advanced-features": "Enable advanced features",
     "it-looks-like-this-is-your-first-time-logging-in": "It looks like this is your first time logging in.",
diff --git a/frontend/lib/api/types/admin.ts b/frontend/lib/api/types/admin.ts
index 88bf0f22f21..6f42b40d443 100644
--- a/frontend/lib/api/types/admin.ts
+++ b/frontend/lib/api/types/admin.ts
@@ -16,6 +16,7 @@ export interface AdminAboutInfo {
   oidcRedirect: boolean;
   oidcProviderName: string;
   enableOpenai: boolean;
+  enableOpenaiImageServices: boolean;
   versionLatest: string;
   apiPort: number;
   apiDocs: boolean;
diff --git a/frontend/lib/api/types/household.ts b/frontend/lib/api/types/household.ts
index e97d8485be5..964ee4ddf04 100644
--- a/frontend/lib/api/types/household.ts
+++ b/frontend/lib/api/types/household.ts
@@ -15,6 +15,7 @@ export interface CreateGroupRecipeAction {
 }
 export interface CreateHouseholdPreferences {
   privateHousehold?: boolean;
+  lockRecipeEditsFromOtherHouseholds?: boolean;
   firstDayOfWeek?: number;
   recipePublic?: boolean;
   recipeShowNutrition?: boolean;
@@ -181,6 +182,7 @@ export interface HouseholdInDB {
 }
 export interface ReadHouseholdPreferences {
   privateHousehold?: boolean;
+  lockRecipeEditsFromOtherHouseholds?: boolean;
   firstDayOfWeek?: number;
   recipePublic?: boolean;
   recipeShowNutrition?: boolean;
@@ -237,6 +239,7 @@ export interface SaveGroupRecipeAction {
 }
 export interface SaveHouseholdPreferences {
   privateHousehold?: boolean;
+  lockRecipeEditsFromOtherHouseholds?: boolean;
   firstDayOfWeek?: number;
   recipePublic?: boolean;
   recipeShowNutrition?: boolean;
@@ -263,6 +266,7 @@ export interface SaveWebhook {
 }
 export interface SetPermissions {
   userId: string;
+  canManageHousehold?: boolean;
   canManage?: boolean;
   canInvite?: boolean;
   canOrganize?: boolean;
@@ -645,6 +649,7 @@ export interface UpdateHouseholdAdmin {
 }
 export interface UpdateHouseholdPreferences {
   privateHousehold?: boolean;
+  lockRecipeEditsFromOtherHouseholds?: boolean;
   firstDayOfWeek?: number;
   recipePublic?: boolean;
   recipeShowNutrition?: boolean;
diff --git a/frontend/lib/api/types/openai.ts b/frontend/lib/api/types/openai.ts
index ac4a29bd998..f1a358ae084 100644
--- a/frontend/lib/api/types/openai.ts
+++ b/frontend/lib/api/types/openai.ts
@@ -62,4 +62,159 @@ export interface OpenAIIngredient {
 export interface OpenAIIngredients {
   ingredients?: OpenAIIngredient[];
 }
+export interface OpenAIRecipe {
+  /**
+   *
+   * The name or title of the recipe. If you're unable to determine the name of the recipe, you should
+   * make your best guess based upon the ingredients and instructions provided.
+   *
+   */
+  name: string;
+  /**
+   *
+   * A long description of the recipe. This should be a string that describes the recipe in a few words
+   * or sentences. If the recipe doesn't have a description, you should return None.
+   *
+   */
+  description: string | null;
+  /**
+   *
+   * The yield of the recipe. For instance, if the recipe makes 12 cookies, the yield is "12 cookies".
+   * If the recipe makes 2 servings, the yield is "2 servings". Typically yield consists of a number followed
+   * by the word "serving" or "servings", but it can be any string that describes the yield. If the yield
+   * isn't specified, you should return None.
+   *
+   */
+  recipe_yield?: string | null;
+  /**
+   *
+   * The total time it takes to make the recipe. This should be a string that describes a duration of time,
+   * such as "1 hour and 30 minutes", "90 minutes", or "1.5 hours". If the recipe has multiple times, choose
+   * the longest time. If the recipe doesn't specify a total time or duration, or it specifies a prep time or
+   * perform time but not a total time, you should return None. Do not duplicate times between total time, prep
+   * time and perform time.
+   *
+   */
+  total_time?: string | null;
+  /**
+   *
+   * The time it takes to prepare the recipe. This should be a string that describes a duration of time,
+   * such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the prep time should be
+   * less than the total time. If the recipe doesn't specify a prep time, you should return None. If the recipe
+   * supplies only one time, it should be the total time. Do not duplicate times between total time, prep
+   * time and coperformok time.
+   *
+   */
+  prep_time?: string | null;
+  /**
+   *
+   * The time it takes to cook the recipe. This should be a string that describes a duration of time,
+   * such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the perform time should be
+   * less than the total time. If the recipe doesn't specify a perform time, you should return None. If the
+   * recipe specifies a cook time, active time, or other time besides total or prep, you should use that
+   * time as the perform time. If the recipe supplies only one time, it should be the total time, and not the
+   * perform time. Do not duplicate times between total time, prep time and perform time.
+   *
+   */
+  perform_time?: string | null;
+  /**
+   *
+   * A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
+   * recipe. If the recipe has no ingredients, you should return an empty list.
+   *
+   * Often times, but not always, ingredients are separated by line breaks. Use these as a guide to
+   * separate ingredients.
+   *
+   */
+  ingredients?: OpenAIRecipeIngredient[];
+  /**
+   *
+   * A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
+   * recipe. If the recipe has no ingredients, you should return an empty list.
+   *
+   * Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs.
+   * Use these as a guide to separate instructions. They also may be separated by numbers or words, such as
+   * "1.", "2.", "Step 1", "Step 2", "First", "Second", etc.
+   *
+   */
+  instructions?: OpenAIRecipeInstruction[];
+  /**
+   *
+   * A list of notes found in the recipe. Notes should be inserted in the order they appear in the recipe.
+   * They may appear anywhere on the recipe, though they are typically found under the instructions.
+   *
+   */
+  notes?: OpenAIRecipeNotes[];
+}
+export interface OpenAIRecipeIngredient {
+  /**
+   *
+   * The title of the section of the recipe that the ingredient is found in. Recipes may not specify
+   * ingredient sections, in which case this should be left blank.
+   * Only the first item in the section should have this set,
+   * whereas subsuquent items should have their titles left blank (unless they start a new section).
+   *
+   */
+  title?: string | null;
+  /**
+   *
+   * The text of the ingredient. This should represent the entire ingredient, such as "1 cup of flour" or
+   * "2 cups of onions, chopped". If the ingredient is completely blank, skip it and do not add the ingredient,
+   * since this field is required.
+   *
+   * If the ingredient has no text, but has a title, include the title on the
+   * next ingredient instead.
+   *
+   */
+  text: string;
+}
+export interface OpenAIRecipeInstruction {
+  /**
+   *
+   * The title of the section of the recipe that the instruction is found in. Recipes may not specify
+   * instruction sections, in which case this should be left blank.
+   * Only the first instruction in the section should have this set,
+   * whereas subsuquent instructions should have their titles left blank (unless they start a new section).
+   *
+   */
+  title?: string | null;
+  /**
+   *
+   * The text of the instruction. This represents one step in the recipe, such as "Preheat the oven to 350",
+   * or "Sauté the onions for 20 minutes". Sometimes steps can be longer, such as "Bring a large pot of lightly
+   * salted water to a boil. Add ditalini pasta and cook for 8 minutes or until al dente; drain.".
+   *
+   * Sometimes, but not always, recipes will include their number in front of the text, such as
+   * "1.", "2.", or "Step 1", "Step 2", or "First", "Second". In the case where they are directly numbered
+   * ("1.", "2.", "Step one", "Step 1", "Step two", "Step 2", etc.), you should not include the number in
+   * the text. However, if they use words ("First", "Second", etc.), then those should be included.
+   *
+   * If the instruction is completely blank, skip it and do not add the instruction, since this field is
+   * required. If the ingredient has no text, but has a title, include the title on the next
+   * instruction instead.
+   *
+   */
+  text: string;
+}
+export interface OpenAIRecipeNotes {
+  /**
+   *
+   * The title of the note. Notes may not specify a title, and just have a body of text. In this case,
+   * title should be left blank, and all content should go in the note text. If the note title is just
+   * "note" or "info", you should ignore it and leave the title blank.
+   *
+   */
+  title?: string | null;
+  /**
+   *
+   * The text of the note. This should represent the entire note, such as "This recipe is great for
+   * a summer picnic" or "This recipe is a family favorite". They may also include additional prep
+   * instructions such as "to make this recipe gluten free, use gluten free flour", or "you may prepare
+   * the dough the night before and refrigerate it until ready to bake".
+   *
+   * If the note is completely blank, skip it and do not add the note, since this field is required.
+   *
+   */
+  text: string;
+}
 export interface OpenAIBase {}
diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts
index 8b2218f0015..81fd6a7014c 100644
--- a/frontend/lib/api/types/user.ts
+++ b/frontend/lib/api/types/user.ts
@@ -114,6 +114,7 @@ export interface PrivateUser {
   advanced?: boolean;
   canInvite?: boolean;
   canManage?: boolean;
+  canManageHousehold?: boolean;
   canOrganize?: boolean;
   groupId: string;
   groupSlug: string;
@@ -189,6 +190,7 @@ export interface UserBase {
   advanced?: boolean;
   canInvite?: boolean;
   canManage?: boolean;
+  canManageHousehold?: boolean;
   canOrganize?: boolean;
 }
 export interface UserIn {
@@ -203,6 +205,7 @@ export interface UserIn {
   advanced?: boolean;
   canInvite?: boolean;
   canManage?: boolean;
+  canManageHousehold?: boolean;
   canOrganize?: boolean;
   password: string;
 }
@@ -218,6 +221,7 @@ export interface UserOut {
   advanced?: boolean;
   canInvite?: boolean;
   canManage?: boolean;
+  canManageHousehold?: boolean;
   canOrganize?: boolean;
   groupId: string;
   groupSlug: string;
diff --git a/frontend/lib/api/user/groups.ts b/frontend/lib/api/user/groups.ts
index a43063988ce..5dc85110abb 100644
--- a/frontend/lib/api/user/groups.ts
+++ b/frontend/lib/api/user/groups.ts
@@ -15,7 +15,8 @@ const routes = {
   groupsSelf: `${prefix}/groups/self`,
   preferences: `${prefix}/groups/preferences`,
   storage: `${prefix}/groups/storage`,
-  households: `${prefix}/households`,
+  households: `${prefix}/groups/households`,
+  householdsId: (id: string | number) => `${prefix}/groups/households/${id}`,
   membersHouseholdId: (householdId: string | number | null) => {
     return householdId ?
       `${prefix}/households/members?householdId=${householdId}` :
@@ -50,6 +51,10 @@ export class GroupAPI extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate
     return await this.requests.get<HouseholdSummary[]>(routes.households);
   }
 
+  async fetchHousehold(householdId: string | number) {
+    return await this.requests.get<HouseholdSummary>(routes.householdsId(householdId));
+  }
+
   async storage() {
     return await this.requests.get<GroupStorage>(routes.storage);
   }
diff --git a/frontend/pages/admin/manage/households/_id.vue b/frontend/pages/admin/manage/households/_id.vue
index 174ab7c1b10..3e897cff2b1 100644
--- a/frontend/pages/admin/manage/households/_id.vue
+++ b/frontend/pages/admin/manage/households/_id.vue
@@ -53,7 +53,7 @@ import { VForm } from "~/types/vuetify";
 
 export default defineComponent({
   components: {
-      HouseholdPreferencesEditor,
+    HouseholdPreferencesEditor,
   },
   layout: "admin",
   setup() {
@@ -94,11 +94,8 @@ export default defineComponent({
 
       const { response, data } = await userApi.households.updateOne(household.value.id, household.value);
       if (response?.status === 200 && data) {
-        if (household.value.slug !== data.slug) {
-          // the slug updated, which invalidates the nav URLs
-          window.location.reload();
-        }
         household.value = data;
+        alert.success(i18n.tc("settings.settings-updated"));
       } else {
         alert.error(i18n.tc("settings.settings-update-failed"));
       }
diff --git a/frontend/pages/household/index.vue b/frontend/pages/household/index.vue
index 3e40b173ed6..6cefb645b8c 100644
--- a/frontend/pages/household/index.vue
+++ b/frontend/pages/household/index.vue
@@ -1,5 +1,5 @@
 <template>
-  <v-container class="narrow-container">
+  <v-container v-if="household" class="narrow-container">
     <BasePageTitle class="mb-5">
       <template #header>
         <v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-group-settings.svg')"></v-img>
@@ -7,70 +7,38 @@
       <template #title> {{ $t("profile.household-settings") }} </template>
       {{ $t("profile.household-description") }}
     </BasePageTitle>
-
-    <section v-if="household">
-      <BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
-      <div class="mb-6">
-        <v-checkbox
-          v-model="household.preferences.privateHousehold"
-          hide-details
-          dense
-          :label="$t('household.private-household')"
-          @change="householdActions.updatePreferences()"
-        />
-        <div class="ml-8">
-          <p class="text-subtitle-2 my-0 py-0">
-            {{ $t("household.private-household-description") }}
-          </p>
-          <DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
-        </div>
-      </div>
-      <v-select
-        v-model="household.preferences.firstDayOfWeek"
-        :prepend-icon="$globals.icons.calendarWeekBegin"
-        :items="allDays"
-        item-text="name"
-        item-value="value"
-        :label="$t('settings.first-day-of-week')"
-        @change="householdActions.updatePreferences()"
-      />
-    </section>
-
-    <section v-if="household">
-      <BaseCardSectionTitle class="mt-10" :title="$tc('group.default-recipe-preferences')">
-        {{ $t("household.default-recipe-preferences-description") }}
-      </BaseCardSectionTitle>
-
-      <div class="preference-container">
-        <div v-for="p in preferencesEditor" :key="p.key">
-          <v-checkbox
-            v-model="household.preferences[p.key]"
-            hide-details
-            dense
-            :label="p.label"
-            @change="householdActions.updatePreferences()"
-          />
-          <p class="ml-8 text-subtitle-2 my-0 py-0">
-            {{ p.description }}
-          </p>
-        </div>
+    <v-form ref="refHouseholdEditForm" @submit.prevent="handleSubmit">
+      <v-card outlined>
+        <v-card-text>
+          <HouseholdPreferencesEditor v-if="household.preferences" v-model="household.preferences" />
+        </v-card-text>
+      </v-card>
+      <div class="d-flex pa-2">
+        <BaseButton type="submit" edit class="ml-auto"> {{ $t("general.update") }}</BaseButton>
       </div>
-    </section>
+    </v-form>
   </v-container>
 </template>
 
 <script lang="ts">
-import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
+import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
+import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue";
+import { VForm } from "~/types/vuetify";
 import { useHouseholdSelf } from "~/composables/use-households";
 import { ReadHouseholdPreferences } from "~/lib/api/types/household";
+import { alert } from "~/composables/use-toast";
 
 export default defineComponent({
+  components: {
+    HouseholdPreferencesEditor,
+  },
   middleware: ["auth", "can-manage-only"],
   setup() {
     const { household, actions: householdActions } = useHouseholdSelf();
-
     const { i18n } = useContext();
 
+    const refHouseholdEditForm = ref<VForm | null>(null);
+
     type Preference = {
       key: keyof ReadHouseholdPreferences;
       value: boolean;
@@ -153,11 +121,27 @@ export default defineComponent({
       },
     ];
 
+    async function handleSubmit() {
+      if (!refHouseholdEditForm.value?.validate() || !household.value?.preferences) {
+        console.log(refHouseholdEditForm.value?.validate());
+        return;
+      }
+
+      const data = await householdActions.updatePreferences();
+      if (data) {
+        alert.success(i18n.tc("settings.settings-updated"));
+      } else {
+        alert.error(i18n.tc("settings.settings-update-failed"));
+      }
+    }
+
     return {
       household,
       householdActions,
       allDays,
       preferencesEditor,
+      refHouseholdEditForm,
+      handleSubmit,
     };
   },
   head() {
diff --git a/frontend/pages/household/members.vue b/frontend/pages/household/members.vue
index 2bef672fe56..cc0e1174041 100644
--- a/frontend/pages/household/members.vue
+++ b/frontend/pages/household/members.vue
@@ -31,6 +31,17 @@
       <template #item.admin="{ item }">
         {{ item.admin ? $t('user.admin') : $t('user.user') }}
       </template>
+      <template #item.manageHousehold="{ item }">
+        <div class="d-flex justify-center">
+          <v-checkbox
+            v-model="item.canManageHousehold"
+            :disabled="item.id === $auth.user.id || item.admin"
+            class=""
+            style="max-width: 30px"
+            @change="setPermissions(item)"
+          ></v-checkbox>
+        </div>
+      </template>
       <template #item.manage="{ item }">
         <div class="d-flex justify-center">
           <v-checkbox
@@ -94,6 +105,7 @@ export default defineComponent({
       { text: i18n.t("group.manage"), value: "manage", sortable: false, align: "center" },
       { text: i18n.t("settings.organize"), value: "organize", sortable: false, align: "center" },
       { text: i18n.t("group.invite"), value: "invite", sortable: false, align: "center" },
+      { text: i18n.t("group.manage-household"), value: "manageHousehold", sortable: false, align: "center" },
     ];
 
     async function refreshMembers() {
@@ -107,6 +119,7 @@ export default defineComponent({
       const payload = {
         userId: user.id,
         canInvite: user.canInvite,
+        canManageHousehold: user.canManageHousehold,
         canManage: user.canManage,
         canOrganize: user.canOrganize,
       };
diff --git a/frontend/pages/user/profile/index.vue b/frontend/pages/user/profile/index.vue
index 3fd7fae833c..bde1dd27279 100644
--- a/frontend/pages/user/profile/index.vue
+++ b/frontend/pages/user/profile/index.vue
@@ -1,7 +1,7 @@
 <template>
   <v-container v-if="user">
     <section class="d-flex flex-column align-center mt-4">
-      <UserAvatar size="96" :user-id="$auth.user.id" />
+      <UserAvatar size="96" :user-id="user.id" />
 
       <h2 class="headline">{{ $t('profile.welcome-user', [user.fullName]) }}</h2>
       <p class="subtitle-1 mb-0 text-center">
@@ -9,7 +9,7 @@
       </p>
       <v-card flat color="transparent" width="100%" max-width="600px">
         <v-card-actions class="d-flex justify-center my-4">
-          <v-btn v-if="$auth.user.canInvite"  outlined rounded @click="getSignupLink()">
+          <v-btn v-if="user.canInvite"  outlined rounded @click="getSignupLink()">
             <v-icon left>
               {{ $globals.icons.createAlt }}
             </v-icon>
@@ -113,7 +113,7 @@
         <p>{{ $t('profile.household-description') }}</p>
       </div>
       <v-row tag="section">
-        <v-col v-if="$auth.user.canManage" cols="12" sm="12" md="6">
+        <v-col v-if="user.canManageHousehold" cols="12" sm="12" md="6">
           <UserProfileLinkCard
             :link="{ text: $tc('profile.household-settings'), to: `/household` }"
             :image="require('~/static/svgs/manage-group-settings.svg')"
@@ -165,13 +165,13 @@
       </v-row>
     </section>
     <v-divider class="my-7" />
-    <section v-if="$auth.user.canManage || $auth.user.canOrganize || $auth.user.advanced">
+    <section v-if="user.canManage || user.canOrganize || user.advanced">
       <div>
         <h3 class="headline">{{ $t('group.group') }}</h3>
         <p>{{ $t('profile.group-description') }}</p>
       </div>
       <v-row tag="section">
-        <v-col v-if="$auth.user.canManage" cols="12" sm="12" md="6">
+        <v-col v-if="user.canManage" cols="12" sm="12" md="6">
           <UserProfileLinkCard
             :link="{ text: $tc('profile.group-settings'), to: `/group` }"
             :image="require('~/static/svgs/manage-group-settings.svg')"
@@ -180,8 +180,7 @@
             {{ $t('profile.group-settings-description') }}
           </UserProfileLinkCard>
         </v-col>
-        <!-- $auth.user.canOrganize should not be null because of the auth middleware -->
-        <v-col v-if="$auth.user.canOrganize" cols="12" sm="12" md="6">
+        <v-col v-if="user.canOrganize" cols="12" sm="12" md="6">
           <UserProfileLinkCard
             :link="{ text: $tc('profile.manage-data'), to: `/group/data/foods` }"
             :image="require('~/static/svgs/manage-recipes.svg')"
diff --git a/mealie/db/models/household/preferences.py b/mealie/db/models/household/preferences.py
index 66c3b440dbf..a6de03c3794 100644
--- a/mealie/db/models/household/preferences.py
+++ b/mealie/db/models/household/preferences.py
@@ -22,6 +22,7 @@ class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
     group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
 
     private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
+    lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
     first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
 
     # Recipe Defaults
diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py
index b962b6be3c2..2659f9288ec 100644
--- a/mealie/db/models/users/users.py
+++ b/mealie/db/models/users/users.py
@@ -68,6 +68,7 @@ class User(SqlAlchemyBase, BaseMixins):
     locked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
 
     # Group Permissions
+    can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False)
     can_manage: Mapped[bool | None] = mapped_column(Boolean, default=False)
     can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)
     can_organize: Mapped[bool | None] = mapped_column(Boolean, default=False)
@@ -108,6 +109,7 @@ class User(SqlAlchemyBase, BaseMixins):
         exclude={
             "password",
             "admin",
+            "can_manage_household",
             "can_manage",
             "can_invite",
             "can_organize",
@@ -186,22 +188,27 @@ def update(self, session: Session, full_name, email, group, household, username,
     def update_password(self, password):
         self.password = password
 
-    def _set_permissions(self, admin, can_manage=False, can_invite=False, can_organize=False, **_):
+    def _set_permissions(
+        self, admin, can_manage_household=False, can_manage=False, can_invite=False, can_organize=False, **_
+    ):
         """Set user permissions based on the admin flag and the passed in kwargs
 
         Args:
             admin (bool):
+            can_manage_household (bool):
             can_manage (bool):
             can_invite (bool):
             can_organize (bool):
         """
         self.admin = admin
         if self.admin:
+            self.can_manage_household = True
             self.can_manage = True
             self.can_invite = True
             self.can_organize = True
             self.advanced = True
         else:
+            self.can_manage_household = can_manage_household
             self.can_manage = can_manage
             self.can_invite = can_invite
             self.can_organize = can_organize
diff --git a/mealie/routes/_base/checks.py b/mealie/routes/_base/checks.py
index 912b9448f32..fb32abf67df 100644
--- a/mealie/routes/_base/checks.py
+++ b/mealie/routes/_base/checks.py
@@ -20,6 +20,11 @@ def __init__(self, user: PrivateUser) -> None:
     # =========================================
     # User Permission Checks
 
+    def can_manage_household(self) -> bool:
+        if not self.user.can_manage_household:
+            raise self.ForbiddenException
+        return True
+
     def can_manage(self) -> bool:
         if not self.user.can_manage:
             raise self.ForbiddenException
diff --git a/mealie/routes/groups/controller_group_self_service.py b/mealie/routes/groups/controller_group_self_service.py
index 0d6db70bfc5..2df5091bebc 100644
--- a/mealie/routes/groups/controller_group_self_service.py
+++ b/mealie/routes/groups/controller_group_self_service.py
@@ -1,6 +1,6 @@
 from functools import cached_property
 
-from fastapi import Query
+from fastapi import HTTPException, Query
 from pydantic import UUID4
 
 from mealie.routes._base.base_controllers import BaseUserController
@@ -10,6 +10,7 @@
 from mealie.schema.group.group_statistics import GroupStorage
 from mealie.schema.household.household import HouseholdSummary
 from mealie.schema.response.pagination import PaginationQuery
+from mealie.schema.response.responses import ErrorResponse
 from mealie.schema.user.user import GroupSummary, UserSummary
 from mealie.services.group_services.group_service import GroupService
 
@@ -42,6 +43,16 @@ def get_group_households(self):
         households = self.repos.households.page_all(PaginationQuery(page=1, per_page=-1)).items
         return [household.cast(HouseholdSummary) for household in households]
 
+    @router.get("/households/{slug}", response_model=HouseholdSummary)
+    def get_group_household(self, slug: str):
+        """Returns a single household belonging to the current group"""
+
+        household = self.repos.households.get_by_slug_or_id(slug)
+        if not household:
+            raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found"))
+
+        return household.cast(HouseholdSummary)
+
     @router.get("/preferences", response_model=ReadGroupPreferences)
     def get_group_preferences(self):
         return self.group.preferences
diff --git a/mealie/routes/households/controller_household_self_service.py b/mealie/routes/households/controller_household_self_service.py
index 18b36c9add0..0b184dbb6a9 100644
--- a/mealie/routes/households/controller_household_self_service.py
+++ b/mealie/routes/households/controller_household_self_service.py
@@ -41,6 +41,8 @@ def get_household_preferences(self):
 
     @router.put("/preferences", response_model=ReadHouseholdPreferences)
     def update_household_preferences(self, new_pref: UpdateHouseholdPreferences):
+        self.checks.can_manage_household()
+
         return self.repos.household_preferences.update(self.household_id, new_pref)
 
     @router.put("/permissions", response_model=UserOut)
@@ -60,6 +62,7 @@ def set_member_permissions(self, permissions: SetPermissions):
 
         target_user.can_invite = permissions.can_invite
         target_user.can_manage = permissions.can_manage
+        target_user.can_manage_household = permissions.can_manage_household
         target_user.can_organize = permissions.can_organize
 
         return self.repos.users.update(permissions.user_id, target_user)
diff --git a/mealie/schema/household/household_permissions.py b/mealie/schema/household/household_permissions.py
index 22ee0eed3dd..4b8f7b2391d 100644
--- a/mealie/schema/household/household_permissions.py
+++ b/mealie/schema/household/household_permissions.py
@@ -5,6 +5,7 @@
 
 class SetPermissions(MealieModel):
     user_id: UUID4
+    can_manage_household: bool = False
     can_manage: bool = False
     can_invite: bool = False
     can_organize: bool = False
diff --git a/mealie/schema/household/household_preferences.py b/mealie/schema/household/household_preferences.py
index f7d09be8d59..5c830bb2ded 100644
--- a/mealie/schema/household/household_preferences.py
+++ b/mealie/schema/household/household_preferences.py
@@ -5,6 +5,7 @@
 
 class UpdateHouseholdPreferences(MealieModel):
     private_household: bool = True
+    lock_recipe_edits_from_other_households: bool = True
     first_day_of_week: int = 0
 
     # Recipe Defaults
diff --git a/mealie/schema/openai/__init__.py b/mealie/schema/openai/__init__.py
index 532faab193f..e153fb35f46 100644
--- a/mealie/schema/openai/__init__.py
+++ b/mealie/schema/openai/__init__.py
@@ -1,7 +1,12 @@
 # This file is auto-generated by gen_schema_exports.py
+from .recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction, OpenAIRecipeNotes
 from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients
 
 __all__ = [
     "OpenAIIngredient",
     "OpenAIIngredients",
+    "OpenAIRecipe",
+    "OpenAIRecipeIngredient",
+    "OpenAIRecipeInstruction",
+    "OpenAIRecipeNotes",
 ]
diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py
index c95c5af6734..6b1b1a18da8 100644
--- a/mealie/schema/user/user.py
+++ b/mealie/schema/user/user.py
@@ -106,6 +106,7 @@ class UserBase(MealieModel):
 
     can_invite: bool = False
     can_manage: bool = False
+    can_manage_household: bool = False
     can_organize: bool = False
     model_config = ConfigDict(
         from_attributes=True,
diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py
index e57223979ce..d9efbaca87e 100644
--- a/mealie/services/recipe/recipe_service.py
+++ b/mealie/services/recipe/recipe_service.py
@@ -73,7 +73,11 @@ def can_update(self, recipe: Recipe) -> bool:
 
         # Check if this user has permission to edit this recipe
         if self.household.id != recipe.household_id:
-            return False
+            other_household = self.repos.households.get_one(recipe.household_id)
+            if not (other_household and other_household.preferences):
+                return False
+            if other_household.preferences.lock_recipe_edits_from_other_households:
+                return False
         if recipe.settings.locked:
             return False
 
@@ -135,7 +139,7 @@ def _recipe_creation_factory(self, name: str, additional_attrs: dict | None = No
 
         return Recipe(**additional_attrs)
 
-    def get_one(self, slug_or_id: str | UUID) -> Recipe | None:
+    def get_one(self, slug_or_id: str | UUID) -> Recipe:
         if isinstance(slug_or_id, str):
             try:
                 slug_or_id = UUID(slug_or_id)
@@ -301,10 +305,10 @@ async def create_from_images(self, images: list[UploadFile], translate_language:
                 data_service.write_image(f.read(), "webp")
             return recipe
 
-    def duplicate_one(self, old_slug: str, dup_data: RecipeDuplicate) -> Recipe:
+    def duplicate_one(self, old_slug_or_id: str | UUID, dup_data: RecipeDuplicate) -> Recipe:
         """Duplicates a recipe and returns the new recipe."""
 
-        old_recipe = self._get_recipe(old_slug)
+        old_recipe = self.get_one(old_slug_or_id)
         new_recipe_data = old_recipe.model_dump(exclude={"id", "name", "slug", "image", "comments"}, round_trip=True)
         new_recipe = Recipe.model_validate(new_recipe_data)
 
@@ -356,7 +360,7 @@ def copy_recipe_ingredient(ingredient: RecipeIngredient):
 
         return new_recipe
 
-    def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
+    def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe:
         """
         gets the recipe from the database and performs a check to see if the user can update the recipe.
         If the user can't update the recipe, an exception is raised.
@@ -367,14 +371,14 @@ def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
             - _if_ the user is locking the recipe, that they can lock the recipe (user is the owner)
 
         Args:
-            slug (str): recipe slug
+            slug_or_id (str | UUID): recipe slug or id
             new_data (Recipe): the new recipe data
 
         Raises:
             exceptions.PermissionDenied (403)
         """
 
-        recipe = self._get_recipe(slug)
+        recipe = self.get_one(slug_or_id)
 
         if recipe is None or recipe.settings is None:
             raise exceptions.NoEntryFound("Recipe not found.")
@@ -388,38 +392,35 @@ def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
 
         return recipe
 
-    def update_one(self, slug: str, update_data: Recipe) -> Recipe:
-        recipe = self._pre_update_check(slug, update_data)
+    def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe:
+        recipe = self._pre_update_check(slug_or_id, update_data)
 
-        new_data = self.repos.recipes.update(slug, update_data)
+        new_data = self.group_recipes.update(recipe.slug, update_data)
         self.check_assets(new_data, recipe.slug)
         return new_data
 
-    def patch_one(self, slug: str, patch_data: Recipe) -> Recipe:
-        recipe: Recipe | None = self._pre_update_check(slug, patch_data)
-        recipe = self._get_recipe(slug)
-
-        if recipe is None:
-            raise exceptions.NoEntryFound("Recipe not found.")
+    def patch_one(self, slug_or_id: str | UUID, patch_data: Recipe) -> Recipe:
+        recipe: Recipe | None = self._pre_update_check(slug_or_id, patch_data)
+        recipe = self.get_one(slug_or_id)
 
-        new_data = self.repos.recipes.patch(recipe.slug, patch_data.model_dump(exclude_unset=True))
+        new_data = self.group_recipes.patch(recipe.slug, patch_data.model_dump(exclude_unset=True))
 
         self.check_assets(new_data, recipe.slug)
         return new_data
 
-    def update_last_made(self, slug: str, timestamp: datetime) -> Recipe:
+    def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe:
         # we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
         # or if the user belongs to a different household
-        recipe = self._get_recipe(slug)
+        recipe = self.get_one(slug_or_id)
         return self.group_recipes.patch(recipe.slug, {"last_made": timestamp})
 
-    def delete_one(self, slug) -> Recipe:
-        recipe = self._get_recipe(slug)
+    def delete_one(self, slug_or_id: str | UUID) -> Recipe:
+        recipe = self.get_one(slug_or_id)
 
         if not self.can_update(recipe):
             raise exceptions.PermissionDenied("You do not have permission to delete this recipe.")
 
-        data = self.repos.recipes.delete(recipe.id, "id")
+        data = self.group_recipes.delete(recipe.id, "id")
         self.delete_assets(data)
         return data
 
diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py
index 1509195f978..08ca36f0127 100644
--- a/mealie/services/user_services/registration_service.py
+++ b/mealie/services/user_services/registration_service.py
@@ -39,6 +39,7 @@ def _create_new_user(self, group: GroupInDB, household: HouseholdInDB, new_group
             household=household,
             can_invite=new_group,
             can_manage=new_group,
+            can_manage_household=new_group,
             can_organize=new_group,
         )
 
diff --git a/tests/integration_tests/admin_tests/test_admin_household_actions.py b/tests/integration_tests/admin_tests/test_admin_household_actions.py
index 554e24f2fde..e2e46cbfb7d 100644
--- a/tests/integration_tests/admin_tests/test_admin_household_actions.py
+++ b/tests/integration_tests/admin_tests/test_admin_household_actions.py
@@ -42,6 +42,7 @@ def test_admin_update_household(api_client: TestClient, admin_user: TestUser, un
         "name": "New Name",
         "preferences": {
             "privateHousehold": random_bool(),
+            "lockRecipeEditsFromOtherHouseholds": random_bool(),
             "firstDayOfWeek": 2,
             "recipePublic": random_bool(),
             "recipeShowNutrition": random_bool(),
diff --git a/tests/integration_tests/user_group_tests/test_group_self_service.py b/tests/integration_tests/user_group_tests/test_group_self_service.py
index a1aa889d6c0..59bfacf33d9 100644
--- a/tests/integration_tests/user_group_tests/test_group_self_service.py
+++ b/tests/integration_tests/user_group_tests/test_group_self_service.py
@@ -33,16 +33,19 @@ def test_get_group_members_filtered(api_client: TestClient, unique_user: TestUse
     assert str(h2_user.user_id) in all_ids
 
 
-def test_get_households(api_client: TestClient, admin_user: TestUser):
-    households = [admin_user.repos.households.create({"name": random_string()}) for _ in range(5)]
-    response = api_client.get(api_routes.groups_households, headers=admin_user.token)
+def test_get_households(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
+    households = [
+        unfiltered_database.households.create({"name": random_string(), "group_id": unique_user.group_id})
+        for _ in range(5)
+    ]
+    response = api_client.get(api_routes.groups_households, headers=unique_user.token)
     response_ids = [item["id"] for item in response.json()]
     for household in households:
         assert str(household.id) in response_ids
 
 
-def test_get_households_filtered(unfiltered_database: AllRepositories, api_client: TestClient, admin_user: TestUser):
-    group_1_id = admin_user.group_id
+def test_get_households_filtered(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
+    group_1_id = unique_user.group_id
     group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id)
 
     group_1_households = [
@@ -54,9 +57,24 @@ def test_get_households_filtered(unfiltered_database: AllRepositories, api_clien
         for _ in range(random_int(2, 5))
     ]
 
-    response = api_client.get(api_routes.groups_households, headers=admin_user.token)
+    response = api_client.get(api_routes.groups_households, headers=unique_user.token)
     response_ids = [item["id"] for item in response.json()]
     for household in group_1_households:
         assert str(household.id) in response_ids
     for household in group_2_households:
         assert str(household.id) not in response_ids
+
+
+def test_get_household(unfiltered_database: AllRepositories, api_client: TestClient, unique_user: TestUser):
+    group_1_id = unique_user.group_id
+    group_2_id = str(unfiltered_database.groups.create({"name": random_string()}).id)
+
+    group_1_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_1_id})
+    group_2_household = unfiltered_database.households.create({"name": random_string(), "group_id": group_2_id})
+
+    response = api_client.get(api_routes.groups_households_slug(group_1_household.slug), headers=unique_user.token)
+    assert response.status_code == 200
+    assert response.json()["id"] == str(group_1_household.id)
+
+    response = api_client.get(api_routes.groups_households_slug(group_2_household.slug), headers=unique_user.token)
+    assert response.status_code == 404
diff --git a/tests/integration_tests/user_household_tests/test_household_perferences.py b/tests/integration_tests/user_household_tests/test_household_perferences.py
index fab320df86f..268216ec1bc 100644
--- a/tests/integration_tests/user_household_tests/test_household_perferences.py
+++ b/tests/integration_tests/user_household_tests/test_household_perferences.py
@@ -31,17 +31,33 @@ def test_preferences_in_household(api_client: TestClient, unique_user: TestUser)
     assert household["preferences"]["recipeShowNutrition"] in {True, False}
 
 
-def test_update_preferences(api_client: TestClient, unique_user: TestUser) -> None:
-    new_data = UpdateHouseholdPreferences(recipe_public=random_bool(), recipe_show_nutrition=random_bool())
+def test_update_preferences_no_permission(api_client: TestClient, user_tuple: list[TestUser]) -> None:
+    unique_user, other_user = user_tuple
+
+    user = other_user.repos.users.get_one(unique_user.user_id)
+    assert user
+    user.can_manage_household = False
+    other_user.repos.users.update(user.id, user)
 
+    new_data = UpdateHouseholdPreferences(recipe_public=random_bool(), recipe_show_nutrition=random_bool())
     response = api_client.put(api_routes.households_preferences, json=new_data.model_dump(), headers=unique_user.token)
+    assert response.status_code == 403
+
+
+def test_update_preferences(api_client: TestClient, user_tuple: list[TestUser]) -> None:
+    unique_user, other_user = user_tuple
 
+    user = other_user.repos.users.get_one(unique_user.user_id)
+    assert user
+    user.can_manage_household = True
+    other_user.repos.users.update(user.id, user)
+
+    new_data = UpdateHouseholdPreferences(recipe_public=random_bool(), recipe_show_nutrition=random_bool())
+    response = api_client.put(api_routes.households_preferences, json=new_data.model_dump(), headers=unique_user.token)
     assert response.status_code == 200
 
     preferences = response.json()
-
     assert preferences is not None
     assert preferences["recipePublic"] == new_data.recipe_public
     assert preferences["recipeShowNutrition"] == new_data.recipe_show_nutrition
-
     assert_ignore_keys(new_data.model_dump(by_alias=True), preferences, ["id", "householdId"])
diff --git a/tests/integration_tests/user_household_tests/test_household_permissions.py b/tests/integration_tests/user_household_tests/test_household_permissions.py
index 09ed931bcff..f467a6d7672 100644
--- a/tests/integration_tests/user_household_tests/test_household_permissions.py
+++ b/tests/integration_tests/user_household_tests/test_household_permissions.py
@@ -7,10 +7,11 @@
 from tests.utils.fixture_schemas import TestUser
 
 
-def get_permissions_payload(user_id: str, can_manage=None) -> dict:
+def get_permissions_payload(user_id: str, can_manage=None, can_manage_household=None) -> dict:
     return {
         "user_id": user_id,
         "can_manage": random_bool() if can_manage is None else can_manage,
+        "can_manage_household": random_bool(),
         "can_invite": random_bool(),
         "can_organize": random_bool(),
     }
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 0fdda91c220..a04bbb4a321 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
@@ -86,13 +86,20 @@ def test_get_one_recipe_from_another_household(
 
 
 @pytest.mark.parametrize("is_private_household", [True, False])
+@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
 @pytest.mark.parametrize("use_patch", [True, False])
-def test_prevent_updates_to_recipes_from_other_households(
-    api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool, use_patch: bool
+def test_update_recipes_in_other_households(
+    api_client: TestClient,
+    unique_user: TestUser,
+    h2_user: TestUser,
+    is_private_household: bool,
+    household_lock_recipe_edits: bool,
+    use_patch: bool,
 ):
     household = unique_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
     unique_user.repos.household_preferences.update(household.id, household.preferences)
 
     original_name = random_string()
@@ -110,23 +117,39 @@ def test_prevent_updates_to_recipes_from_other_households(
     updated_name = random_string()
     recipe["name"] = updated_name
     client_func = api_client.patch if use_patch else api_client.put
-    response = client_func(api_routes.recipes_slug(recipe["slug"]), json=recipe, headers=unique_user.token)
-    assert response.status_code == 403
+    response = client_func(api_routes.recipes_slug(recipe["id"]), json=recipe, headers=unique_user.token)
 
-    # confirm the recipe is unchanged
-    response = api_client.get(api_routes.recipes_slug(recipe["slug"]), headers=unique_user.token)
-    assert response.status_code == 200
-    updated_recipe = response.json()
-    assert updated_recipe["name"] == original_name != updated_name
+    if household_lock_recipe_edits:
+        assert response.status_code == 403
+
+        # confirm the recipe is unchanged
+        response = api_client.get(api_routes.recipes_slug(recipe["id"]), headers=unique_user.token)
+        assert response.status_code == 200
+        updated_recipe = response.json()
+        assert updated_recipe["name"] == original_name != updated_name
+    else:
+        assert response.status_code == 200
+
+        # confirm the recipe was updated
+        response = api_client.get(api_routes.recipes_slug(recipe["id"]), headers=unique_user.token)
+        assert response.status_code == 200
+        updated_recipe = response.json()
+        assert updated_recipe["name"] == updated_name != original_name
 
 
 @pytest.mark.parametrize("is_private_household", [True, False])
-def test_prevent_deletes_to_recipes_from_other_households(
-    api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
+@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
+def test_delete_recipes_from_other_households(
+    api_client: TestClient,
+    unique_user: TestUser,
+    h2_user: TestUser,
+    is_private_household: bool,
+    household_lock_recipe_edits: bool,
 ):
     household = unique_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
     unique_user.repos.household_preferences.update(household.id, household.preferences)
 
     response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
@@ -141,21 +164,34 @@ def test_prevent_deletes_to_recipes_from_other_households(
     assert recipe_json["id"] == h2_recipe_id
 
     response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token)
-    assert response.status_code == 403
+    if household_lock_recipe_edits:
+        assert response.status_code == 403
 
-    # confirm the recipe still exists
-    response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
-    assert response.status_code == 200
-    assert response.json()["id"] == h2_recipe_id
+        # confirm the recipe still exists
+        response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
+        assert response.status_code == 200
+        assert response.json()["id"] == h2_recipe_id
+    else:
+        assert response.status_code == 200
+
+        # confirm the recipe was deleted
+        response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.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(
-    api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
+    api_client: TestClient,
+    unique_user: TestUser,
+    h2_user: TestUser,
+    is_private_household: bool,
+    household_lock_recipe_edits: bool,
 ):
     household = unique_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
     unique_user.repos.household_preferences.update(household.id, household.preferences)
 
     response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py
index 6f2bc33c0c9..1ecda6178ea 100644
--- a/tests/utils/api_routes/__init__.py
+++ b/tests/utils/api_routes/__init__.py
@@ -292,6 +292,11 @@ def foods_item_id(item_id):
     return f"{prefix}/foods/{item_id}"
 
 
+def groups_households_slug(slug):
+    """`/api/groups/households/{slug}`"""
+    return f"{prefix}/groups/households/{slug}"
+
+
 def groups_labels_item_id(item_id):
     """`/api/groups/labels/{item_id}`"""
     return f"{prefix}/groups/labels/{item_id}"


Code review comments:
diff_hunk: @@ -64,12 +64,14 @@
 <script lang="ts">
 import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
 import { useLoggedInState } from "~/composables/use-logged-in-state";
+import { useUserApi } from "~/composables/api";
 import { useRecipePermissions } from "~/composables/recipes";
 import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
 import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
 import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
 import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
 import { useStaticRoutes } from "~/composables/api";
path: frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue
text: @user1:
```suggestion
import { useRecipePermissions } from "~/composables/recipes";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes, useUserApi  } from "~/composables/api";
```

Dupicate Import from "~/composables/api"

diff_hunk: @@ -241,13 +241,14 @@
     "manage-members": "Manage Members",
     "manage-members-description": "Manage the permissions of the members in your household. {manage} allows the user to access the data-management page, and {invite} allows the user to generate invitation links for other users. Group owners cannot change their own permissions.",
     "manage": "Manage",
+    "manage-household": "Manage Household",
     "invite": "Invite",
     "looking-to-update-your-profile": "Looking to Update Your Profile?",
     "default-recipe-preferences-description": "These are the default settings when a new recipe is created in your group. These can be changed for individual recipes in the recipe settings menu.",
     "default-recipe-preferences": "Default Recipe Preferences",
     "group-preferences": "Group Preferences",
     "private-group": "Private Group",
-    "private-group-description": "Setting your group to private will default all public view options to default. This overrides any individual households or recipes public view settings.",
+    "private-group-description": "Setting your group to private will default all public view options to default. This overrides any individual households or recipes public view settings",
path: frontend/lang/messages/en-US.json
text: @user1:
```suggestion
    "private-group-description": "Setting your group to private will default all public view options to private. This overrides any individual households or recipes public view settings",
```

Aside from the error that it defaults to default this is a bit confusing in general. Is it defaulting to private and gets overwritten by individual recipe settings. Or does it set all recipes to private and it cannot be overwritten by individual settings. 

@author:
I changed this to:
> Setting your group to private will disable all public view options. This overrides any individual public view settings

Which I think works better. There's also the household variant:
> Setting your household to private will disable all public view options. This overrides any individual public view settings


@user1:
Works for me 👍