mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-02 20:15:24 +02:00
feat: improved registration signup flow (#1188)
refactored signup flow for entire registration process. Utilized seed data option for optional seeding of Foods, Units, and Labels. Localized registration page.
This commit is contained in:
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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue