feat: improved registration signup flow (#1188)

refactored signup flow for entire registration process. Utilized seed data option for optional seeding of Foods, Units, and Labels. Localized registration page.
This commit is contained in:
Hayden 2022-05-06 11:18:06 -08:00 committed by GitHub
parent 6ee9a31c92
commit 7e4da3e5a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1056 additions and 316 deletions

View File

@ -1,14 +1,5 @@
import { BaseAPI } from "../_base"; import { BaseAPI } from "../_base";
import { CreateUserRegistration } from "~/types/api-types/user";
export interface RegisterPayload {
group: string;
groupToken: string;
email: string;
password: string;
passwordConfirm: string;
advanced: boolean;
private: boolean;
}
const prefix = "/api"; const prefix = "/api";
@ -19,7 +10,7 @@ const routes = {
export class RegisterAPI extends BaseAPI { export class RegisterAPI extends BaseAPI {
/** Returns a list of avaiable .zip files for import into Mealie. /** Returns a list of avaiable .zip files for import into Mealie.
*/ */
async register(payload: RegisterPayload) { async register(payload: CreateUserRegistration) {
return await this.requests.post<any>(routes.register, payload); return await this.requests.post<any>(routes.register, payload);
} }
} }

View File

@ -0,0 +1,12 @@
import { ValidatorsApi } from "./public/validators";
import { ApiRequestInstance } from "~/types/api";
export class PublicApi {
public validators: ValidatorsApi;
constructor(requests: ApiRequestInstance) {
this.validators = new ValidatorsApi(requests);
Object.freeze(this);
}
}

View File

@ -0,0 +1,32 @@
import { BaseAPI } from "../_base";
export type Validation = {
valid: boolean;
};
const prefix = "/api";
const routes = {
group: (name: string) => `${prefix}/validators/group?name=${name}`,
user: (name: string) => `${prefix}/validators/user/name?name=${name}`,
email: (name: string) => `${prefix}/validators/user/email?email=${name}`,
recipe: (groupId: string, name: string) => `${prefix}/validators/group/recipe?group_id=${groupId}?name=${name}`,
};
export class ValidatorsApi extends BaseAPI {
async group(name: string) {
return await this.requests.get<Validation>(routes.group(name));
}
async username(name: string) {
return await this.requests.get<Validation>(routes.user(name));
}
async email(email: string) {
return await this.requests.get<Validation>(routes.email(email));
}
async recipe(groupId: string, name: string) {
return await this.requests.get<Validation>(routes.recipe(groupId, name));
}
}

View File

@ -183,9 +183,10 @@ export default defineComponent({
return []; return [];
} }
const list = [] as ((v: string) => (boolean | string))[]; const list = [] as ((v: string) => boolean | string)[];
keys.forEach((key) => { keys.forEach((key) => {
if (key in validators) { if (key in validators) {
// @ts-ignore TODO: fix this
list.push(validators[key]); list.push(validators[key]);
} }
}); });

View File

@ -3,9 +3,14 @@ import { useContext } from "@nuxtjs/composition-api";
import { NuxtAxiosInstance } from "@nuxtjs/axios"; import { NuxtAxiosInstance } from "@nuxtjs/axios";
import { AdminAPI, Api } from "~/api"; import { AdminAPI, Api } from "~/api";
import { ApiRequestInstance, RequestResponse } from "~/types/api"; import { ApiRequestInstance, RequestResponse } from "~/types/api";
import { PublicApi } from "~/api/public-api";
const request = { const request = {
async safe<T, U>(funcCall: (url: string, data: U) => Promise<AxiosResponse<T>>, url: string, data: U): Promise<RequestResponse<T>> { async safe<T, U>(
funcCall: (url: string, data: U) => Promise<AxiosResponse<T>>,
url: string,
data: U
): Promise<RequestResponse<T>> {
let error = null; let error = null;
const response = await funcCall(url, data).catch(function (e) { const response = await funcCall(url, data).catch(function (e) {
console.log(e); console.log(e);
@ -66,6 +71,13 @@ export const useUserApi = function (): Api {
$axios.setHeader("Accept-Language", i18n.locale); $axios.setHeader("Accept-Language", i18n.locale);
const requests = getRequests($axios); const requests = getRequests($axios);
return new Api(requests); return new Api(requests);
}; };
export const usePublicApi = function (): PublicApi {
const { $axios, i18n } = useContext();
$axios.setHeader("Accept-Language", i18n.locale);
const requests = getRequests($axios);
return new PublicApi(requests);
};

View File

@ -1,22 +0,0 @@
import { computed, ref, useContext } from "@nuxtjs/composition-api";
export function usePasswordField() {
const show = ref(false);
const { $globals } = useContext();
const passwordIcon = computed(() => {
return show.value ? $globals.icons.eyeOff : $globals.icons.eye;
});
const inputType = computed(() => (show.value ? "text" : "password"));
const togglePasswordShow = () => {
show.value = !show.value;
};
return {
inputType,
togglePasswordShow,
passwordIcon,
};
}

View File

@ -0,0 +1,94 @@
import { computed, Ref, ref, useContext } from "@nuxtjs/composition-api";
export function usePasswordField() {
const show = ref(false);
const { $globals } = useContext();
const passwordIcon = computed(() => {
return show.value ? $globals.icons.eyeOff : $globals.icons.eye;
});
const inputType = computed(() => (show.value ? "text" : "password"));
const togglePasswordShow = () => {
show.value = !show.value;
};
return {
inputType,
togglePasswordShow,
passwordIcon,
};
}
function scorePassword(pass: string): number {
let score = 0;
if (!pass) return score;
const flaggedWords = ["password", "mealie", "admin", "qwerty", "login"];
if (pass.length < 6) return score;
// Check for flagged words
for (const word of flaggedWords) {
if (pass.toLowerCase().includes(word)) {
score -= 100;
}
}
// award every unique letter until 5 repetitions
const letters: { [key: string]: number } = {};
for (let i = 0; i < pass.length; i++) {
letters[pass[i]] = (letters[pass[i]] || 0) + 1;
score += 5.0 / letters[pass[i]];
}
// bonus points for mixing it up
const variations: { [key: string]: boolean } = {
digits: /\d/.test(pass),
lower: /[a-z]/.test(pass),
upper: /[A-Z]/.test(pass),
nonWords: /\W/.test(pass),
};
let variationCount = 0;
for (const check in variations) {
variationCount += variations[check] === true ? 1 : 0;
}
score += (variationCount - 1) * 10;
return score;
}
export const usePasswordStrength = (password: Ref<string>) => {
const score = computed(() => {
return scorePassword(password.value);
});
const strength = computed(() => {
if (score.value < 50) {
return "Weak";
} else if (score.value < 80) {
return "Good";
} else if (score.value < 100) {
return "Strong";
} else {
return "Very Strong";
}
});
const color = computed(() => {
if (score.value < 50) {
return "error";
} else if (score.value < 80) {
return "warning";
} else if (score.value < 100) {
return "info";
} else {
return "success";
}
});
return { score, strength, color };
};

View File

@ -1,14 +1,46 @@
import { ref, Ref } from "@nuxtjs/composition-api";
import { RequestResponse } from "~/types/api";
import { Validation } from "~/api/public/validators";
const EMAIL_REGEX = const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
export const validators: {[key: string]: (v: string) => boolean | string} = { export const validators = {
required: (v: string) => !!v || "This Field is Required", required: (v: string) => !!v || "This Field is Required",
email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid", email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid",
whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed", whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed",
url: (v: string) => !v || URL_REGEX.test(v) || "Must Be A Valid URL", url: (v: string) => !v || URL_REGEX.test(v) || "Must Be A Valid URL",
// TODO These appear to be unused? minLength: (min: number) => (v: string) => !v || v.length >= min || `Must Be At Least ${min} Characters`,
// minLength: (min: number) => (v: string) => !v || v.length >= min || `Must Be At Least ${min} Characters`, maxLength: (max: number) => (v: string) => !v || v.length <= max || `Must Be At Most ${max} Characters`,
// maxLength: (max: number) => (v: string) => !v || v.length <= max || `Must Be At Most ${max} Characters`, };
/**
* useAsyncValidator us a factory function that returns an async function that
* when called will validate the input against the backend database and set the
* error messages when applicable to the ref.
*/
export const useAsyncValidator = (
value: Ref<string>,
validatorFunc: (v: string) => Promise<RequestResponse<Validation>>,
validatorMessage: string,
errorMessages: Ref<string[]>
) => {
const valid = ref(false);
const validate = async () => {
errorMessages.value = [];
const { data } = await validatorFunc(value.value);
if (!data?.valid) {
valid.value = false;
errorMessages.value.push(validatorMessage);
return;
}
valid.value = true;
};
return { validate, valid };
}; };

View File

@ -132,7 +132,9 @@
"wednesday": "Wednesday", "wednesday": "Wednesday",
"yes": "Yes", "yes": "Yes",
"foods": "Foods", "foods": "Foods",
"units": "Units" "units": "Units",
"back": "Back",
"next": "Next"
}, },
"group": { "group": {
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?", "are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
@ -152,7 +154,11 @@
"manage-groups": "Manage Groups", "manage-groups": "Manage Groups",
"user-group": "User Group", "user-group": "User Group",
"user-group-created": "User Group Created", "user-group-created": "User Group Created",
"user-group-creation-failed": "User Group Creation Failed" "user-group-creation-failed": "User Group Creation Failed",
"settings": {
"keep-my-recipes-private": "Keep My Recipes Private",
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later."
}
}, },
"meal-plan": { "meal-plan": {
"create-a-new-meal-plan": "Create a New Meal Plan", "create-a-new-meal-plan": "Create a New Meal Plan",
@ -281,9 +287,7 @@
"sugar-content": "Sugar", "sugar-content": "Sugar",
"title": "Title", "title": "Title",
"total-time": "Total Time", "total-time": "Total Time",
"unable-to-delete-recipe": "Unable to Delete Recipe" "unable-to-delete-recipe": "Unable to Delete Recipe",
},
"reicpe": {
"no-recipe": "No Recipe" "no-recipe": "No Recipe"
}, },
"search": { "search": {
@ -473,6 +477,7 @@
"password-reset-failed": "Password reset failed", "password-reset-failed": "Password reset failed",
"password-updated": "Password updated", "password-updated": "Password updated",
"password": "Password", "password": "Password",
"password-strength": "Password is {strength}",
"register": "Register", "register": "Register",
"reset-password": "Reset Password", "reset-password": "Reset Password",
"sign-in": "Sign in", "sign-in": "Sign in",
@ -496,7 +501,9 @@
"webhook-time": "Webhook Time", "webhook-time": "Webhook Time",
"webhooks-enabled": "Webhooks Enabled", "webhooks-enabled": "Webhooks Enabled",
"you-are-not-allowed-to-create-a-user": "You are not allowed to create a user", "you-are-not-allowed-to-create-a-user": "You are not allowed to create a user",
"you-are-not-allowed-to-delete-this-user": "You are not allowed to delete this user" "you-are-not-allowed-to-delete-this-user": "You are not allowed to delete this user",
"enable-advanced-content": "Enable Advanced Content",
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later"
}, },
"language-dialog": { "language-dialog": {
"translated": "translated", "translated": "translated",
@ -513,5 +520,21 @@
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.", "seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.",
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually." "seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
} }
},
"user-registration": {
"user-registration": "User Registration",
"join-a-group": "Join a Group",
"create-a-new-group": "Create a New Group",
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.",
"group-details": "Group Details",
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!",
"use-seed-data": "Use Seed Data",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes.",
"account-details": "Account Details"
},
"validation": {
"group-name-is-taken": "Group name is taken",
"username-is-taken": "Username is taken",
"email-is-taken": "Email is taken"
} }
} }

View File

@ -112,7 +112,7 @@
import { defineComponent, ref, useContext, computed, reactive } from "@nuxtjs/composition-api"; import { defineComponent, ref, useContext, computed, reactive } from "@nuxtjs/composition-api";
import { useDark } from "@vueuse/core"; import { useDark } from "@vueuse/core";
import { useAppInfo } from "~/composables/api"; import { useAppInfo } from "~/composables/api";
import { usePasswordField } from "~/composables/use-password-field"; import { usePasswordField } from "~/composables/use-passwords";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { useToggleDarkMode } from "~/composables/use-utils"; import { useToggleDarkMode } from "~/composables/use-utils";
export default defineComponent({ export default defineComponent({

View File

@ -1,187 +0,0 @@
<template>
<v-container fill-height fluid class="d-flex justify-center align-start narrow-container">
<v-card color="background d-flex flex-column align-center" flat width="700px">
<v-card-title class="headline"> User Registration </v-card-title>
<v-card-text>
<v-form ref="domRegisterForm" @submit.prevent="register()">
<div class="d-flex justify-center my-2">
<v-btn-toggle v-model="joinGroup" mandatory tile group color="primary">
<v-btn :value="false" small @click="toggleJoinGroup"> Create a Group </v-btn>
<v-btn :value="true" small @click="toggleJoinGroup"> Join a Group </v-btn>
</v-btn-toggle>
</div>
<v-text-field
v-if="!joinGroup"
v-model="form.group"
filled
rounded
autofocus
validate-on-blur
class="rounded-lg"
:prepend-icon="$globals.icons.group"
:rules="[tokenOrGroup]"
label="New Group Name"
/>
<v-text-field
v-else
v-model="form.groupToken"
filled
rounded
validate-on-blur
:rules="[tokenOrGroup]"
class="rounded-lg"
:prepend-icon="$globals.icons.group"
label="Group Token"
/>
<v-text-field
v-model="form.email"
filled
rounded
class="rounded-lg"
validate-on-blur
:prepend-icon="$globals.icons.email"
label="Email"
:rules="[validators.required, validators.email]"
/>
<v-text-field
v-model="form.username"
filled
rounded
class="rounded-lg"
:prepend-icon="$globals.icons.user"
label="Username"
:rules="[validators.required]"
/>
<v-text-field
v-model="form.password"
filled
rounded
class="rounded-lg"
:prepend-icon="$globals.icons.lock"
name="password"
label="Password"
type="password"
:rules="[validators.required]"
/>
<v-text-field
v-model="form.passwordConfirm"
filled
rounded
validate-on-blur
class="rounded-lg"
:prepend-icon="$globals.icons.lock"
name="password"
label="Confirm Password"
type="password"
:rules="[validators.required, passwordMatch]"
/>
<div class="mt-n4 px-8">
<v-checkbox v-model="form.private" label="Keep My Recipes Private"></v-checkbox>
<p class="text-caption mt-n4">
Sets your group and all recipes defaults to private. You can always change this later.
</p>
<v-checkbox v-model="form.advanced" label="Enable Advanced Content"></v-checkbox>
<p class="text-caption mt-n4">
Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you
can always change this later
</p>
</div>
<div class="d-flex flex-column justify-center">
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
Register
</v-btn>
<v-btn class="mx-auto my-2" text to="/login"> Login </v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
import { validators } from "@/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useRouteQuery } from "@/composables/use-router";
import { VForm } from "~/types/vuetify";
export default defineComponent({
layout: "basic",
setup() {
const api = useUserApi();
const state = reactive({
joinGroup: false,
loggingIn: false,
success: false,
});
const allowSignup = computed(() => process.env.AllOW_SIGNUP);
const token = useRouteQuery("token");
if (token.value) {
state.joinGroup = true;
}
function toggleJoinGroup() {
if (state.joinGroup) {
state.joinGroup = false;
token.value = "";
} else {
state.joinGroup = true;
form.group = "";
}
}
const domRegisterForm = ref<VForm | null>(null);
const form = reactive({
group: "",
groupToken: token,
email: "",
username: "",
password: "",
passwordConfirm: "",
advanced: false,
private: false,
});
const passwordMatch = () => form.password === form.passwordConfirm || "Passwords do not match";
const tokenOrGroup = () => form.group !== "" || form.groupToken !== "" || "Group name or token must be given";
const router = useRouter();
async function register() {
if (!domRegisterForm.value?.validate()) {
return;
}
const { response } = await api.register.register(form);
if (response?.status === 201) {
state.success = true;
alert.success("Registration Success");
router.push("/login");
}
}
return {
token,
toggleJoinGroup,
domRegisterForm,
validators,
allowSignup,
form,
...toRefs(state),
passwordMatch,
tokenOrGroup,
register,
};
},
head() {
return {
title: this.$t("user.register") as string,
};
},
});
</script>

View File

@ -0,0 +1,2 @@
import Register from "./register.vue";
export default Register;

View File

@ -0,0 +1,603 @@
<template>
<v-container
fill-height
fluid
class="d-flex justify-center align-center"
:class="{
'bg-off-white': !$vuetify.theme.dark && !isDark.value,
}"
>
<LanguageDialog v-model="langDialog" />
<v-card class="d-flex flex-column" width="1200px" min-height="700px">
<div>
<v-toolbar width="100%" color="primary" class="d-flex justify-center" style="margin-bottom: 4rem" dark>
<v-toolbar-title class="headline text-h4"> Mealie </v-toolbar-title>
</v-toolbar>
<div class="icon-container">
<v-divider class="icon-divider"></v-divider>
<v-avatar class="pa-2 icon-avatar" color="primary" size="75">
<svg class="icon-white" style="width: 75;" viewBox="0 0 24 24">
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</v-avatar>
</div>
</div>
<!-- Form Container -->
<div class="d-flex justify-center grow items-center my-4">
<template v-if="state.ctx.state === States.Initial">
<div width="600px">
<v-card-title class="headline justify-center my-4 mb-5 pb-0">
{{ $t("user-registration.user-registration") }}
</v-card-title>
<div class="d-flex flex-wrap justify-center flex-md-nowrap pa-4" style="gap: 1em">
<v-card color="primary" dark hover width="300px" outlined @click="initial.joinGroup">
<v-card-title class="justify-center">
<v-icon large left> {{ $globals.icons.group }}</v-icon>
{{ $t("user-registration.join-a-group") }}
</v-card-title>
</v-card>
<v-card color="primary" dark hover width="300px" outlined @click="initial.createGroup">
<v-card-title class="justify-center">
<v-icon large left> {{ $globals.icons.user }}</v-icon>
{{ $t("user-registration.create-a-new-group") }}
</v-card-title>
</v-card>
</div>
</div>
</template>
<template v-else-if="state.ctx.state === States.ProvideToken">
<div>
<v-card-title>
<v-icon large class="mr-3"> {{ $globals.icons.group }}</v-icon>
<span class="headline"> {{ $t("user-registration.join-a-group") }} </span>
</v-card-title>
<v-divider />
<v-card-text>
{{ $t("user-registration.provide-registration-token-description") }}
<v-form ref="domTokenForm" class="mt-4" @submit.prevent>
<v-text-field v-model="token" v-bind="inputAttrs" label="Group Token" :rules="[validators.required]" />
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="mt-auto justify-space-between">
<BaseButton cancel @click="state.back">
<template #icon> {{ $globals.icons.back }}</template>
{{ $t("general.back") }}
</BaseButton>
<BaseButton icon-right @click="provideToken.next">
<template #icon> {{ $globals.icons.forward }}</template>
{{ $t("general.next") }}
</BaseButton>
</v-card-actions>
</div>
</template>
<template v-else-if="state.ctx.state === States.ProvideGroupDetails">
<div class="preferred-width">
<v-card-title>
<v-icon large class="mr-3"> {{ $globals.icons.group }}</v-icon>
<span class="headline"> {{ $t("user-registration.group-details") }}</span>
</v-card-title>
<v-card-text>
{{ $t("user-registration.group-details-description") }}
</v-card-text>
<v-divider />
<v-card-text>
<v-form ref="domGroupForm" @submit.prevent>
<v-text-field
v-model="groupDetails.groupName.value"
v-bind="inputAttrs"
:label="$t('group.group-name')"
:rules="[validators.required]"
:error-messages="groupErrorMessages"
@blur="validGroupName"
/>
<div class="mt-n4 px-2">
<v-checkbox
v-model="groupDetails.groupPrivate.value"
hide-details
:label="$tc('group.settings.keep-my-recipes-private')"
/>
<p class="text-caption mt-1">
{{ $t("group.settings.keep-my-recipes-private-description") }}
</p>
<v-checkbox
v-model="groupDetails.groupSeed.value"
hide-details
:label="$tc('data-pages.seed-data')"
/>
<p class="text-caption mt-1">
{{ $t("user-registration.use-seed-data-description") }}
</p>
</div>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="justify-space-between">
<BaseButton cancel @click="state.back">
<template #icon> {{ $globals.icons.back }}</template>
{{ $t("general.back") }}
</BaseButton>
<BaseButton icon-right @click="groupDetails.next">
<template #icon> {{ $globals.icons.forward }}</template>
{{ $t("general.next") }}
</BaseButton>
</v-card-actions>
</div>
</template>
<template v-else-if="state.ctx.state === States.ProvideAccountDetails">
<div>
<v-card-title>
<v-icon large class="mr-3"> {{ $globals.icons.user }}</v-icon>
<span class="headline"> {{ $t("user-registration.account-details") }}</span>
</v-card-title>
<v-divider />
<v-card-text>
<v-form ref="domAccountForm" @submit.prevent>
<v-text-field
v-model="accountDetails.username.value"
autofocus
v-bind="inputAttrs"
:label="$tc('user.username')"
:prepend-icon="$globals.icons.user"
:rules="[validators.required]"
:error-messages="usernameErrorMessages"
@blur="validateUsername"
/>
<v-text-field
v-model="accountDetails.email.value"
v-bind="inputAttrs"
:prepend-icon="$globals.icons.email"
:label="$tc('user.email')"
:rules="[validators.required, validators.email]"
:error-messages="emailErrorMessages"
@blur="validateEmail"
/>
<v-text-field
v-model="credentials.password1.value"
v-bind="inputAttrs"
:type="pwFields.inputType.value"
:append-icon="pwFields.passwordIcon.value"
:prepend-icon="$globals.icons.lock"
:label="$tc('user.password')"
:rules="[validators.required, validators.minLength(8), validators.maxLength(258)]"
@click:append="pwFields.togglePasswordShow"
/>
<div class="d-flex justify-center pb-6 mt-n1">
<div style="width: 500px">
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear
:value="pwStrength.score.value"
class="rounded-lg"
:color="pwStrength.color.value"
height="15"
/>
</div>
</div>
<v-text-field
v-model="credentials.password2.value"
v-bind="inputAttrs"
:type="pwFields.inputType.value"
:append-icon="pwFields.passwordIcon.value"
:prepend-icon="$globals.icons.lock"
:label="$tc('user.confirm-password')"
:rules="[validators.required, credentials.passwordMatch]"
@click:append="pwFields.togglePasswordShow"
/>
<div class="px-2">
<v-checkbox
v-model="accountDetails.advancedOptions.value"
:label="$tc('user.enable-advanced-content')"
/>
<p class="text-caption mt-n4">
{{ $tc("user.enable-advanced-content-description") }}
</p>
</div>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="justify-space-between">
<BaseButton cancel @click="state.back">
<template #icon> {{ $globals.icons.back }}</template>
{{ $t("general.back") }}
</BaseButton>
<BaseButton icon-right @click="accountDetails.next">
<template #icon> {{ $globals.icons.forward }}</template>
{{ $t("general.next") }}
</BaseButton>
</v-card-actions>
</div>
</template>
<template v-else-if="state.ctx.state === States.Confirmation">
<div class="preferred-width">
<v-card-title class="mb-0 pb-0">
<v-icon large class="mr-3"> {{ $globals.icons.user }}</v-icon>
<span class="headline">{{ $t("general.confirm") }}</span>
</v-card-title>
<v-list>
<template v-for="(item, idx) in confirmationData.value">
<v-list-item v-if="item.display" :key="idx">
<v-list-item-content>
<v-list-item-title> {{ item.text }} </v-list-item-title>
<v-list-item-subtitle> {{ item.value }} </v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider v-if="idx !== confirmationData.value.length - 1" :key="`divider-${idx}`" />
</template>
</v-list>
<v-divider />
<v-card-actions class="justify-space-between">
<BaseButton cancel @click="state.back">
<template #icon> {{ $globals.icons.back }}</template>
{{ $t("general.back") }}
</BaseButton>
<BaseButton @click="submitRegistration">
<template #icon> {{ $globals.icons.check }}</template>
{{ $t("general.submit") }}
</BaseButton>
</v-card-actions>
</div>
</template>
</div>
<v-card-actions class="justify-center flex-column py-8">
<v-btn text class="mb-2" to="/login"> Login </v-btn>
<BaseButton large color="primary" @click="langDialog = true">
<template #icon> {{ $globals.icons.translate }}</template>
{{ $t("language-dialog.choose-language") }}
</BaseButton>
</v-card-actions>
</v-card>
</v-container>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, useRouter, Ref, useContext } from "@nuxtjs/composition-api";
import { useDark } from "@vueuse/core";
import { computed } from "@vue/reactivity";
import { States, RegistrationType, useRegistration } from "./states";
import { useRouteQuery } from "~/composables/use-router";
import { validators, useAsyncValidator } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { CreateUserRegistration } from "~/types/api-types/user";
import { VForm } from "~/types/vuetify";
import { usePasswordField, usePasswordStrength } from "~/composables/use-passwords";
import { usePublicApi } from "~/composables/api/api-client";
import { useLocales } from "~/composables/use-locales";
const inputAttrs = {
filled: true,
rounded: true,
validateOnBlur: true,
class: "rounded-lg",
};
export default defineComponent({
layout: "basic",
setup() {
const { i18n } = useContext();
const isDark = useDark();
function safeValidate(form: Ref<VForm | null>) {
if (form.value && form.value.validate) {
return form.value.validate();
}
return false;
}
// ================================================================
// Registration Context
//
// State is used to manage the registration process states and provide
// a state machine esq interface to interact with the registration workflow.
const state = useRegistration();
// ================================================================
// Handle Token URL / Initialization
//
const token = useRouteQuery("token");
// TODO: We need to have some way to check to see if the site is in a state
// Where it needs to be initialized with a user, in that case we'll handle that
// somewhere...
function initialUser() {
return false;
}
onMounted(() => {
if (token.value) {
state.setState(States.ProvideAccountDetails);
state.setType(RegistrationType.JoinGroup);
}
if (initialUser()) {
state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.InitialGroup);
}
});
// ================================================================
// Initial
const initial = {
createGroup: () => {
state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.CreateGroup);
if (token.value != null) {
token.value = null;
}
},
joinGroup: () => {
state.setState(States.ProvideToken);
state.setType(RegistrationType.JoinGroup);
},
};
// ================================================================
// Provide Token
const domTokenForm = ref<VForm | null>(null);
function validateToken() {
return true;
}
const provideToken = {
next: () => {
if (!safeValidate(domTokenForm as Ref<VForm>)) {
return;
}
if (validateToken()) {
state.setState(States.ProvideAccountDetails);
}
},
};
// ================================================================
// Provide Group Details
const publicApi = usePublicApi();
const domGroupForm = ref<VForm | null>(null);
const groupName = ref("");
const groupSeed = ref(false);
const groupPrivate = ref(false);
const groupErrorMessages = ref<string[]>([]);
const { validate: validGroupName, valid: groupNameValid } = useAsyncValidator(
groupName,
(v: string) => publicApi.validators.group(v),
i18n.tc("validation.group-name-is-taken"),
groupErrorMessages
);
const groupDetails = {
groupName,
groupSeed,
groupPrivate,
next: () => {
if (!safeValidate(domGroupForm as Ref<VForm>) || !groupNameValid.value) {
return;
}
state.setState(States.ProvideAccountDetails);
},
};
// ================================================================
// Provide Account Details
const domAccountForm = ref<VForm | null>(null);
const username = ref("");
const email = ref("");
const advancedOptions = ref(false);
const usernameErrorMessages = ref<string[]>([]);
const { validate: validateUsername, valid: validUsername } = useAsyncValidator(
username,
(v: string) => publicApi.validators.username(v),
i18n.tc("validation.username-is-taken"),
usernameErrorMessages
);
const emailErrorMessages = ref<string[]>([]);
const { validate: validateEmail, valid: validEmail } = useAsyncValidator(
email,
(v: string) => publicApi.validators.email(v),
i18n.tc("validation.email-is-taken"),
emailErrorMessages
);
const accountDetails = {
username,
email,
advancedOptions,
next: () => {
if (!safeValidate(domAccountForm as Ref<VForm>) || !validUsername.value || !validEmail.value) {
return;
}
state.setState(States.Confirmation);
},
};
// ================================================================
// Provide Credentials
const password1 = ref("");
const password2 = ref("");
const pwStrength = usePasswordStrength(password1);
const pwFields = usePasswordField();
const passwordMatch = () => password1.value === password2.value || i18n.tc("user.password-must-match");
const credentials = {
password1,
password2,
passwordMatch,
};
// ================================================================
// Locale
const { locale } = useLocales();
const langDialog = ref(false);
// ================================================================
// Confirmation
const confirmationData = computed(() => {
return [
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.tc("group.group"),
value: groupName.value,
},
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.tc("data-pages.seed-data"),
value: groupSeed.value ? i18n.tc("general.yes") : i18n.tc("general.no"),
},
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.tc("group.settings.keep-my-recipes-private"),
value: groupPrivate.value ? i18n.tc("general.yes") : i18n.tc("general.no"),
},
{
display: true,
text: i18n.tc("user.email"),
value: email.value,
},
{
display: true,
text: i18n.tc("user.username"),
value: username.value,
},
{
display: true,
text: i18n.tc("user.enable-advanced-content"),
value: advancedOptions.value ? i18n.tc("general.yes") : i18n.tc("general.no"),
},
];
});
const api = useUserApi();
const router = useRouter();
async function submitRegistration() {
const payload: CreateUserRegistration = {
email: email.value,
username: username.value,
password: password1.value,
passwordConfirm: password2.value,
locale: locale.value,
seedData: groupSeed.value,
};
if (state.ctx.type === RegistrationType.CreateGroup) {
payload.group = groupName.value;
payload.advanced = advancedOptions.value;
payload.private = groupPrivate.value;
} else {
payload.groupToken = token.value;
}
const { response } = await api.register.register(payload);
if (response?.status === 201) {
alert.success("Registration Success");
router.push("/login");
}
}
return {
accountDetails,
confirmationData,
credentials,
emailErrorMessages,
groupDetails,
groupErrorMessages,
initial,
inputAttrs,
isDark,
langDialog,
provideToken,
pwFields,
pwStrength,
RegistrationType,
state,
States,
token,
usernameErrorMessages,
validators,
submitRegistration,
// Validators
validGroupName,
validateUsername,
validateEmail,
// Dom Refs
domAccountForm,
domGroupForm,
domTokenForm,
};
},
});
</script>
<style lang="css" scoped>
.icon-primary {
fill: var(--v-primary-base);
}
.icon-white {
fill: white;
}
.icon-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
position: relative;
margin-top: 2.5rem;
}
.icon-divider {
width: 100%;
margin-bottom: -2.5rem;
}
.icon-avatar {
border-color: rgba(0, 0, 0, 0.12);
border: 2px;
}
.bg-off-white {
background: #f5f8fa;
}
.preferred-width {
width: 840px;
}
</style>

