feat: improved registration signup flow (#1188)

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

View file

@ -1,14 +1,5 @@
import { BaseAPI } from "../_base";
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);
}
}

View file

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

View file

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

View file

@ -183,9 +183,10 @@ export default defineComponent({
return [];
}
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]);
}
});

View file

@ -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);
};

View file

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

View file

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

View file

@ -1,14 +1,46 @@
import { ref, Ref } from "@nuxtjs/composition-api";
import { RequestResponse } from "~/types/api";
import { Validation } from "~/api/public/validators";
const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\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 };
};

View file

@ -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"
}
}

View file

@ -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({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}))

View file

@ -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)

View file

@ -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)

View file

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

View file

@ -1,46 +1,7 @@
from pydantic import validator
from 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

View file

@ -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

View file

@ -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

View file

@ -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