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:
parent
6ee9a31c92
commit
7e4da3e5a4
23 changed files with 1056 additions and 316 deletions
|
@ -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}")
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert not response_data["valid"]
|
||||
|
||||
# Test non-existing user
|
||||
response = api_client.get(Routes.user + f"/{unique_user.username}2")
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["valid"]
|
||||
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),
|
||||
]
|
||||
|
||||
session.close()
|
||||
for user in users:
|
||||
response = api_client.get(Routes.username(user.value))
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["valid"] == user.is_valid
|
||||
|
||||
|
||||
def test_validators_email(api_client: TestClient, unique_user: TestUser):
|
||||
emails = [
|
||||
SimpleCase(value=unique_user.email, is_valid=False),
|
||||
SimpleCase(value=f"{random_string()}@email.com", is_valid=True),
|
||||
]
|
||||
|
||||
for user in emails:
|
||||
response = api_client.get(Routes.email(user.value))
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["valid"] == user.is_valid
|
||||
|
||||
|
||||
def test_validators_group_name(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
|
||||
group = database.groups.get_one(unique_user.group_id)
|
||||
|
||||
groups = [
|
||||
SimpleCase(value=group.name, is_valid=False),
|
||||
SimpleCase(value=random_string(), is_valid=True),
|
||||
]
|
||||
|
||||
for user in groups:
|
||||
response = api_client.get(Routes.group(user.value))
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["valid"] == user.is_valid
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RecipeValidators:
|
||||
name: str
|
||||
group: UUID | str
|
||||
is_valid: bool
|
||||
|
||||
|
||||
def test_validators_recipe(api_client: TestClient, random_recipe: Recipe):
|
||||
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}")
|
||||
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()
|
||||
for recipe in recipes:
|
||||
response = api_client.get(Routes.recipe(recipe.group, recipe.name))
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["valid"] == recipe.is_valid
|
||||
|
|
Loading…
Reference in a new issue