View File

@ -0,0 +1,66 @@
import { reactive } from "@nuxtjs/composition-api";
export enum States {
Initial,
ProvideToken,
ProvideGroupDetails,
ProvideCredentials,
ProvideAccountDetails,
SelectGroupOptions,
Confirmation,
}
export enum RegistrationType {
Unknown,
JoinGroup,
CreateGroup,
InitialGroup,
}
interface Context {
state: States;
type: RegistrationType;
}
interface RegistrationContext {
ctx: Context;
setState(state: States): void;
setType(type: RegistrationType): void;
back(): void;
}
export function useRegistration(): RegistrationContext {
const context = reactive({
state: States.Initial,
type: RegistrationType.Unknown,
history: [
{
state: States.Initial,
},
],
});
function saveHistory() {
context.history.push({
state: context.state,
});
}
const back = () => {
const last = context.history.pop();
if (last) {
context.state = last.state;
}
};
const setState = (state: States) => {
saveHistory();
context.state = state;
};
const setType = (t: RegistrationType) => {
context.type = t;
};
return { ctx: context, setType, setState, back };
}

View File

@ -28,6 +28,8 @@ export interface CreateUserRegistration {
passwordConfirm: string; passwordConfirm: string;
advanced?: boolean; advanced?: boolean;
private?: boolean; private?: boolean;
seedData?: boolean;
locale?: string;
} }
export interface ForgotPassword { export interface ForgotPassword {
email: string; email: string;

View File

@ -159,7 +159,7 @@ class RepositoryGeneric(Generic[T, D]):
if any_case: if any_case:
search_attr = getattr(self.sql_model, key) search_attr = getattr(self.sql_model, key)
q = q.filter(func.lower(search_attr) == key.lower()).filter_by(**self._filter_builder()) q = q.filter(func.lower(search_attr) == str(value).lower()).filter_by(**self._filter_builder())
else: else:
q = self.session.query(self.sql_model).filter_by(**self._filter_builder(**{key: value})) q = self.session.query(self.sql_model).filter_by(**self._filter_builder(**{key: value}))

View File

@ -22,5 +22,5 @@ class RegistrationController(BasePublicController):
status_code=status.HTTP_403_FORBIDDEN, detail=ErrorResponse.respond("User Registration is Disabled") status_code=status.HTTP_403_FORBIDDEN, detail=ErrorResponse.respond("User Registration is Disabled")
) )
registration_service = RegistrationService(self.deps.logger, get_repositories(self.deps.session)) registration_service = RegistrationService(self.deps.logger, get_repositories(self.deps.session), self.deps.t)
return registration_service.register_user(data) return registration_service.register_user(data)

