1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 15:49:42 +02:00

feat: Additional Household Permissions (#4158)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-09-17 10:48:14 -05:00 committed by GitHub
parent b1820f9b23
commit fd0257c1b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 690 additions and 185 deletions

View file

@ -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("group.allow-users-outside-of-your-group-to-see-your-recipes"),
description: i18n.tc("group.allow-users-outside-of-your-group-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>

View file

@ -50,7 +50,7 @@
:recipe-id="recipeId"
:use-items="{
delete: false,
edit: true,
edit: false,
download: true,
mealplanner: true,
shoppingList: true,

View file

@ -62,7 +62,7 @@
:recipe-id="recipeId"
:use-items="{
delete: false,
edit: true,
edit: false,
download: true,
mealplanner: true,
shoppingList: true,

View file

@ -69,7 +69,8 @@ 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 { useStaticRoutes, useUserApi } 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 +101,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();

View file

@ -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", () => {
const result = useRecipePermissions(
createRecipe({}),
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, 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({}),
createRecipeHousehold({}),
createUser({ id: "other-user-id", groupId: "other-group-id"}),
);
expect(result.canEditRecipe.value).toBe(false);
});
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 unlocked, can edit", () => {
const result = useRecipePermissions(
createRecipe({}),
createRecipeHousehold({}),
createUser({ id: "other-user-id", householdId: "other-household-id" }),
);
expect(result.canEditRecipe.value).toBe(true);
});
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);
});
});

View file

@ -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;
}
// Check group and household
if (user.groupId !== recipe.groupId) {
return false;
}
if (user.householdId !== recipe.householdId) {
return false;
}
// Check recipe
if (recipe.settings?.locked) {
return false;
}
return true;
});
return {
canEditRecipe,
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) {
if (!recipeHousehold.value?.preferences) {
return false;
}
if (recipeHousehold.value?.preferences.lockRecipeEditsFromOtherHouseholds) {
return false;
}
}
// Check recipe
if (recipe.settings?.locked) {
return false;
}
return true;
});
return {
canEditRecipe,
}
}

View file

@ -36,6 +36,8 @@ export const useHouseholdSelf = function () {
if (data) {
householdSelfRef.value.preferences = data;
}
return data || undefined;
},
};

View file

@ -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",

View file

@ -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 disable all public view options. This overrides any individual 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 disable all public view options. This overrides any individual 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.",

View file

@ -16,6 +16,7 @@ export interface AdminAboutInfo {
oidcRedirect: boolean;
oidcProviderName: string;
enableOpenai: boolean;
enableOpenaiImageServices: boolean;
versionLatest: string;
apiPort: number;
apiDocs: boolean;

View file

@ -15,6 +15,7 @@ export interface CreateGroupRecipeAction {
}
export interface CreateHouseholdPreferences {
privateHousehold?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
recipeShowNutrition?: boolean;
@ -185,6 +186,7 @@ export interface HouseholdInDB {
}
export interface ReadHouseholdPreferences {
privateHousehold?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
recipeShowNutrition?: boolean;
@ -241,6 +243,7 @@ export interface SaveGroupRecipeAction {
}
export interface SaveHouseholdPreferences {
privateHousehold?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
recipeShowNutrition?: boolean;
@ -267,6 +270,7 @@ export interface SaveWebhook {
}
export interface SetPermissions {
userId: string;
canManageHousehold?: boolean;
canManage?: boolean;
canInvite?: boolean;
canOrganize?: boolean;
@ -649,6 +653,7 @@ export interface UpdateHouseholdAdmin {
}
export interface UpdateHouseholdPreferences {
privateHousehold?: boolean;
lockRecipeEditsFromOtherHouseholds?: boolean;
firstDayOfWeek?: number;
recipePublic?: boolean;
recipeShowNutrition?: boolean;

View file

@ -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 {}

View file

@ -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;

View file

@ -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);
}

View file

@ -0,0 +1,11 @@
interface CanManageRedirectParams {
$auth: any
redirect: (path: string) => void
}
export default function ({ $auth, redirect }: CanManageRedirectParams) {
// If the user is not allowed to manage group settings redirect to the home page
if (!$auth.user?.canManageHousehold) {
console.warn("User is not allowed to manage household settings");
return redirect("/");
}
}

View file

@ -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"));
}

View file

@ -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>
<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>
<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>
</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({
middleware: ["auth", "can-manage-only"],
components: {
HouseholdPreferencesEditor,
},
middleware: ["auth", "can-manage-household-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() {

View file

@ -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,
};

View file

@ -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')"