feat: Groups/households custom invitations ()

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
Arsène Reymond 2024-11-12 04:30:08 +01:00 committed by GitHub
parent 7ada42a791
commit 622c1b11f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 276 additions and 106 deletions
frontend
components
lang/messages
pages
admin/manage/users
user/profile
mealie
routes/households
schema/household
tests/integration_tests/user_household_tests

@ -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()