View File

@ -1,6 +1,7 @@
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from slugify import slugify
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
@ -10,15 +11,23 @@ from mealie.schema.response import ValidationResponse
router = APIRouter() router = APIRouter()
@router.get("/user/{name}", response_model=ValidationResponse) @router.get("/user/name", response_model=ValidationResponse)
def validate_user(name: str, session: Session = Depends(generate_session)): def validate_user(name: str, session: Session = Depends(generate_session)):
"""Checks if a user with the given name exists""" """Checks if a user with the given name exists"""
db = get_repositories(session) db = get_repositories(session)
existing_element = db.users.get_by_username(name) existing_element = db.users.get_one(name, "username", any_case=True)
return ValidationResponse(valid=existing_element is None) return ValidationResponse(valid=existing_element is None)
@router.get("/group/{name}", response_model=ValidationResponse) @router.get("/user/email", response_model=ValidationResponse)
def validate_user_email(email: str, session: Session = Depends(generate_session)):
"""Checks if a user with the given name exists"""
db = get_repositories(session)
existing_element = db.users.get_one(email, "email", any_case=True)
return ValidationResponse(valid=existing_element is None)
@router.get("/group", response_model=ValidationResponse)
def validate_group(name: str, session: Session = Depends(generate_session)): def validate_group(name: str, session: Session = Depends(generate_session)):
"""Checks if a group with the given name exists""" """Checks if a group with the given name exists"""
db = get_repositories(session) db = get_repositories(session)
@ -26,9 +35,10 @@ def validate_group(name: str, session: Session = Depends(generate_session)):
return ValidationResponse(valid=existing_element is None) return ValidationResponse(valid=existing_element is None)
@router.get("/recipe/{group_id}/{slug}", response_model=ValidationResponse) @router.get("/recipe", response_model=ValidationResponse)
def validate_recipe(group_id: UUID, slug: str, session: Session = Depends(generate_session)): def validate_recipe(group_id: UUID, name: str, session: Session = Depends(generate_session)):
"""Checks if a group with the given slug exists""" """Checks if a group with the given slug exists"""
db = get_repositories(session) db = get_repositories(session)
slug = slugify(name)
existing_element = db.recipes.get_by_slug(group_id, slug) existing_element = db.recipes.get_by_slug(group_id, slug)
return ValidationResponse(valid=existing_element is None) return ValidationResponse(valid=existing_element is None)

