mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -04:00 
			
		
		
		
	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:
		| @@ -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); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								frontend/api/public-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/api/public-api.ts
									
									
									
									
									
										Normal 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); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								frontend/api/public/validators.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								frontend/api/public/validators.ts
									
									
									
									
									
										Normal 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)); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -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]); | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
|   | |||||||
| @@ -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); | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -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, |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
							
								
								
									
										94
									
								
								frontend/composables/use-passwords.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								frontend/composables/use-passwords.ts
									
									
									
									
									
										Normal 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 }; | ||||||
|  | }; | ||||||
| @@ -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 }; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -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" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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({ | ||||||
|   | |||||||
| @@ -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> |  | ||||||
							
								
								
									
										2
									
								
								frontend/pages/register/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								frontend/pages/register/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | import Register from "./register.vue"; | ||||||
|  | export default Register; | ||||||
							
								
								
									
										603
									
								
								frontend/pages/register/register.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										603
									
								
								frontend/pages/register/register.vue
									
									
									
									
									
										Normal 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> | ||||||
							
								
								
									
										66
									
								
								frontend/pages/register/states.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								frontend/pages/register/states.ts
									
									
									
									
									
										Normal 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 }; | ||||||
|  | } | ||||||
| @@ -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; | ||||||
|   | |||||||
| @@ -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})) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
							
								
								
									
										38
									
								
								mealie/schema/_mealie/validators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								mealie/schema/_mealie/validators.py
									
									
									
									
									
										Normal 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 | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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}") | def test_validators_username(api_client: TestClient, unique_user: TestUser): | ||||||
|  |     users = [ | ||||||
|  |         SimpleCase(value=unique_user.username, is_valid=False), | ||||||
|  |         SimpleCase(value=random_string(), is_valid=True), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     for user in users: | ||||||
|  |         response = api_client.get(Routes.username(user.value)) | ||||||
|         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"] == user.is_valid | ||||||
|  |  | ||||||
|     # Test non-existing user |  | ||||||
|     response = api_client.get(Routes.user + f"/{unique_user.username}2") | 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 |         assert response.status_code == 200 | ||||||
|         response_data = response.json() |         response_data = response.json() | ||||||
|     assert response_data["valid"] |         assert response_data["valid"] == user.is_valid | ||||||
|  |  | ||||||
|     session.close() |  | ||||||
|  | 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() |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user