mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -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"; | ||||
|  | ||||
| export interface RegisterPayload { | ||||
|   group: string; | ||||
|   groupToken: string; | ||||
|   email: string; | ||||
|   password: string; | ||||
|   passwordConfirm: string; | ||||
|   advanced: boolean; | ||||
|   private: boolean; | ||||
| } | ||||
| import { CreateUserRegistration } from "~/types/api-types/user"; | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| @@ -19,7 +10,7 @@ const routes = { | ||||
| export class RegisterAPI extends BaseAPI { | ||||
|   /** 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); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										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 []; | ||||
|       } | ||||
|  | ||||
|       const list = [] as ((v: string) => (boolean | string))[]; | ||||
|       const list = [] as ((v: string) => boolean | string)[]; | ||||
|       keys.forEach((key) => { | ||||
|         if (key in validators) { | ||||
|           // @ts-ignore TODO: fix this | ||||
|           list.push(validators[key]); | ||||
|         } | ||||
|       }); | ||||
|   | ||||
| @@ -3,9 +3,14 @@ import { useContext } from "@nuxtjs/composition-api"; | ||||
| import { NuxtAxiosInstance } from "@nuxtjs/axios"; | ||||
| import { AdminAPI, Api } from "~/api"; | ||||
| import { ApiRequestInstance, RequestResponse } from "~/types/api"; | ||||
| import { PublicApi } from "~/api/public-api"; | ||||
|  | ||||
| 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; | ||||
|     const response = await funcCall(url, data).catch(function (e) { | ||||
|       console.log(e); | ||||
| @@ -66,6 +71,13 @@ export const useUserApi = function (): Api { | ||||
|   $axios.setHeader("Accept-Language", i18n.locale); | ||||
|  | ||||
|   const requests = getRequests($axios); | ||||
|  | ||||
|   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 = | ||||
|   /^(([^<>()[\]\\.,;:\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()@:%_+.~#?&//=]*)/; | ||||
|  | ||||
| export const validators: {[key: string]: (v: string) => boolean | string} = { | ||||
| export const validators = { | ||||
|   required: (v: string) => !!v || "This Field is Required", | ||||
|   email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid", | ||||
|   whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed", | ||||
|   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`, | ||||
|   // maxLength: (max: number) => (v: string) => !v || v.length <= max || `Must Be At Most ${max} 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`, | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * 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", | ||||
|     "yes": "Yes", | ||||
|     "foods": "Foods", | ||||
|     "units": "Units" | ||||
|     "units": "Units", | ||||
|     "back": "Back", | ||||
|     "next": "Next" | ||||
|   }, | ||||
|   "group": { | ||||
|     "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", | ||||
|     "user-group": "User Group", | ||||
|     "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": { | ||||
|     "create-a-new-meal-plan": "Create a New Meal Plan", | ||||
| @@ -281,9 +287,7 @@ | ||||
|     "sugar-content": "Sugar", | ||||
|     "title": "Title", | ||||
|     "total-time": "Total Time", | ||||
|     "unable-to-delete-recipe": "Unable to Delete Recipe" | ||||
|   }, | ||||
|   "reicpe": { | ||||
|     "unable-to-delete-recipe": "Unable to Delete Recipe", | ||||
|     "no-recipe": "No Recipe" | ||||
|   }, | ||||
|   "search": { | ||||
| @@ -473,6 +477,7 @@ | ||||
|     "password-reset-failed": "Password reset failed", | ||||
|     "password-updated": "Password updated", | ||||
|     "password": "Password", | ||||
|     "password-strength": "Password is {strength}", | ||||
|     "register": "Register", | ||||
|     "reset-password": "Reset Password", | ||||
|     "sign-in": "Sign in", | ||||
| @@ -496,7 +501,9 @@ | ||||
|     "webhook-time": "Webhook Time", | ||||
|     "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-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": { | ||||
|     "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-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 { useDark } from "@vueuse/core"; | ||||
| 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 { useToggleDarkMode } from "~/composables/use-utils"; | ||||
| 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; | ||||
|   advanced?: boolean; | ||||
|   private?: boolean; | ||||
|   seedData?: boolean; | ||||
|   locale?: string; | ||||
| } | ||||
| export interface ForgotPassword { | ||||
|   email: string; | ||||
|   | ||||
| @@ -159,7 +159,7 @@ class RepositoryGeneric(Generic[T, D]): | ||||
|  | ||||
|         if any_case: | ||||
|             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: | ||||
|             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") | ||||
|             ) | ||||
|  | ||||
|         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) | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| from uuid import UUID | ||||
|  | ||||
| from fastapi import APIRouter, Depends | ||||
| from slugify import slugify | ||||
| from sqlalchemy.orm.session import Session | ||||
|  | ||||
| from mealie.db.db_setup import generate_session | ||||
| @@ -10,15 +11,23 @@ from mealie.schema.response import ValidationResponse | ||||
| 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)): | ||||
|     """Checks if a user with the given name exists""" | ||||
|     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) | ||||
|  | ||||
|  | ||||
| @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)): | ||||
|     """Checks if a group with the given name exists""" | ||||
|     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) | ||||
|  | ||||
|  | ||||
| @router.get("/recipe/{group_id}/{slug}", response_model=ValidationResponse) | ||||
| def validate_recipe(group_id: UUID, slug: str, session: Session = Depends(generate_session)): | ||||
| @router.get("/recipe", response_model=ValidationResponse) | ||||
| def validate_recipe(group_id: UUID, name: str, session: Session = Depends(generate_session)): | ||||
|     """Checks if a group with the given slug exists""" | ||||
|     db = get_repositories(session) | ||||
|     slug = slugify(name) | ||||
|     existing_element = db.recipes.get_by_slug(group_id, slug) | ||||
|     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 mealie.schema._mealie.mealie_model import MealieModel | ||||
|  | ||||
|  | ||||
| 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 | ||||
| from mealie.schema._mealie.validators import validate_locale | ||||
|  | ||||
|  | ||||
| class SeederConfig(MealieModel): | ||||
| @@ -49,5 +10,5 @@ class SeederConfig(MealieModel): | ||||
|     @validator("locale") | ||||
|     def valid_locale(cls, v, values, **kwargs): | ||||
|         if not validate_locale(v): | ||||
|             raise ValueError("passwords do not match") | ||||
|             raise ValueError("invalid locale") | ||||
|         return v | ||||
|   | ||||
| @@ -2,6 +2,7 @@ from pydantic import validator | ||||
| from pydantic.types import NoneStr, constr | ||||
|  | ||||
| from mealie.schema._mealie import MealieModel | ||||
| from mealie.schema._mealie.validators import validate_locale | ||||
|  | ||||
|  | ||||
| class CreateUserRegistration(MealieModel): | ||||
| @@ -14,6 +15,15 @@ class CreateUserRegistration(MealieModel): | ||||
|     advanced: 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") | ||||
|     @classmethod | ||||
|     def passwords_match(cls, value, values): | ||||
| @@ -24,7 +34,7 @@ class CreateUserRegistration(MealieModel): | ||||
|     @validator("group_token", always=True) | ||||
|     @classmethod | ||||
|     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") | ||||
|  | ||||
|         return value | ||||
|   | ||||
| @@ -4,22 +4,23 @@ from uuid import uuid4 | ||||
| from fastapi import HTTPException, status | ||||
|  | ||||
| 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.schema.group.group_preferences import CreateGroupPreferences | ||||
| from mealie.schema.user.registration import CreateUserRegistration | ||||
| from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn | ||||
| from mealie.services.group_services.group_service import GroupService | ||||
| from mealie.services.seeder.seeder_service import SeederService | ||||
|  | ||||
|  | ||||
| class RegistrationService: | ||||
|     logger: Logger | ||||
|     repos: AllRepositories | ||||
|  | ||||
|     def __init__(self, logger: Logger, db: AllRepositories): | ||||
|     def __init__(self, logger: Logger, db: AllRepositories, t: Translator): | ||||
|         self.logger = logger | ||||
|         self.repos = db | ||||
|         self.t = local_provider() | ||||
|         self.t = t | ||||
|  | ||||
|     def _create_new_user(self, group: GroupInDB, new_group: bool) -> PrivateUser: | ||||
|         new_user = UserIn( | ||||
| @@ -79,6 +80,14 @@ class RegistrationService: | ||||
|  | ||||
|         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: | ||||
|             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 mealie.db.db_setup import create_session | ||||
| from mealie.repos.repository_factory import AllRepositories | ||||
| from mealie.schema.recipe.recipe import Recipe | ||||
| from tests.utils import random_string | ||||
| from tests.utils.fixture_schemas import TestUser | ||||
|  | ||||
|  | ||||
| class Routes: | ||||
|     user = "/api/validators/user" | ||||
|     recipe = "/api/validators/recipe" | ||||
|     base = "/api/validators" | ||||
|  | ||||
|     @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): | ||||
|     session = create_session() | ||||
| @dataclass(slots=True) | ||||
| 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 | ||||
|         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 | ||||
|         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): | ||||
|     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 | ||||
|     response = api_client.get(Routes.recipe + f"/{random_recipe.group_id}/{random_recipe.slug}") | ||||
|     for recipe in recipes: | ||||
|         response = api_client.get(Routes.recipe(recipe.group, recipe.name)) | ||||
|         assert response.status_code == 200 | ||||
|         response_data = response.json() | ||||
|     assert not response_data["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() | ||||
|         assert response_data["valid"] == recipe.is_valid | ||||
|   | ||||
		Reference in New Issue
	
	Block a user