feat(frontend): add group permissions (#721)

* style(frontend): 💄 add darktheme custom

* add dummy users in dev mode

* feat(frontend):  add group permissions editor UI

* feat(backend):  add group permissions setters

* test(backend):  tests for basic permission get/set (WIP)

Needs more testing

* remove old test

* chore(backend): copy template.env on setup

* feat(frontend):  enable send invitation via email

* feat(backend):  enable send invitation via email

* feat:  add app config checker for site-settings

* refactor(frontend): ♻️ consolidate bool checks

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-10-04 20:16:37 -08:00 committed by GitHub
parent b7b8aa9a08
commit 5d43fac7c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 652 additions and 106 deletions

View File

@ -0,0 +1,26 @@
import json
from pathlib import Path
import requests
CWD = Path(__file__).parent
def login(username="changeme@email.com", password="MyPassword"):
payload = {"username": username, "password": password}
r = requests.post("http://localhost:9000/api/auth/token", payload)
# Bearer
token = json.loads(r.text).get("access_token")
return {"Authorization": f"Bearer {token}"}
def main():
print("Starting...")
print("Finished...")
if __name__ == "__main__":
main()

View File

@ -5,6 +5,7 @@ const prefix = "/api";
const routes = { const routes = {
about: `${prefix}/admin/about`, about: `${prefix}/admin/about`,
aboutStatistics: `${prefix}/admin/about/statistics`, aboutStatistics: `${prefix}/admin/about/statistics`,
check: `${prefix}/admin/about/check`,
}; };
export interface AdminAboutInfo { export interface AdminAboutInfo {
@ -26,6 +27,11 @@ export interface AdminStatistics {
untaggedRecipes: number; untaggedRecipes: number;
} }
export interface CheckAppConfig {
emailReady: boolean;
baseUrlSet: boolean;
}
export class AdminAboutAPI extends BaseAPI { export class AdminAboutAPI extends BaseAPI {
async about() { async about() {
return await this.requests.get<AdminAboutInfo>(routes.about); return await this.requests.get<AdminAboutInfo>(routes.about);
@ -34,4 +40,8 @@ export class AdminAboutAPI extends BaseAPI {
async statistics() { async statistics() {
return await this.requests.get(routes.aboutStatistics); return await this.requests.get(routes.aboutStatistics);
} }
async checkApp() {
return await this.requests.get<CheckAppConfig>(routes.check);
}
} }

View File

@ -2,6 +2,8 @@ import { BaseAPI } from "./_base";
const routes = { const routes = {
base: "/api/admin/email", base: "/api/admin/email",
invitation: "/api/groups/invitations/email",
}; };
export interface CheckEmailResponse { export interface CheckEmailResponse {
@ -17,6 +19,16 @@ export interface TestEmailPayload {
email: string; email: string;
} }
export interface InvitationEmail {
email: string;
token: string;
}
export interface InvitationEmailResponse {
success: boolean;
error: string;
}
export class EmailAPI extends BaseAPI { export class EmailAPI extends BaseAPI {
check() { check() {
return this.requests.get<CheckEmailResponse>(routes.base); return this.requests.get<CheckEmailResponse>(routes.base);
@ -25,4 +37,8 @@ export class EmailAPI extends BaseAPI {
test(payload: TestEmailPayload) { test(payload: TestEmailPayload) {
return this.requests.post<TestEmailResponse>(routes.base, payload); return this.requests.post<TestEmailResponse>(routes.base, payload);
} }
sendInvitation(payload: InvitationEmail) {
return this.requests.post<InvitationEmailResponse>(routes.invitation, payload);
}
} }

View File

@ -1,5 +1,5 @@
import { BaseCRUDAPI } from "./_base"; import { BaseCRUDAPI } from "./_base";
import { GroupInDB } from "~/types/api-types/user"; import { GroupInDB, UserOut } from "~/types/api-types/user";
const prefix = "/api"; const prefix = "/api";
@ -7,6 +7,8 @@ const routes = {
groups: `${prefix}/admin/groups`, groups: `${prefix}/admin/groups`,
groupsSelf: `${prefix}/groups/self`, groupsSelf: `${prefix}/groups/self`,
categories: `${prefix}/groups/categories`, categories: `${prefix}/groups/categories`,
members: `${prefix}/groups/members`,
permissions: `${prefix}/groups/permissions`,
preferences: `${prefix}/groups/preferences`, preferences: `${prefix}/groups/preferences`,
@ -56,6 +58,13 @@ export interface Invitation {
uses_left: number; uses_left: number;
} }
export interface SetPermissions {
userId: number;
canInvite: boolean;
canManage: boolean;
canOrganize: boolean;
}
export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> { export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
baseRoute = routes.groups; baseRoute = routes.groups;
itemRoute = routes.groupsId; itemRoute = routes.groupsId;
@ -84,4 +93,12 @@ export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
async createInvitation(payload: CreateInvitation) { async createInvitation(payload: CreateInvitation) {
return await this.requests.post<Invitation>(routes.invitation, payload); return await this.requests.post<Invitation>(routes.invitation, payload);
} }
async fetchMembers() {
return await this.requests.get<UserOut[]>(routes.members);
}
async setMemberPermissions(payload: SetPermissions) {
return await this.requests.put<UserOut>(routes.permissions, payload);
}
} }

View File

@ -10,3 +10,21 @@
.narrow-container { .narrow-container {
max-width: 700px !important; max-width: 700px !important;
} }
.theme--dark.v-application {
background-color: var(--v-background-base, #121212) !important;
}
.theme--dark.v-navigation-drawer {
background-color: var(--v-background-base, #121212) !important;
}
/* 1E1E1E */
.theme--dark.v-card {
background-color: #2b2b2b !important;
}
.theme--light.v-application {
background-color: var(--v-background-base, white) !important;
}

View File

@ -35,7 +35,7 @@
<v-icon left>{{ $globals.icons.logout }}</v-icon> <v-icon left>{{ $globals.icons.logout }}</v-icon>
{{ $t("user.logout") }} {{ $t("user.logout") }}
</v-btn> </v-btn>
<v-btn v-else text nuxt to="/user/login"> <v-btn v-else text nuxt to="/login">
<v-icon left>{{ $globals.icons.user }}</v-icon> <v-icon left>{{ $globals.icons.user }}</v-icon>
{{ $t("user.login") }} {{ $t("user.login") }}
</v-btn> </v-btn>

View File

@ -121,6 +121,7 @@
</v-list-item> </v-list-item>
</template> </template>
</v-list-item-group> </v-list-item-group>
<slot name="bottom"></slot>
</v-list> </v-list>
</template> </template>
</v-navigation-drawer> </v-navigation-drawer>

View File

@ -11,7 +11,7 @@
> >
<template #activator="{ on }"> <template #activator="{ on }">
<v-btn <v-btn
icon :icon="icon"
:color="color" :color="color"
retain-focus-on-click retain-focus-on-click
@click=" @click="
@ -21,6 +21,7 @@
@blur="on.blur" @blur="on.blur"
> >
<v-icon>{{ $globals.icons.contentCopy }}</v-icon> <v-icon>{{ $globals.icons.contentCopy }}</v-icon>
{{ icon ? "" : "Copy" }}
</v-btn> </v-btn>
</template> </template>
<span> <span>
@ -43,6 +44,10 @@ export default {
type: String, type: String,
default: "primary", default: "primary",
}, },
icon: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {

View File

@ -1,5 +1,5 @@
<template> <template>
<v-card flat class="pb-2"> <v-card color="background" flat class="pb-2">
<v-card-title class="headline py-0"> <v-card-title class="headline py-0">
<v-icon v-if="icon !== ''" left> <v-icon v-if="icon !== ''" left>
{{ icon }} {{ icon }}

View File

@ -1,6 +1,6 @@
<template> <template>
<v-app dark> <v-app dark>
<!-- <TheSnackbar /> --> <TheSnackbar />
<AppSidebar <AppSidebar
v-model="sidebar" v-model="sidebar"
@ -35,6 +35,16 @@
</template> </template>
</v-list> </v-list>
</v-menu> </v-menu>
<template #bottom>
<v-list-item @click="toggleDark">
<v-list-item-icon>
<v-icon>
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
</v-icon>
</v-list-item-icon>
<v-list-item-title> {{ $vuetify.theme.dark ? "Light Mode" : "Dark Mode" }} </v-list-item-title>
</v-list-item>
</template>
</AppSidebar> </AppSidebar>
<AppHeader> <AppHeader>
@ -55,19 +65,25 @@
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import AppHeader from "@/components/Layout/AppHeader.vue"; import AppHeader from "@/components/Layout/AppHeader.vue";
import AppSidebar from "@/components/Layout/AppSidebar.vue"; import AppSidebar from "@/components/Layout/AppSidebar.vue";
import TheSnackbar from "@/components/Layout/TheSnackbar.vue";
import { useCookbooks } from "~/composables/use-group-cookbooks"; import { useCookbooks } from "~/composables/use-group-cookbooks";
export default defineComponent({ export default defineComponent({
components: { AppHeader, AppSidebar }, components: { AppHeader, AppSidebar, TheSnackbar },
// @ts-ignore // @ts-ignore
middleware: "auth", middleware: "auth",
setup() { setup() {
const { cookbooks } = useCookbooks(); const { cookbooks } = useCookbooks();
// @ts-ignore // @ts-ignore
const { $globals, $auth } = useContext(); const { $globals, $auth, $vuetify } = useContext();
const isAdmin = computed(() => $auth.user?.admin); const isAdmin = computed(() => $auth.user?.admin);
function toggleDark() {
$vuetify.theme.dark = !$vuetify.theme.dark;
console.log("toggleDark");
}
const cookbookLinks = computed(() => { const cookbookLinks = computed(() => {
if (!cookbooks.value) return []; if (!cookbooks.value) return [];
return cookbooks.value.map((cookbook) => { return cookbooks.value.map((cookbook) => {
@ -78,7 +94,7 @@ export default defineComponent({
}; };
}); });
}); });
return { cookbookLinks, isAdmin }; return { cookbookLinks, isAdmin, toggleDark };
}, },
data() { data() {
return { return {

View File

@ -201,10 +201,16 @@ export default {
publicRuntimeConfig: { publicRuntimeConfig: {
GLOBAL_MIDDLEWARE: process.env.GLOBAL_MIDDLEWARE || null, GLOBAL_MIDDLEWARE: process.env.GLOBAL_MIDDLEWARE || null,
ALLOW_SIGNUP: process.env.ALLOW_SIGNUP || true, ALLOW_SIGNUP: process.env.ALLOW_SIGNUP || true,
envProps: {
allowSignup: process.env.ALLOW_SIGNUP || true,
},
SUB_PATH: process.env.SUB_PATH || "", SUB_PATH: process.env.SUB_PATH || "",
axios: { axios: {
browserBaseURL: process.env.SUB_PATH || "", browserBaseURL: process.env.SUB_PATH || "",
}, },
// ==============================================
// Theme Runtime Config
useDark: process.env.THEME_USE_DARK || false,
themes: { themes: {
dark: { dark: {
primary: process.env.THEME_DARK_PRIMARY || "#E58325", primary: process.env.THEME_DARK_PRIMARY || "#E58325",
@ -214,6 +220,7 @@ export default {
info: process.env.THEME_DARK_INFO || "#1976d2", info: process.env.THEME_DARK_INFO || "#1976d2",
warning: process.env.THEME_DARK_WARNING || "#FF6D00", warning: process.env.THEME_DARK_WARNING || "#FF6D00",
error: process.env.THEME_DARK_ERROR || "#EF5350", error: process.env.THEME_DARK_ERROR || "#EF5350",
background: "#202021",
}, },
light: { light: {
primary: process.env.THEME_LIGHT_PRIMARY || "#007A99", primary: process.env.THEME_LIGHT_PRIMARY || "#007A99",

View File

@ -34,7 +34,7 @@
<v-divider></v-divider> <v-divider></v-divider>
</BaseDialog> </BaseDialog>
<v-toolbar flat class="justify-between"> <v-toolbar flat color="background" class="justify-between">
<BaseButton class="mr-2" @click="createBackup(null)" /> <BaseButton class="mr-2" @click="createBackup(null)" />
<!-- Backup Creation Dialog --> <!-- Backup Creation Dialog -->
<BaseDialog <BaseDialog

View File

@ -1,5 +1,3 @@
// TODO: Possibly add confirmation dialog? I'm not sure that it's really requried for events...
<template> <template>
<v-container v-if="statistics" class="mt-10"> <v-container v-if="statistics" class="mt-10">
<v-row v-if="statistics"> <v-row v-if="statistics">

View File

@ -3,7 +3,7 @@
<v-container fluid> <v-container fluid>
<BaseCardSectionTitle title="Group Management"> </BaseCardSectionTitle> <BaseCardSectionTitle title="Group Management"> </BaseCardSectionTitle>
<section> <section>
<v-toolbar flat class="justify-between"> <v-toolbar flat color="background" class="justify-between">
<BaseDialog <BaseDialog
ref="refUserDialog" ref="refUserDialog"
top top

View File

@ -3,7 +3,7 @@
<v-container fluid> <v-container fluid>
<BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle> <BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle>
<section> <section>
<v-toolbar flat class="justify-between"> <v-toolbar color="background" flat class="justify-between">
<BaseDialog <BaseDialog
ref="refUserDialog" ref="refUserDialog"
top top

View File

@ -11,64 +11,81 @@
</template> </template>
<template #title> {{ $t("settings.site-settings") }} </template> <template #title> {{ $t("settings.site-settings") }} </template>
</BasePageTitle> </BasePageTitle>
<BaseCardSectionTitle :icon="$globals.icons.email" title="Email Configuration"> </BaseCardSectionTitle>
<v-card> <section>
<v-card-text> <BaseCardSectionTitle :icon="$globals.icons.cog" title="General Configuration"> </BaseCardSectionTitle>
<v-card class="mb-4">
<v-list-item> <v-list-item>
<v-list-item-avatar> <v-list-item-avatar>
<v-icon :color="ready ? 'success' : 'error'"> <v-icon :color="getColor(appConfig.baseUrlSet)">
{{ ready ? $globals.icons.check : $globals.icons.close }} {{ appConfig.baseUrlSet ? $globals.icons.checkboxMarkedCircle : $globals.icons.close }}
</v-icon> </v-icon>
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title <v-list-item-title :class="getTextClass(appConfig.baseUrlSet)"> Server Side Base URL </v-list-item-title>
:class="{ <v-list-item-subtitle :class="getTextClass(appConfig.baseUrlSet)">
'success--text': ready, {{ appConfig.baseUrlSet ? "Ready" : "Not Ready - `BASE_URL` still default on API Server" }}
'error--text': !ready,
}"
>
Email Configuration Status
</v-list-item-title>
<v-list-item-subtitle
:class="{
'success--text': ready,
'error--text': !ready,
}"
>
{{ ready ? "Ready" : "Not Ready - Check Env Variables" }}
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
<v-card-actions> </v-card>
<v-text-field v-model="address" class="mr-4" :label="$t('user.email')" :rules="[validators.email]"> </section>
</v-text-field> <section>
<BaseButton color="info" :disabled="!ready || !validEmail" :loading="loading" @click="testEmail"> <BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration">
<template #icon> {{ $globals.icons.email }} </template> </BaseCardSectionTitle>
{{ $t("general.test") }} <v-card>
</BaseButton>
</v-card-actions>
</v-card-text>
<template v-if="tested">
<v-divider class="my-x"></v-divider>
<v-card-text> <v-card-text>
Email Test Result: {{ success ? "Succeeded" : "Failed" }} <v-list-item>
<div>Errors: {{ error }}</div> <v-list-item-avatar>
<v-icon :color="getColor(appConfig.emailReady)">
{{ appConfig.emailReady ? $globals.icons.checkboxMarkedCircle : $globals.icons.close }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title :class="getTextClass(appConfig.emailReady)">
Email Configuration Status
</v-list-item-title>
<v-list-item-subtitle :class="getTextClass(appConfig.emailReady)">
{{ appConfig.emailReady ? "Ready" : "Not Ready - Check Env Variables" }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-card-actions>
<v-text-field v-model="address" class="mr-4" :label="$t('user.email')" :rules="[validators.email]">
</v-text-field>
<BaseButton
color="info"
:disabled="!appConfig.emailReady || !validEmail"
:loading="loading"
@click="testEmail"
>
<template #icon> {{ $globals.icons.email }} </template>
{{ $t("general.test") }}
</BaseButton>
</v-card-actions>
</v-card-text> </v-card-text>
</template> <template v-if="tested">
</v-card> <v-divider class="my-x"></v-divider>
<v-card-text>
Email Test Result: {{ success ? "Succeeded" : "Failed" }}
<div>Errors: {{ error }}</div>
</v-card-text>
</template>
</v-card>
</section>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onMounted, reactive, toRefs } from "@nuxtjs/composition-api"; import { computed, defineComponent, onMounted, reactive, toRefs, ref } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api"; import { CheckAppConfig } from "~/api/class-interfaces/admin-about";
import { useAdminApi, useApiSingleton } from "~/composables/use-api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
export default defineComponent({ export default defineComponent({
layout: "admin", layout: "admin",
setup() { setup() {
const state = reactive({ const state = reactive({
ready: true,
loading: false, loading: false,
address: "", address: "",
success: false, success: false,
@ -76,13 +93,19 @@ export default defineComponent({
tested: false, tested: false,
}); });
const appConfig = ref<CheckAppConfig>({
emailReady: false,
baseUrlSet: false,
});
const api = useApiSingleton(); const api = useApiSingleton();
const adminAPI = useAdminApi();
onMounted(async () => { onMounted(async () => {
const { data } = await api.email.check(); const { data } = await adminAPI.about.checkApp();
if (data) { if (data) {
state.ready = data.ready; appConfig.value = data;
} }
}); });
@ -116,7 +139,17 @@ export default defineComponent({
return false; return false;
}); });
function getTextClass(booly: boolean | any) {
return booly ? "success--text" : "error--text";
}
function getColor(booly: boolean | any) {
return booly ? "success" : "error";
}
return { return {
getColor,
getTextClass,
appConfig,
validEmail, validEmail,
validators, validators,
...toRefs(state), ...toRefs(state),

View File

@ -1,7 +1,7 @@
<template> <template>
<v-container fluid> <v-container fluid>
<BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle> <BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle>
<v-toolbar flat> <v-toolbar flat color="background">
<BaseDialog <BaseDialog
ref="domFoodDialog" ref="domFoodDialog"
:title="dialog.title" :title="dialog.title"

View File

@ -25,7 +25,7 @@
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<v-toolbar flat class="justify-between"> <v-toolbar color="background" flat class="justify-between">
<BaseDialog <BaseDialog
:icon="$globals.icons.bellAlert" :icon="$globals.icons.bellAlert"
:title="$t('general.new') + ' ' + $t('events.notification')" :title="$t('general.new') + ' ' + $t('events.notification')"

View File

@ -1,7 +1,7 @@
<template> <template>
<v-container fluid> <v-container fluid>
<BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle> <BaseCardSectionTitle title="Manage Units"> </BaseCardSectionTitle>
<v-toolbar flat> <v-toolbar flat color="background">
<BaseDialog <BaseDialog
ref="domUnitDialog" ref="domUnitDialog"
:title="dialog.title" :title="dialog.title"

View File

@ -195,6 +195,7 @@ export default defineComponent({
setup() { setup() {
const { $auth } = useContext(); const { $auth } = useContext();
const context = useContext();
const form = reactive({ const form = reactive({
email: "changeme@email.com", email: "changeme@email.com",
@ -203,7 +204,7 @@ export default defineComponent({
const loggingIn = ref(false); const loggingIn = ref(false);
const allowSignup = computed(() => process.env.ALLOW_SIGNUP); const allowSignup = computed(() => context.env.ALLOW_SIGNUP);
async function authenticate() { async function authenticate() {
loggingIn.value = true; loggingIn.value = true;

View File

@ -158,7 +158,7 @@ export default defineComponent({
if (response?.status === 201) { if (response?.status === 201) {
state.success = true; state.success = true;
alert.success("Registration Success"); alert.success("Registration Success");
router.push("/user/login"); router.push("/login");
} }
} }

View File

@ -0,0 +1,113 @@
<template>
<v-container>
<BasePageTitle divider>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-members.svg')"></v-img>
</template>
<template #title> Manage Memebers </template>
Manage the permissions of the members in your groups. <b> Manage </b> allows the user to access the
data-management page <b> Invite </b> allows the user to generate invitation links for other users. Group owners
cannot change their own permissions.
</BasePageTitle>
<v-data-table
:headers="headers"
:items="members || []"
item-key="id"
class="elevation-0"
hide-default-footer
disable-pagination
>
<template #item.avatar="">
<v-avatar>
<img src="https://i.pravatar.cc/300" alt="John" />
</v-avatar>
</template>
<template #item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template #item.manage="{ item }">
<div class="d-flex justify-center">
<v-checkbox
v-model="item.canManage"
:disabled="item.id === $auth.user.id || item.admin"
class=""
style="max-width: 30px"
@change="setPermissions(item)"
></v-checkbox>
</div>
</template>
<template #item.organize="{ item }">
<div class="d-flex justify-center">
<v-checkbox
v-model="item.canOrganize"
:disabled="item.id === $auth.user.id || item.admin"
class=""
style="max-width: 30px"
@change="setPermissions(item)"
></v-checkbox>
</div>
</template>
<template #item.invite="{ item }">
<div class="d-flex justify-center">
<v-checkbox
v-model="item.canInvite"
:disabled="item.id === $auth.user.id || item.admin"
class=""
style="max-width: 30px"
@change="setPermissions(item)"
></v-checkbox>
</div>
</template>
</v-data-table>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, useContext } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
import { UserOut } from "~/types/api-types/user";
export default defineComponent({
setup() {
const api = useApiSingleton();
const { i18n } = useContext();
const members = ref<UserOut[] | null[]>([]);
const headers = [
{ text: "", value: "avatar", sortable: false, align: "center" },
{ text: i18n.t("user.username"), value: "username" },
{ text: i18n.t("user.full-name"), value: "fullName" },
{ text: i18n.t("user.admin"), value: "admin" },
{ text: "Manage", value: "manage", sortable: false, align: "center" },
{ text: "Organize", value: "organize", sortable: false, align: "center" },
{ text: "Invite", value: "invite", sortable: false, align: "center" },
];
async function refreshMembers() {
const { data } = await api.groups.fetchMembers();
if (data) {
members.value = data;
}
}
async function setPermissions(user: UserOut) {
const payload = {
userId: user.id,
canInvite: user.canInvite,
canManage: user.canManage,
canOrganize: user.canOrganize,
};
await api.groups.setMemberPermissions(payload);
}
onMounted(async () => {
await refreshMembers();
});
return { members, headers, setPermissions };
},
});
</script>

View File

@ -9,7 +9,7 @@
Manage your profile, recipes, and group settings. Manage your profile, recipes, and group settings.
<a href="https://hay-kot.github.io/mealie/" target="_blank"> Learn More </a> <a href="https://hay-kot.github.io/mealie/" target="_blank"> Learn More </a>
</p> </p>
<v-card flat width="100%" max-width="600px"> <v-card v-if="$auth.user.canInvite" flat color="background" width="100%" max-width="600px">
<v-card-actions class="d-flex justify-center"> <v-card-actions class="d-flex justify-center">
<v-btn outlined rounded @click="getSignupLink()"> <v-btn outlined rounded @click="getSignupLink()">
<v-icon left> <v-icon left>
@ -18,13 +18,25 @@
Get Invite Link Get Invite Link
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
<v-card-text v-if="generatedLink !== ''" class="d-flex"> <div v-show="generatedLink !== ''">
<v-text-field v-model="generatedLink" solo readonly> <v-card-text>
<template #append> <p class="text-center pb-0">
<AppButtonCopy :copy-text="generatedLink" /> {{ generatedLink }}
</template> </p>
</v-text-field> <v-text-field v-model="sendTo" :label="$t('user.email')" :rules="[validators.email]"> </v-text-field>
</v-card-text> </v-card-text>
<v-card-actions class="py-0 align-center" style="gap: 4px">
<BaseButton cancel @click="generatedLink = ''"> {{ $t("general.close") }} </BaseButton>
<v-spacer></v-spacer>
<AppButtonCopy :icon="false" color="info" :copy-text="generatedLink" />
<BaseButton color="info" :disabled="!validEmail" :loading="loading" @click="sendInvite">
<template #icon>
{{ $globals.icons.email }}
</template>
{{ $t("user.email") }}
</BaseButton>
</v-card-actions>
</div>
</v-card> </v-card>
</section> </section>
<section> <section>
@ -89,31 +101,44 @@
Setup webhooks that trigger on days that you have have mealplan scheduled. Setup webhooks that trigger on days that you have have mealplan scheduled.
</UserProfileLinkCard> </UserProfileLinkCard>
</v-col> </v-col>
<v-col cols="12" sm="12" md="6">
<UserProfileLinkCard
v-if="user.canManage"
:link="{ text: 'Manage Members', to: '/user/group/members' }"
:image="require('~/static/svgs/manage-members.svg')"
>
<template #title> Members </template>
See who's in your group and manage their permissions.
</UserProfileLinkCard>
</v-col>
</v-row> </v-row>
</section> </section>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, ref } from "@nuxtjs/composition-api"; import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue"; import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
import { useApiSingleton } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
import { validators } from "~/composables/use-validators";
import { alert } from "~/composables/use-toast";
export default defineComponent({ export default defineComponent({
components: { components: {
UserProfileLinkCard, UserProfileLinkCard,
}, },
setup() { setup() {
const user = computed(() => useContext().$auth.user); const { $auth } = useContext();
const user = computed(() => $auth.user);
const generatedLink = ref(""); const generatedLink = ref("");
const token = ref("");
const api = useApiSingleton(); const api = useApiSingleton();
async function getSignupLink() { async function getSignupLink() {
const { data } = await api.groups.createInvitation({ uses: 1 }); const { data } = await api.groups.createInvitation({ uses: 1 });
if (data) { if (data) {
token.value = data.token;
generatedLink.value = constructLink(data.token); generatedLink.value = constructLink(data.token);
} }
} }
@ -122,7 +147,51 @@ export default defineComponent({
return `${window.location.origin}/register?token=${token}`; return `${window.location.origin}/register?token=${token}`;
} }
return { user, constructLink, generatedLink, getSignupLink }; // =================================================
// 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("Email Sent");
} else {
alert.error("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;
});
return {
user,
constructLink,
generatedLink,
getSignupLink,
sendInvite,
validators,
validEmail,
...toRefs(state),
};
}, },
}); });
</script> </script>

View File

@ -68,7 +68,7 @@
</v-btn> </v-btn>
</v-form> </v-form>
</v-card-text> </v-card-text>
<v-btn v-if="allowSignup" class="mx-auto" text to="/user/login"> Login </v-btn> <v-btn v-if="allowSignup" class="mx-auto" text to="/login"> Login </v-btn>
</v-card> </v-card>
<!-- <v-col class="fill-height"> </v-col> --> <!-- <v-col class="fill-height"> </v-col> -->
</v-container> </v-container>

View File

@ -1,3 +1,7 @@
export default ({ $vuetify, $config }: any) => { export default ({ $vuetify, $config }: any) => {
$vuetify.theme.themes = $config.themes; $vuetify.theme.themes = $config.themes;
if ($config.useDark) {
$vuetify.theme.dark = true;
}
}; };

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -34,6 +34,9 @@ export interface GroupInDB {
shoppingLists?: ShoppingListOut[]; shoppingLists?: ShoppingListOut[];
} }
export interface UserOut { export interface UserOut {
canOrganize: boolean;
canManage: boolean;
canInvite: boolean;
username?: string; username?: string;
fullName?: string; fullName?: string;
email: string; email: string;

View File

@ -70,8 +70,10 @@ coverage: ## ☂️ Check code coverage quickly with the default Python
$(BROWSER) htmlcov/index.html $(BROWSER) htmlcov/index.html
setup: ## 🏗 Setup Development Instance setup: ## 🏗 Setup Development Instance
cp template.env .env -n
poetry install && \ poetry install && \
cd frontend && \ cd frontend && \
cp template.env .env -n
yarn install && \ yarn install && \
cd .. cd ..

View File

@ -0,0 +1,61 @@
from mealie.core import root_logger
from mealie.core.config import settings
from mealie.core.security import hash_password
from mealie.db.data_access_layer.access_model_factory import Database
logger = root_logger.get_logger("init_users")
def dev_users() -> list[dict]:
return [
{
"full_name": "Jason",
"username": "jason",
"email": "jason@email.com",
"password": hash_password(settings.DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"admin": False,
},
{
"full_name": "Bob",
"username": "bob",
"email": "bob@email.com",
"password": hash_password(settings.DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"admin": False,
},
{
"full_name": "Sarah",
"username": "sarah",
"email": "sarah@email.com",
"password": hash_password(settings.DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"admin": False,
},
{
"full_name": "Sammy",
"username": "sammy",
"email": "sammy@email.com",
"password": hash_password(settings.DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"admin": False,
},
]
def default_user_init(db: Database):
default_user = {
"full_name": "Change Me",
"username": "admin",
"email": settings.DEFAULT_EMAIL,
"password": hash_password(settings.DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"admin": True,
}
logger.info("Generating Default User")
db.users.create(default_user)
if not settings.PRODUCTION:
for user in dev_users():
db.users.create(user)

View File

@ -1,8 +1,8 @@
from mealie.core import root_logger from mealie.core import root_logger
from mealie.core.config import settings from mealie.core.config import settings
from mealie.core.security import hash_password
from mealie.db.data_access_layer.access_model_factory import Database from mealie.db.data_access_layer.access_model_factory import Database
from mealie.db.data_initialization.init_units_foods import default_recipe_unit_init from mealie.db.data_initialization.init_units_foods import default_recipe_unit_init
from mealie.db.data_initialization.init_users import default_user_init
from mealie.db.database import get_database from mealie.db.database import get_database
from mealie.db.db_setup import create_session, engine from mealie.db.db_setup import create_session, engine
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
@ -37,20 +37,6 @@ def default_group_init(db: Database):
create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP)) create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP))
def default_user_init(db: Database):
default_user = {
"full_name": "Change Me",
"username": "admin",
"email": settings.DEFAULT_EMAIL,
"password": hash_password(settings.DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"admin": True,
}
logger.info("Generating Default User")
db.users.create(default_user)
def main(): def main():
create_all_models() create_all_models()

View File

@ -34,8 +34,12 @@ class User(SqlAlchemyBase, BaseMixins):
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users") group = orm.relationship("Group", back_populates="users")
# Recipes # Group Permissions
can_manage = Column(Boolean, default=False)
can_invite = Column(Boolean, default=False)
can_organize = Column(Boolean, default=False)
# Recipes
tokens: list[LongLiveToken] = orm.relationship( tokens: list[LongLiveToken] = orm.relationship(
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
) )
@ -59,6 +63,9 @@ class User(SqlAlchemyBase, BaseMixins):
group: str = settings.DEFAULT_GROUP, group: str = settings.DEFAULT_GROUP,
admin=False, admin=False,
advanced=False, advanced=False,
can_manage=False,
can_invite=False,
can_organize=False,
**_ **_
) -> None: ) -> None:
@ -71,6 +78,15 @@ class User(SqlAlchemyBase, BaseMixins):
self.password = password self.password = password
self.advanced = advanced self.advanced = advanced
if self.admin:
self.can_manage = True
self.can_invite = True
self.can_organize = True
else:
self.can_manage = can_manage
self.can_invite = can_invite
self.can_organize = can_organize
self.favorite_recipes = [] self.favorite_recipes = []
if self.username is None: if self.username is None:
@ -87,6 +103,9 @@ class User(SqlAlchemyBase, BaseMixins):
favorite_recipes=None, favorite_recipes=None,
password=None, password=None,
advanced=False, advanced=False,
can_manage=False,
can_invite=False,
can_organize=False,
**_ **_
): ):
favorite_recipes = favorite_recipes or [] favorite_recipes = favorite_recipes or []
@ -103,6 +122,15 @@ class User(SqlAlchemyBase, BaseMixins):
if password: if password:
self.password = password self.password = password
if self.admin:
self.can_manage = True
self.can_invite = True
self.can_organize = True
else:
self.can_manage = can_manage
self.can_invite = can_invite
self.can_organize = can_organize
def update_password(self, password): def update_password(self, password):
self.password = password self.password = password

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm.session import Session
from mealie.core.config import APP_VERSION, get_settings from mealie.core.config import APP_VERSION, get_settings
from mealie.db.database import get_database from mealie.db.database import get_database
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.schema.admin.about import AdminAboutInfo, AppStatistics from mealie.schema.admin.about import AdminAboutInfo, AppStatistics, CheckAppConfig
router = APIRouter(prefix="/about") router = APIRouter(prefix="/about")
@ -36,3 +36,15 @@ async def get_app_statistics(session: Session = Depends(generate_session)):
total_users=db.users.count_all(), total_users=db.users.count_all(),
total_groups=db.groups.count_all(), total_groups=db.groups.count_all(),
) )
@router.get("/check", response_model=CheckAppConfig)
async def check_app_config():
settings = get_settings()
url_set = settings.BASE_URL != "http://localhost:8080"
return CheckAppConfig(
email_ready=settings.SMTP_ENABLE,
base_url_set=url_set,
)

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from mealie.schema.group.invite_token import CreateInviteToken, ReadInviteToken from mealie.schema.group.invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken
from mealie.services.group_services.group_service import GroupSelfService from mealie.services.group_services.group_service import GroupSelfService
router = APIRouter() router = APIRouter()
@ -14,3 +14,8 @@ def get_invite_tokens(g_service: GroupSelfService = Depends(GroupSelfService.pri
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
def create_invite_token(uses: CreateInviteToken, g_service: GroupSelfService = Depends(GroupSelfService.private)): def create_invite_token(uses: CreateInviteToken, g_service: GroupSelfService = Depends(GroupSelfService.private)):
return g_service.create_invite_token(uses.uses) return g_service.create_invite_token(uses.uses)
@router.post("/email", response_model=EmailInitationResponse)
def email_invitation(invite: EmailInvitation, g_service: GroupSelfService = Depends(GroupSelfService.private)):
return g_service.email_invitation(invite)

View File

@ -1,7 +1,8 @@
from fastapi import Depends from fastapi import Depends
from mealie.routes.routers import UserAPIRouter from mealie.routes.routers import UserAPIRouter
from mealie.schema.user.user import GroupInDB from mealie.schema.group.group_permissions import SetPermissions
from mealie.schema.user.user import GroupInDB, UserOut
from mealie.services.group_services.group_service import GroupSelfService from mealie.services.group_services.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"]) user_router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@ -10,5 +11,17 @@ user_router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@user_router.get("/self", response_model=GroupInDB) @user_router.get("/self", response_model=GroupInDB)
async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)): async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
""" Returns the Group Data for the Current User """ """ Returns the Group Data for the Current User """
return g_service.item return g_service.item
@user_router.get("/members", response_model=list[UserOut])
async def get_group_members(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
""" Returns the Group of user lists """
return g_service.get_members()
@user_router.put("/permissions", response_model=UserOut)
async def set_member_permissions(
payload: SetPermissions, g_service: GroupSelfService = Depends(GroupSelfService.manage_existing)
):
return g_service.set_member_permissions(payload)

View File

@ -23,3 +23,8 @@ class AdminAboutInfo(AppInfo):
db_type: str db_type: str
db_url: Path db_url: Path
default_group: str default_group: str
class CheckAppConfig(CamelModel):
email_ready: bool = False
base_url_set: bool = False

View File

@ -0,0 +1,8 @@
from fastapi_camelcase import CamelModel
class SetPermissions(CamelModel):
user_id: int
can_manage: bool = False
can_invite: bool = False
can_organize: bool = False

View File

@ -18,3 +18,13 @@ class ReadInviteToken(CamelModel):
class Config: class Config:
orm_mode = True orm_mode = True
class EmailInvitation(CamelModel):
email: str
token: str
class EmailInitationResponse(CamelModel):
success: bool
error: str = None

View File

@ -55,6 +55,10 @@ class UserBase(CamelModel):
advanced: bool = False advanced: bool = False
favorite_recipes: Optional[list[str]] = [] favorite_recipes: Optional[list[str]] = []
can_invite: bool = False
can_manage: bool = False
can_organize: bool = False
class Config: class Config:
orm_mode = True orm_mode = True

View File

@ -55,7 +55,7 @@ class EmailService(BaseService):
def send_invitation(self, address: str, invitation_url: str) -> bool: def send_invitation(self, address: str, invitation_url: str) -> bool:
invitation = EmailTemplate( invitation = EmailTemplate(
subject="Invitation to join Mealie", subject="Invitation to join Mealie",
header_text="Invitation", header_text="Your Invited!",
message_top="You have been invited to join Mealie.", message_top="You have been invited to join Mealie.",
message_bottom="Please click the button below to accept the invitation.", message_bottom="Please click the button below to accept the invitation.",
button_link=invitation_url, button_link=invitation_url,

View File

@ -419,7 +419,7 @@
" "
> >
<div style="text-align: center"> <div style="text-align: center">
{{ data.bottom_message}} {{ data.message_bottom}}
</div> </div>
</div> </div>
</td> </td>

View File

@ -2,15 +2,17 @@ from __future__ import annotations
from uuid import uuid4 from uuid import uuid4
from fastapi import Depends from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import UserDeps from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.schema.group.group_permissions import SetPermissions
from mealie.schema.group.group_preferences import UpdateGroupPreferences from mealie.schema.group.group_preferences import UpdateGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken, SaveInviteToken from mealie.schema.group.invite_token import EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
from mealie.schema.recipe.recipe_category import CategoryBase from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB from mealie.schema.user.user import GroupInDB, PrivateUser, UserOut
from mealie.services._base_http_service.http_services import UserHttpService from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.email import EmailService
from mealie.services.events import create_group_event from mealie.services.events import create_group_event
logger = get_logger(module=__name__) logger = get_logger(module=__name__)
@ -31,10 +33,38 @@ class GroupSelfService(UserHttpService[int, str]):
"""Override parent method to remove `item_id` from arguments""" """Override parent method to remove `item_id` from arguments"""
return super().write_existing(item_id=0, deps=deps) return super().write_existing(item_id=0, deps=deps)
@classmethod
def manage_existing(cls, deps: UserDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
if not deps.user.can_manage:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return super().write_existing(item_id=0, deps=deps)
def populate_item(self, _: str = None) -> GroupInDB: def populate_item(self, _: str = None) -> GroupInDB:
self.item = self.db.groups.get(self.group_id) self.item = self.db.groups.get(self.group_id)
return self.item return self.item
# ====================================================================
# Manage Menbers
def get_members(self) -> list[UserOut]:
return self.db.users.multi_query(query_by={"group_id": self.item.id}, override_schema=UserOut)
def set_member_permissions(self, permissions: SetPermissions) -> PrivateUser:
target_user = self.db.users.get(permissions.user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
if target_user.group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this group")
target_user.can_invite = permissions.can_invite
target_user.can_manage = permissions.can_manage
target_user.can_organize = permissions.can_organize
return self.db.users.update(permissions.user_id, target_user)
# ==================================================================== # ====================================================================
# Meal Categories # Meal Categories
@ -53,11 +83,27 @@ class GroupSelfService(UserHttpService[int, str]):
# Group Invites # Group Invites
def create_invite_token(self, uses: int = 1) -> None: def create_invite_token(self, uses: int = 1) -> None:
if not self.user.can_invite:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not allowed to create invite tokens")
token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex) token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex)
return self.db.group_invite_tokens.create(token) return self.db.group_invite_tokens.create(token)
def get_invite_tokens(self) -> list[ReadInviteToken]: def get_invite_tokens(self) -> list[ReadInviteToken]:
return self.db.group_invite_tokens.multi_query({"group_id": self.group_id}) return self.db.group_invite_tokens.multi_query({"group_id": self.group_id})
def email_invitation(self, invite: EmailInvitation) -> EmailInitationResponse:
email_service = EmailService()
url = f"{self.settings.BASE_URL}/register?token={invite.token}"
success = False
error = None
try:
success = email_service.send_invitation(address=invite.email, invitation_url=url)
except Exception as e:
error = str(e)
return EmailInitationResponse(success=success, error=error)
# ==================================================================== # ====================================================================
# Export / Import Recipes # Export / Import Recipes

View File

@ -23,25 +23,21 @@ class RegistrationService(PublicHttpService[int, str]):
logger.info(f"Registering user {registration.username}") logger.info(f"Registering user {registration.username}")
token_entry = None token_entry = None
new_group = False
if registration.group: if registration.group:
new_group = True
group = self._register_new_group() group = self._register_new_group()
elif registration.group_token and registration.group_token != "": elif registration.group_token and registration.group_token != "":
token_entry = self.db.group_invite_tokens.get(registration.group_token) token_entry = self.db.group_invite_tokens.get(registration.group_token)
print("Token Entry", token_entry)
if not token_entry: if not token_entry:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"}) raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Invalid group token"})
group = self.db.groups.get(token_entry.group_id) group = self.db.groups.get(token_entry.group_id)
else: else:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"}) raise HTTPException(status.HTTP_400_BAD_REQUEST, {"message": "Missing group"})
user = self._create_new_user(group) user = self._create_new_user(group, new_group)
if token_entry and user: if token_entry and user:
token_entry.uses_left = token_entry.uses_left - 1 token_entry.uses_left = token_entry.uses_left - 1
@ -54,7 +50,7 @@ class RegistrationService(PublicHttpService[int, str]):
return user return user
def _create_new_user(self, group: GroupInDB) -> PrivateUser: def _create_new_user(self, group: GroupInDB, new_group=bool) -> PrivateUser:
new_user = UserIn( new_user = UserIn(
email=self.registration.email, email=self.registration.email,
username=self.registration.username, username=self.registration.username,
@ -62,6 +58,9 @@ class RegistrationService(PublicHttpService[int, str]):
full_name=self.registration.username, full_name=self.registration.username,
advanced=self.registration.advanced, advanced=self.registration.advanced,
group=group.name, group=group.name,
can_invite=new_group,
can_manage=new_group,
can_organize=new_group,
) )
return self.db.users.create(new_user) return self.db.users.create(new_user)

View File

@ -4,6 +4,7 @@ from tests.utils.factories import user_registration_factory
class Routes: class Routes:
self = "/api/users/self"
base = "/api/users/register" base = "/api/users/register"
auth_token = "/api/auth/token" auth_token = "/api/auth/token"
@ -22,3 +23,31 @@ def test_user_registration_new_group(api_client: TestClient):
token = response.json().get("access_token") token = response.json().get("access_token")
assert token is not None assert token is not None
def test_new_user_group_permissions(api_client: TestClient):
registration = user_registration_factory()
response = api_client.post(Routes.base, json=registration.dict(by_alias=True))
assert response.status_code == 201
# Login
form_data = {"username": registration.email, "password": registration.password}
response = api_client.post(Routes.auth_token, form_data)
assert response.status_code == 200
token = response.json().get("access_token")
assert token is not None
# Get User
headers = {"Authorization": f"Bearer {token}"}
response = api_client.get(Routes.self, headers=headers)
assert response.status_code == 200
user = response.json()
assert user.get("canInvite") is True
assert user.get("canManage") is True
assert user.get("canOrganize") is True