View File

@ -0,0 +1,38 @@
def validate_locale(locale: str) -> bool:
valid = {
"el-GR",
"it-IT",
"ko-KR",
"es-ES",
"ja-JP",
"zh-CN",
"tr-TR",
"ar-SA",
"hu-HU",
"pt-PT",
"no-NO",
"sv-SE",
"ro-RO",
"sk-SK",
"uk-UA",
"fr-CA",
"pl-PL",
"da-DK",
"pt-BR",
"de-DE",
"ca-ES",
"sr-SP",
"cs-CZ",
"fr-FR",
"zh-TW",
"af-ZA",
"ru-RU",
"he-IL",
"nl-NL",
"en-US",
"en-GB",
"fi-FI",
"vi-VN",
}
return locale in valid

View File

@ -1,46 +1,7 @@
from pydantic import validator from pydantic import validator
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema._mealie.validators import validate_locale
def validate_locale(locale: str) -> bool:
valid = {
"el-GR",
"it-IT",
"ko-KR",
"es-ES",
"ja-JP",
"zh-CN",
"tr-TR",
"ar-SA",
"hu-HU",
"pt-PT",
"no-NO",
"sv-SE",
"ro-RO",
"sk-SK",
"uk-UA",
"fr-CA",
"pl-PL",
"da-DK",
"pt-BR",
"de-DE",
"ca-ES",
"sr-SP",
"cs-CZ",
"fr-FR",
"zh-TW",
"af-ZA",
"ru-RU",
"he-IL",
"nl-NL",
"en-US",
"en-GB",
"fi-FI",
"vi-VN",
}
return locale in valid
class SeederConfig(MealieModel): class SeederConfig(MealieModel):
@ -49,5 +10,5 @@ class SeederConfig(MealieModel):
@validator("locale") @validator("locale")
def valid_locale(cls, v, values, **kwargs): def valid_locale(cls, v, values, **kwargs):
if not validate_locale(v): if not validate_locale(v):
raise ValueError("passwords do not match") raise ValueError("invalid locale")
return v return v

