mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-03-12 04:35:35 -07:00
feat: Groups/households custom invitations (#4252)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
parent
7ada42a791
commit
622c1b11f5
frontend
components
lang/messages
pages
mealie
tests/integration_tests/user_household_tests
215
frontend/components/Domain/User/UserInviteDialog.vue
Normal file
215
frontend/components/Domain/User/UserInviteDialog.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="inviteDialog"
|
||||
:title="$tc('profile.get-invite-link')"
|
||||
:icon="$globals.icons.accountPlusOutline"
|
||||
color="primary">
|
||||
<v-container>
|
||||
<v-form class="mt-5">
|
||||
<v-select
|
||||
v-if="groups && groups.length"
|
||||
v-model="selectedGroup"
|
||||
:items="groups"
|
||||
item-text="name"
|
||||
item-value="id"
|
||||
:return-object="false"
|
||||
filled
|
||||
:label="$tc('group.user-group')"
|
||||
:rules="[validators.required]" />
|
||||
<v-select
|
||||
v-if="households && households.length"
|
||||
v-model="selectedHousehold"
|
||||
:items="filteredHouseholds"
|
||||
item-text="name" item-value="id"
|
||||
:return-object="false" filled
|
||||
:label="$tc('household.user-household')"
|
||||
:rules="[validators.required]" />
|
||||
<v-row>
|
||||
<v-col cols="9">
|
||||
<v-text-field
|
||||
:label="$tc('profile.invite-link')"
|
||||
type="text" readonly filled
|
||||
:value="generatedSignupLink" />
|
||||
</v-col>
|
||||
<v-col cols="3" class="pl-1 mt-3">
|
||||
<AppButtonCopy
|
||||
:icon="false"
|
||||
color="info"
|
||||
:copy-text="generatedSignupLink"
|
||||
:disabled="generatedSignupLink" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-text-field
|
||||
v-model="sendTo"
|
||||
:label="$t('user.email')"
|
||||
:rules="[validators.email]"
|
||||
outlined
|
||||
@keydown.enter="sendInvite" />
|
||||
</v-form>
|
||||
</v-container>
|
||||
<template #custom-card-action>
|
||||
<BaseButton
|
||||
:disabled="!validEmail"
|
||||
:loading="loading"
|
||||
:icon="$globals.icons.email"
|
||||
@click="sendInvite">
|
||||
{{ $t("group.invite") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||
import { watchEffect } from "vue";
|
||||
import { useUserApi } from "@/composables/api";
|
||||
import BaseDialog from "~/components/global/BaseDialog.vue";
|
||||
import AppButtonCopy from "~/components/global/AppButtonCopy.vue";
|
||||
import BaseButton from "~/components/global/BaseButton.vue";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { GroupInDB } from "~/lib/api/types/user";
|
||||
import { HouseholdInDB } from "~/lib/api/types/household";
|
||||
import { useGroups } from "~/composables/use-groups";
|
||||
import { useAdminHouseholds } from "~/composables/use-households";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UserInviteDialog",
|
||||
components: {
|
||||
BaseDialog,
|
||||
AppButtonCopy,
|
||||
BaseButton,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $auth, i18n } = useContext();
|
||||
|
||||
const isAdmin = computed(() => $auth.user?.admin);
|
||||
const token = ref("");
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const selectedHousehold = ref<string | null>(null);
|
||||
const groups = ref<GroupInDB[]>([]);
|
||||
const households = ref<HouseholdInDB[]>([]);
|
||||
const api = useUserApi();
|
||||
|
||||
const fetchGroupsAndHouseholds = () => {
|
||||
if (isAdmin) {
|
||||
const groupsResponse = useGroups();
|
||||
const householdsResponse = useAdminHouseholds();
|
||||
watchEffect(() => {
|
||||
groups.value = groupsResponse.groups.value || [];
|
||||
households.value = householdsResponse.households.value || [];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const inviteDialog = computed<boolean>({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("input", val);
|
||||
},
|
||||
});
|
||||
|
||||
async function getSignupLink(group: string | null = null, household: string | null = null) {
|
||||
const payload = (group && household) ? { uses: 1, group_id: group, household_id: household } : { uses: 1 };
|
||||
const { data } = await api.households.createInvitation(payload);
|
||||
if (data) {
|
||||
token.value = data.token;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredHouseholds = computed(() => {
|
||||
if (!selectedGroup.value) return [];
|
||||
return households.value?.filter(household => household.groupId === selectedGroup.value);
|
||||
});
|
||||
|
||||
function constructLink(token: string) {
|
||||
return token ? `${window.location.origin}/register?token=${token}` : "";
|
||||
}
|
||||
|
||||
const generatedSignupLink = computed(() => {
|
||||
return constructLink(token.value);
|
||||
});
|
||||
|
||||
// =================================================
|
||||
// Email Invitation
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
sendTo: "",
|
||||
});
|
||||
|
||||
async function sendInvite() {
|
||||
state.loading = true;
|
||||
if (!token.value) {
|
||||
getSignupLink(selectedGroup.value, selectedHousehold.value);
|
||||
}
|
||||
const { data } = await api.email.sendInvitation({
|
||||
email: state.sendTo,
|
||||
token: token.value,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
alert.success(i18n.tc("profile.email-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("profile.error-sending-email"));
|
||||
}
|
||||
state.loading = false;
|
||||
inviteDialog.value = false;
|
||||
}
|
||||
|
||||
const validEmail = computed(() => {
|
||||
if (state.sendTo === "") {
|
||||
return false;
|
||||
}
|
||||
const valid = validators.email(state.sendTo);
|
||||
|
||||
// Explicit bool check because validators.email sometimes returns a string
|
||||
if (valid === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
sendInvite,
|
||||
validators,
|
||||
validEmail,
|
||||
inviteDialog,
|
||||
getSignupLink,
|
||||
generatedSignupLink,
|
||||
selectedGroup,
|
||||
selectedHousehold,
|
||||
filteredHouseholds,
|
||||
groups,
|
||||
households,
|
||||
fetchGroupsAndHouseholds,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: false,
|
||||
handler(val) {
|
||||
if (val && !this.isAdmin) {
|
||||
this.getSignupLink();
|
||||
}
|
||||
},
|
||||
},
|
||||
selectedHousehold(newVal) {
|
||||
if (newVal && this.selectedGroup) {
|
||||
this.getSignupLink(this.selectedGroup, this.selectedHousehold);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchGroupsAndHouseholds();
|
||||
},
|
||||
});
|
||||
</script>
|
@ -15,6 +15,7 @@
|
||||
:color="color"
|
||||
retain-focus-on-click
|
||||
:class="btnClass"
|
||||
:disabled="copyText !== '' ? false : true"
|
||||
@click="
|
||||
on.click;
|
||||
textToClipboard();
|
||||
|
@ -1279,6 +1279,7 @@
|
||||
"profile": {
|
||||
"welcome-user": "👋 Welcome, {0}!",
|
||||
"description": "Manage your profile, recipes, and group settings.",
|
||||
"invite-link": "Invite Link",
|
||||
"get-invite-link": "Get Invite Link",
|
||||
"get-public-link": "Get Public Link",
|
||||
"account-summary": "Account Summary",
|
||||
|
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<UserInviteDialog v-model="inviteDialog" />
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$tc('general.confirm')"
|
||||
@ -22,6 +23,9 @@
|
||||
<BaseButton to="/admin/manage/users/create" class="mr-2">
|
||||
{{ $t("general.create") }}
|
||||
</BaseButton>
|
||||
<BaseButton class="mr-2" color="info" :icon="$globals.icons.link" @click="inviteDialog = true">
|
||||
{{ $t("group.invite") }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseOverflowButton mode="event" :items="ACTIONS_OPTIONS" @unlock-all-users="unlockAllUsers">
|
||||
</BaseOverflowButton>
|
||||
@ -69,12 +73,17 @@ import { useAdminApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useUser, useAllUsers } from "~/composables/use-user";
|
||||
import { UserOut } from "~/lib/api/types/user";
|
||||
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
UserInviteDialog,
|
||||
},
|
||||
layout: "admin",
|
||||
setup() {
|
||||
const api = useAdminApi();
|
||||
const refUserDialog = ref();
|
||||
const inviteDialog = ref();
|
||||
const { $auth } = useContext();
|
||||
|
||||
const user = computed(() => $auth.user);
|
||||
@ -99,6 +108,9 @@ export default defineComponent({
|
||||
deleteDialog: false,
|
||||
deleteTargetId: "",
|
||||
search: "",
|
||||
groups: [],
|
||||
households: [],
|
||||
sendTo: "",
|
||||
});
|
||||
|
||||
const { users, refreshAllUsers } = useAllUsers();
|
||||
@ -154,6 +166,7 @@ export default defineComponent({
|
||||
deleteUser,
|
||||
loading,
|
||||
refUserDialog,
|
||||
inviteDialog,
|
||||
users,
|
||||
user,
|
||||
handleRowClick,
|
||||
|
@ -9,44 +9,14 @@
|
||||
</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="user.canInvite" outlined rounded @click="getSignupLink()">
|
||||
<v-btn v-if="user.canInvite" outlined rounded @click="inviteDialog = true">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
{{ $t('profile.get-invite-link') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<div v-show="generatedSignupLink !== ''">
|
||||
<v-card-text>
|
||||
<p class="text-center pb-0">
|
||||
{{ generatedSignupLink }}
|
||||
</p>
|
||||
<v-text-field v-model="sendTo" :label="$t('user.email')" :rules="[validators.email]"> </v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 align-center" style="gap: 4px">
|
||||
<BaseButton cancel @click="generatedSignupLink = ''"> {{ $t("general.close") }} </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<AppButtonCopy :icon="false" color="info" :copy-text="generatedSignupLink" />
|
||||
<BaseButton color="info" :disabled="!validEmail" :loading="loading" @click="sendInvite">
|
||||
<template #icon>
|
||||
{{ $globals.icons.email }}
|
||||
</template>
|
||||
{{ $t("user.email") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
<div v-show="showPublicLink">
|
||||
<v-card-text>
|
||||
<p class="text-center pb-0">
|
||||
{{ publicLink }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 align-center" style="gap: 4px">
|
||||
<BaseButton cancel @click="showPublicLink = false"> {{ $t("general.close") }} </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<AppButtonCopy :icon="false" color="info" :copy-text="publicLink" />
|
||||
</v-card-actions>
|
||||
</div>
|
||||
<UserInviteDialog v-model="inviteDialog" />
|
||||
</v-card>
|
||||
</section>
|
||||
<section class="my-3">
|
||||
@ -206,19 +176,19 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, ref, toRefs, reactive, useAsync, useRoute } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, useContext, ref, useAsync, useRoute } from "@nuxtjs/composition-api";
|
||||
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import UserAvatar from "@/components/Domain/User/UserAvatar.vue";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
import StatsCards from "~/components/global/StatsCards.vue";
|
||||
import { UserOut } from "~/lib/api/types/user";
|
||||
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UserProfile",
|
||||
components: {
|
||||
UserInviteDialog,
|
||||
UserProfileLinkCard,
|
||||
UserAvatar,
|
||||
StatsCards,
|
||||
@ -233,61 +203,9 @@ export default defineComponent({
|
||||
// @ts-ignore $auth.user is typed as unknown, but it's a user
|
||||
const user = computed<UserOut | null>(() => $auth.user);
|
||||
|
||||
const showPublicLink = ref(false);
|
||||
const publicLink = ref("");
|
||||
|
||||
const generatedSignupLink = ref("");
|
||||
const token = ref("");
|
||||
const inviteDialog = ref(false);
|
||||
const api = useUserApi();
|
||||
|
||||
async function getSignupLink() {
|
||||
const { data } = await api.households.createInvitation({ uses: 1 });
|
||||
if (data) {
|
||||
token.value = data.token;
|
||||
generatedSignupLink.value = constructLink(data.token);
|
||||
showPublicLink.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function constructLink(token: string) {
|
||||
return `${window.location.origin}/register?token=${token}`;
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// Email Invitation
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
sendTo: "",
|
||||
});
|
||||
|
||||
async function sendInvite() {
|
||||
state.loading = true;
|
||||
const { data } = await api.email.sendInvitation({
|
||||
email: state.sendTo,
|
||||
token: token.value,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
alert.success(i18n.tc("profile.email-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("profile.error-sending-email"));
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
const validEmail = computed(() => {
|
||||
if (state.sendTo === "") {
|
||||
return false;
|
||||
}
|
||||
const valid = validators.email(state.sendTo);
|
||||
|
||||
// Explicit bool check because validators.email sometimes returns a string
|
||||
if (valid === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const stats = useAsync(async () => {
|
||||
const { data } = await api.households.statistics();
|
||||
|
||||
@ -321,13 +239,15 @@ export default defineComponent({
|
||||
return iconText[key] ?? $globals.icons.primary;
|
||||
}
|
||||
|
||||
const statsTo = computed<{ [key: string]: string }>(() => { return {
|
||||
totalRecipes: `/g/${groupSlug.value}/`,
|
||||
totalUsers: "/household/members",
|
||||
totalCategories: `/g/${groupSlug.value}/recipes/categories`,
|
||||
totalTags: `/g/${groupSlug.value}/recipes/tags`,
|
||||
totalTools: `/g/${groupSlug.value}/recipes/tools`,
|
||||
}});
|
||||
const statsTo = computed<{ [key: string]: string }>(() => {
|
||||
return {
|
||||
totalRecipes: `/g/${groupSlug.value}/`,
|
||||
totalUsers: "/household/members",
|
||||
totalCategories: `/g/${groupSlug.value}/recipes/categories`,
|
||||
totalTags: `/g/${groupSlug.value}/recipes/tags`,
|
||||
totalTools: `/g/${groupSlug.value}/recipes/tools`,
|
||||
}
|
||||
});
|
||||
|
||||
function getStatsTo(key: string) {
|
||||
return statsTo.value[key] ?? "unknown";
|
||||
@ -338,17 +258,9 @@ export default defineComponent({
|
||||
getStatsTitle,
|
||||
getStatsIcon,
|
||||
getStatsTo,
|
||||
inviteDialog,
|
||||
stats,
|
||||
user,
|
||||
constructLink,
|
||||
generatedSignupLink,
|
||||
showPublicLink,
|
||||
publicLink,
|
||||
getSignupLink,
|
||||
sendInvite,
|
||||
validators,
|
||||
validEmail,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
@ -24,15 +24,24 @@ class GroupInvitationsController(BaseUserController):
|
||||
return self.repos.group_invite_tokens.page_all(PaginationQuery(page=1, per_page=-1)).items
|
||||
|
||||
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
|
||||
def create_invite_token(self, uses: CreateInviteToken):
|
||||
def create_invite_token(self, body: CreateInviteToken):
|
||||
if not self.user.can_invite:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail="User is not allowed to create invite tokens",
|
||||
)
|
||||
|
||||
body.group_id = body.group_id or self.group_id
|
||||
body.household_id = body.household_id or self.household_id
|
||||
|
||||
if not self.user.admin and (body.group_id != self.group_id or body.household_id != self.household_id):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can create invite tokens for other groups or households",
|
||||
)
|
||||
|
||||
token = SaveInviteToken(
|
||||
uses_left=uses.uses, group_id=self.group_id, household_id=self.household_id, token=url_safe_token()
|
||||
uses_left=body.uses, group_id=body.group_id, household_id=body.household_id, token=url_safe_token()
|
||||
)
|
||||
return self.repos.group_invite_tokens.create(token)
|
||||
|
||||
|
@ -7,6 +7,8 @@ from mealie.schema._mealie import MealieModel
|
||||
|
||||
class CreateInviteToken(MealieModel):
|
||||
uses: int
|
||||
group_id: UUID | None = None
|
||||
household_id: UUID | None = None
|
||||
|
||||
|
||||
class SaveInviteToken(MealieModel):
|
||||
|
@ -1,3 +1,5 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@ -31,6 +33,21 @@ def test_get_all_invitation(api_client: TestClient, unique_user: TestUser, invit
|
||||
assert item["token"] == invite
|
||||
|
||||
|
||||
def test_create_invitation(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# Create invitation for the same group as user
|
||||
r = api_client.post(api_routes.households_invitations, json={"uses": 1}, headers=unique_user.token)
|
||||
assert r.status_code == 201
|
||||
|
||||
# Create invitation for other group as user
|
||||
body = {
|
||||
"uses": 1,
|
||||
"groupId": str(uuid4()),
|
||||
"householdId": str(uuid4()),
|
||||
}
|
||||
r = api_client.post(api_routes.households_invitations, json=body, headers=unique_user.token)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def register_user(api_client: TestClient, invite: str):
|
||||
# Test User can Join Group
|
||||
registration = user_registration_factory()
|
||||
|
Loading…
x
Reference in New Issue
Block a user