View File

@ -2,6 +2,7 @@ from pydantic import validator
from pydantic.types import NoneStr, constr from pydantic.types import NoneStr, constr
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.validators import validate_locale
class CreateUserRegistration(MealieModel): class CreateUserRegistration(MealieModel):
@ -14,6 +15,15 @@ class CreateUserRegistration(MealieModel):
advanced: bool = False advanced: bool = False
private: bool = False private: bool = False
seed_data: bool = False
locale: str = "en-US"
@validator("locale")
def valid_locale(cls, v, values, **kwargs):
if not validate_locale(v):
raise ValueError("invalid locale")
return v
@validator("password_confirm") @validator("password_confirm")
@classmethod @classmethod
def passwords_match(cls, value, values): def passwords_match(cls, value, values):
@ -24,7 +34,7 @@ class CreateUserRegistration(MealieModel):
@validator("group_token", always=True) @validator("group_token", always=True)
@classmethod @classmethod
def group_or_token(cls, value, values): def group_or_token(cls, value, values):
if bool(value) is False and bool(values["group"]) is False: if not bool(value) and not bool(values["group"]):
raise ValueError("group or group_token must be provided") raise ValueError("group or group_token must be provided")
return value return value

View File

@ -4,22 +4,23 @@ from uuid import uuid4
from fastapi import HTTPException, status from fastapi import HTTPException, status
from mealie.core.security import hash_password from mealie.core.security import hash_password
from mealie.lang import local_provider from mealie.lang.providers import Translator
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_preferences import CreateGroupPreferences from mealie.schema.group.group_preferences import CreateGroupPreferences
from mealie.schema.user.registration import CreateUserRegistration from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn
from mealie.services.group_services.group_service import GroupService from mealie.services.group_services.group_service import GroupService
from mealie.services.seeder.seeder_service import SeederService
class RegistrationService: class RegistrationService:
logger: Logger logger: Logger
repos: AllRepositories repos: AllRepositories
def __init__(self, logger: Logger, db: AllRepositories): def __init__(self, logger: Logger, db: AllRepositories, t: Translator):
self.logger = logger self.logger = logger
self.repos = db self.repos = db
self.t = local_provider() self.t = t
def _create_new_user(self, group: GroupInDB, new_group: bool) -> PrivateUser: def _create_new_user(self, group: GroupInDB, new_group: bool) -> PrivateUser:
new_user = UserIn( new_user = UserIn(
@ -79,6 +80,14 @@ class RegistrationService:
user = self._create_new_user(group, new_group) user = self._create_new_user(group, new_group)
if new_group and registration.seed_data:
seeder_service = SeederService(self.repos, user, group)
seeder_service.seed_foods(registration.locale)
seeder_service.seed_labels(registration.locale)
seeder_service.seed_units(registration.locale)
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

View File

@ -1,46 +1,97 @@
from dataclasses import dataclass
from uuid import UUID
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.db.db_setup import create_session from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe import Recipe
from tests.utils import random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
class Routes: class Routes:
user = "/api/validators/user" base = "/api/validators"
recipe = "/api/validators/recipe"
@staticmethod
def username(username: str):
return f"{Routes.base}/user/name?name={username}"
@staticmethod
def email(email: str):
return f"{Routes.base}/user/email?email={email}"
@staticmethod
def group(group_name: str):
return f"{Routes.base}/group?name={group_name}"
@staticmethod
def recipe(group_id, name) -> str:
return f"{Routes.base}/recipe?group_id={group_id}&name={name}"
def test_validators_user(api_client: TestClient, unique_user: TestUser): @dataclass(slots=True)
session = create_session() class SimpleCase:
value: str
is_valid: bool
# Test existing user
response = api_client.get(Routes.user + f"/{unique_user.username}")
assert response.status_code == 200
response_data = response.json()
assert not response_data["valid"]
# Test non-existing user def test_validators_username(api_client: TestClient, unique_user: TestUser):
response = api_client.get(Routes.user + f"/{unique_user.username}2") users = [
assert response.status_code == 200 SimpleCase(value=unique_user.username, is_valid=False),
response_data = response.json() SimpleCase(value=random_string(), is_valid=True),
assert response_data["valid"] ]
session.close() for user in users:
response = api_client.get(Routes.username(user.value))
assert response.status_code == 200
response_data = response.json()
assert response_data["valid"] == user.is_valid
def test_validators_email(api_client: TestClient, unique_user: TestUser):
emails = [
SimpleCase(value=unique_user.email, is_valid=False),
SimpleCase(value=f"{random_string()}@email.com", is_valid=True),
]
for user in emails:
response = api_client.get(Routes.email(user.value))
assert response.status_code == 200
response_data = response.json()
assert response_data["valid"] == user.is_valid
def test_validators_group_name(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
group = database.groups.get_one(unique_user.group_id)
groups = [
SimpleCase(value=group.name, is_valid=False),
SimpleCase(value=random_string(), is_valid=True),
]
for user in groups:
response = api_client.get(Routes.group(user.value))
assert response.status_code == 200
response_data = response.json()
assert response_data["valid"] == user.is_valid
@dataclass(slots=True)
class RecipeValidators:
name: str
group: UUID | str
is_valid: bool
def test_validators_recipe(api_client: TestClient, random_recipe: Recipe): def test_validators_recipe(api_client: TestClient, random_recipe: Recipe):
session = create_session() recipes = [
RecipeValidators(name=random_recipe.name, group=random_recipe.group_id, is_valid=False),
RecipeValidators(name=random_string(), group=random_recipe.group_id, is_valid=True),
RecipeValidators(name=random_string(), group=random_recipe.group_id, is_valid=True),
]
# Test existing user for recipe in recipes:
response = api_client.get(Routes.recipe + f"/{random_recipe.group_id}/{random_recipe.slug}") response = api_client.get(Routes.recipe(recipe.group, recipe.name))
assert response.status_code == 200 assert response.status_code == 200
response_data = response.json() response_data = response.json()
assert not response_data["valid"] assert response_data["valid"] == recipe.is_valid
# Test non-existing user
response = api_client.get(Routes.recipe + f"/{random_recipe.group_id}/{random_recipe.slug}-test")
assert response.status_code == 200
response_data = response.json()
assert response_data["valid"]
session.close()