mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-24 15:49:42 +02:00
feat: First Time Setup Wizard (#3204)
* extract user registration form into a composable * added base wizard component * added partial setup implementation * removed unused attrs * added setup bypass * made setup page more readable * add checkbox hints to autoform * added common settings pages and initial submit logic * bypass setup in demo * add full name to user registration * added fullname and pw handling to setup * fixed wizard indentation * added post-setup suggestions * added tests for backend changes * renamed Wizard to BaseWizard * lint fixes * pass hardcoded default password instead of backend nonsense * removed old test * fix e2e * added setup skip to e2e testing for all admin users --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
parent
430e1d7d4e
commit
403038a5b2
25 changed files with 1103 additions and 141 deletions
160
frontend/components/Domain/User/UserRegistrationForm.vue
Normal file
160
frontend/components/Domain/User/UserRegistrationForm.vue
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
<template>
|
||||||
|
<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.fullName.value"
|
||||||
|
v-bind="inputAttrs"
|
||||||
|
:label="$tc('user.full-name')"
|
||||||
|
:prepend-icon="$globals.icons.user"
|
||||||
|
:rules="[validators.required]"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserPasswordStrength :value="credentials.password1.value" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||||
|
import { useDark } from "@vueuse/core";
|
||||||
|
import { validators } from "~/composables/use-validators";
|
||||||
|
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
||||||
|
import { usePasswordField } from "~/composables/use-passwords";
|
||||||
|
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
|
||||||
|
|
||||||
|
const inputAttrs = {
|
||||||
|
filled: true,
|
||||||
|
rounded: true,
|
||||||
|
validateOnBlur: true,
|
||||||
|
class: "rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: { UserPasswordStrength },
|
||||||
|
layout: "blank",
|
||||||
|
setup() {
|
||||||
|
const isDark = useDark();
|
||||||
|
const langDialog = ref(false);
|
||||||
|
|
||||||
|
const pwFields = usePasswordField();
|
||||||
|
const {
|
||||||
|
accountDetails,
|
||||||
|
credentials,
|
||||||
|
emailErrorMessages,
|
||||||
|
usernameErrorMessages,
|
||||||
|
validateUsername,
|
||||||
|
validateEmail,
|
||||||
|
domAccountForm,
|
||||||
|
} = useUserRegistrationForm();
|
||||||
|
return {
|
||||||
|
accountDetails,
|
||||||
|
credentials,
|
||||||
|
emailErrorMessages,
|
||||||
|
inputAttrs,
|
||||||
|
isDark,
|
||||||
|
langDialog,
|
||||||
|
pwFields,
|
||||||
|
usernameErrorMessages,
|
||||||
|
validators,
|
||||||
|
// Validators
|
||||||
|
validateUsername,
|
||||||
|
validateEmail,
|
||||||
|
// Dom Refs
|
||||||
|
domAccountForm,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</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>
|
|
@ -15,12 +15,22 @@
|
||||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||||
v-model="value[inputField.varName]"
|
v-model="value[inputField.varName]"
|
||||||
class="my-0 py-0"
|
class="my-0 py-0"
|
||||||
:label="inputField.label"
|
|
||||||
:name="inputField.varName"
|
:name="inputField.varName"
|
||||||
:hint="inputField.hint || ''"
|
|
||||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||||
@change="emitBlur"
|
@change="emitBlur"
|
||||||
/>
|
>
|
||||||
|
<template #label>
|
||||||
|
<div>
|
||||||
|
<v-card-text class="text-body-1 my-0 py-0">
|
||||||
|
{{ inputField.label }}
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-text v-if="inputField.hint" class="text-caption my-0 py-0">
|
||||||
|
{{ inputField.hint }}
|
||||||
|
</v-card-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-checkbox>
|
||||||
|
|
||||||
|
|
||||||
<!-- Text Field -->
|
<!-- Text Field -->
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
|
259
frontend/components/global/BaseWizard.vue
Normal file
259
frontend/components/global/BaseWizard.vue
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
<template>
|
||||||
|
<div :style="`width: ${width}; height: 100%;`">
|
||||||
|
<LanguageDialog v-model="langDialog" />
|
||||||
|
<v-card>
|
||||||
|
<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>
|
||||||
|
<div class="d-flex justify-center grow items-center my-4">
|
||||||
|
<slot :width="pageWidth"></slot>
|
||||||
|
</div>
|
||||||
|
<div class="mx-2 my-4">
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="value > 0"
|
||||||
|
:value="Math.ceil((value/maxPageNumber)*100)"
|
||||||
|
striped
|
||||||
|
height="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<v-divider class="ma-2" />
|
||||||
|
<v-card-actions width="100%">
|
||||||
|
<v-btn
|
||||||
|
v-if="prevButtonShow"
|
||||||
|
:disabled="!prevButtonEnable"
|
||||||
|
:color="prevButtonColor"
|
||||||
|
@click="decrementPage"
|
||||||
|
>
|
||||||
|
<v-icon v-if="prevButtonIconRef">
|
||||||
|
{{ prevButtonIconRef }}
|
||||||
|
</v-icon>
|
||||||
|
{{ prevButtonTextRef }}
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="nextButtonShow"
|
||||||
|
:disabled="!nextButtonEnable"
|
||||||
|
:color="nextButtonColorRef"
|
||||||
|
@click="incrementPage"
|
||||||
|
>
|
||||||
|
<div v-if="isSubmitting">
|
||||||
|
<v-progress-circular indeterminate color="white" size="24" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<v-icon v-if="nextButtonIconRef && !nextButtonIconAfter">
|
||||||
|
{{ nextButtonIconRef }}
|
||||||
|
</v-icon>
|
||||||
|
{{ nextButtonTextRef }}
|
||||||
|
<v-icon v-if="nextButtonIconRef && nextButtonIconAfter">
|
||||||
|
{{ nextButtonIconRef }}
|
||||||
|
</v-icon>
|
||||||
|
</div>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
<v-card-actions class="justify-center flex-column py-8">
|
||||||
|
<BaseButton large color="primary" @click="langDialog = true">
|
||||||
|
<template #icon> {{ $globals.icons.translate }}</template>
|
||||||
|
{{ $t("language-dialog.choose-language") }}
|
||||||
|
</BaseButton>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
minPageNumber: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
maxPageNumber: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: "1200px",
|
||||||
|
},
|
||||||
|
pageWidth: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: "600px",
|
||||||
|
},
|
||||||
|
prevButtonText: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
prevButtonIcon: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
prevButtonColor: {
|
||||||
|
type: String,
|
||||||
|
default: "grey-darken-3",
|
||||||
|
},
|
||||||
|
prevButtonShow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
prevButtonEnable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
nextButtonText: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
nextButtonIcon: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
nextButtonIconAfter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
nextButtonColor: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
nextButtonShow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
nextButtonEnable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
nextButtonIsSubmit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isSubmitting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props, context) {
|
||||||
|
const { $globals, i18n } = useContext();
|
||||||
|
const ready = ref(false);
|
||||||
|
const langDialog = ref(false);
|
||||||
|
|
||||||
|
const prevButtonTextRef = computed(() => props.prevButtonText || i18n.tc("general.back"));
|
||||||
|
const prevButtonIconRef = computed(() => props.prevButtonIcon || $globals.icons.back);
|
||||||
|
const nextButtonTextRef = computed(
|
||||||
|
() => props.nextButtonText || (
|
||||||
|
props.nextButtonIsSubmit ? i18n.tc("general.submit") : i18n.tc("general.next")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const nextButtonIconRef = computed(
|
||||||
|
() => props.nextButtonIcon || (
|
||||||
|
props.nextButtonIsSubmit ? $globals.icons.createAlt : $globals.icons.forward
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const nextButtonColorRef = computed(
|
||||||
|
() => props.nextButtonColor || (props.nextButtonIsSubmit ? "success" : "info")
|
||||||
|
);
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
if (page < props.minPageNumber) {
|
||||||
|
goToPage(props.minPageNumber);
|
||||||
|
return;
|
||||||
|
} else if (page > props.maxPageNumber) {
|
||||||
|
goToPage(props.maxPageNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.emit("input", page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementPage() {
|
||||||
|
goToPage(props.value - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementPage() {
|
||||||
|
if (props.nextButtonIsSubmit) {
|
||||||
|
context.emit("submit", props.value);
|
||||||
|
} else {
|
||||||
|
goToPage(props.value + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ready.value = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
langDialog,
|
||||||
|
prevButtonTextRef,
|
||||||
|
prevButtonIconRef,
|
||||||
|
nextButtonTextRef,
|
||||||
|
nextButtonIconRef,
|
||||||
|
nextButtonColorRef,
|
||||||
|
decrementPage,
|
||||||
|
incrementPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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>
|
30
frontend/composables/use-setup/common-settings-form.ts
Normal file
30
frontend/composables/use-setup/common-settings-form.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { fieldTypes } from "../forms";
|
||||||
|
import { AutoFormItems } from "~/types/auto-forms";
|
||||||
|
|
||||||
|
export const useCommonSettingsForm = () => {
|
||||||
|
const { i18n } = useContext();
|
||||||
|
|
||||||
|
const commonSettingsForm: AutoFormItems = [
|
||||||
|
{
|
||||||
|
section: i18n.tc("profile.group-settings"),
|
||||||
|
label: i18n.tc("group.enable-public-access"),
|
||||||
|
hint: i18n.tc("group.enable-public-access-description"),
|
||||||
|
varName: "makeGroupRecipesPublic",
|
||||||
|
type: fieldTypes.BOOLEAN,
|
||||||
|
rules: ["required"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: i18n.tc("data-pages.data-management"),
|
||||||
|
label: i18n.tc("user-registration.use-seed-data"),
|
||||||
|
hint: i18n.tc("user-registration.use-seed-data-description"),
|
||||||
|
varName: "useSeedData",
|
||||||
|
type: fieldTypes.BOOLEAN,
|
||||||
|
rules: ["required"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
commonSettingsForm,
|
||||||
|
}
|
||||||
|
}
|
1
frontend/composables/use-setup/index.ts
Normal file
1
frontend/composables/use-setup/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { useCommonSettingsForm } from "./common-settings-form";
|
|
@ -1 +1,2 @@
|
||||||
export { useUserForm } from "./user-form";
|
export { useUserForm } from "./user-form";
|
||||||
|
export { useUserRegistrationForm } from "./user-registration-form";
|
||||||
|
|
87
frontend/composables/use-users/user-registration-form.ts
Normal file
87
frontend/composables/use-users/user-registration-form.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { ref, Ref, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { useAsyncValidator } from "~/composables/use-validators";
|
||||||
|
import { VForm } from "~/types/vuetify";
|
||||||
|
import { usePublicApi } from "~/composables/api/api-client";
|
||||||
|
|
||||||
|
const domAccountForm = ref<VForm | null>(null);
|
||||||
|
const username = ref("");
|
||||||
|
const fullName = ref("");
|
||||||
|
const email = ref("");
|
||||||
|
const password1 = ref("");
|
||||||
|
const password2 = ref("");
|
||||||
|
const advancedOptions = ref(false);
|
||||||
|
|
||||||
|
export const useUserRegistrationForm = () => {
|
||||||
|
const { i18n } = useContext();
|
||||||
|
function safeValidate(form: Ref<VForm | null>) {
|
||||||
|
if (form.value && form.value.validate) {
|
||||||
|
return form.value.validate();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ================================================================
|
||||||
|
// Provide Group Details
|
||||||
|
const publicApi = usePublicApi();
|
||||||
|
// ================================================================
|
||||||
|
// Provide Account Details
|
||||||
|
|
||||||
|
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,
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
advancedOptions,
|
||||||
|
validate: async () => {
|
||||||
|
if (!(validUsername.value && validEmail.value)) {
|
||||||
|
await Promise.all([validateUsername(), validateEmail()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (safeValidate(domAccountForm as Ref<VForm>) && validUsername.value && validEmail.value);
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
accountDetails.username.value = "";
|
||||||
|
accountDetails.fullName.value = "";
|
||||||
|
accountDetails.email.value = "";
|
||||||
|
accountDetails.advancedOptions.value = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// ================================================================
|
||||||
|
// Provide Credentials
|
||||||
|
const passwordMatch = () => password1.value === password2.value || i18n.tc("user.password-must-match");
|
||||||
|
const credentials = {
|
||||||
|
password1,
|
||||||
|
password2,
|
||||||
|
passwordMatch,
|
||||||
|
reset: () => {
|
||||||
|
credentials.password1.value = "";
|
||||||
|
credentials.password2.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountDetails,
|
||||||
|
credentials,
|
||||||
|
emailErrorMessages,
|
||||||
|
usernameErrorMessages,
|
||||||
|
// Fields
|
||||||
|
advancedOptions,
|
||||||
|
// Validators
|
||||||
|
validateUsername,
|
||||||
|
validateEmail,
|
||||||
|
// Dom Refs
|
||||||
|
domAccountForm,
|
||||||
|
};
|
||||||
|
};
|
|
@ -85,6 +85,7 @@
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
"confirm-how-does-everything-look": "How does everything look?",
|
||||||
"confirm-delete-generic": "Are you sure you want to delete this?",
|
"confirm-delete-generic": "Are you sure you want to delete this?",
|
||||||
"copied_message": "Copied!",
|
"copied_message": "Copied!",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
|
@ -170,6 +171,7 @@
|
||||||
"units": "Units",
|
"units": "Units",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
|
"start": "Start",
|
||||||
"toggle-view": "Toggle View",
|
"toggle-view": "Toggle View",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"id": "Id",
|
"id": "Id",
|
||||||
|
@ -238,6 +240,8 @@
|
||||||
"group-preferences": "Group Preferences",
|
"group-preferences": "Group Preferences",
|
||||||
"private-group": "Private Group",
|
"private-group": "Private Group",
|
||||||
"private-group-description": "Setting your group to private will default all public view options to default. This overrides an individual recipes public view settings.",
|
"private-group-description": "Setting your group to private will default all public view options to default. This overrides an individual recipes public view settings.",
|
||||||
|
"enable-public-access": "Enable Public Access",
|
||||||
|
"enable-public-access-description": "Make group recipes public by default, and allow visitors to view recipes without logging-in",
|
||||||
"allow-users-outside-of-your-group-to-see-your-recipes": "Allow users outside of your group to see your recipes",
|
"allow-users-outside-of-your-group-to-see-your-recipes": "Allow users outside of your group to see your recipes",
|
||||||
"allow-users-outside-of-your-group-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your group or with a pre-generated private link",
|
"allow-users-outside-of-your-group-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your group or with a pre-generated private link",
|
||||||
"show-nutrition-information": "Show nutrition information",
|
"show-nutrition-information": "Show nutrition information",
|
||||||
|
@ -351,6 +355,7 @@
|
||||||
},
|
},
|
||||||
"recipe-data-migrations": "Recipe Data Migrations",
|
"recipe-data-migrations": "Recipe Data Migrations",
|
||||||
"recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.",
|
"recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.",
|
||||||
|
"coming-from-another-application-or-an-even-older-version-of-mealie": "Coming from another application or an even older version of Mealie? Check out migrations and see if your data can be imported.",
|
||||||
"choose-migration-type": "Choose Migration Type",
|
"choose-migration-type": "Choose Migration Type",
|
||||||
"tag-all-recipes": "Tag all recipes with {tag-name} tag",
|
"tag-all-recipes": "Tag all recipes with {tag-name} tag",
|
||||||
"nextcloud-text": "Nextcloud recipes can be imported from a zip file that contains the data stored in Nextcloud. See the example folder structure below to ensure your recipes are able to be imported.",
|
"nextcloud-text": "Nextcloud recipes can be imported from a zip file that contains the data stored in Nextcloud. See the example folder structure below to ensure your recipes are able to be imported.",
|
||||||
|
@ -533,6 +538,8 @@
|
||||||
"looking-for-migrations": "Looking For Migrations?",
|
"looking-for-migrations": "Looking For Migrations?",
|
||||||
"import-with-url": "Import with URL",
|
"import-with-url": "Import with URL",
|
||||||
"create-recipe": "Create Recipe",
|
"create-recipe": "Create Recipe",
|
||||||
|
"create-recipe-description": "Create a new recipe from scratch.",
|
||||||
|
"create-recipes": "Create Recipes",
|
||||||
"import-with-zip": "Import with .zip",
|
"import-with-zip": "Import with .zip",
|
||||||
"create-recipe-from-an-image": "Create recipe from an image",
|
"create-recipe-from-an-image": "Create recipe from an image",
|
||||||
"bulk-url-import": "Bulk URL Import",
|
"bulk-url-import": "Bulk URL Import",
|
||||||
|
@ -843,6 +850,7 @@
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"manage-users": "Manage Users",
|
"manage-users": "Manage Users",
|
||||||
|
"manage-users-description": "Create and manage users.",
|
||||||
"new-password": "New Password",
|
"new-password": "New Password",
|
||||||
"new-user": "New User",
|
"new-user": "New User",
|
||||||
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
|
"password-has-been-reset-to-the-default-password": "Password has been reset to the default password",
|
||||||
|
@ -1138,7 +1146,17 @@
|
||||||
"background-tasks": "Background Tasks",
|
"background-tasks": "Background Tasks",
|
||||||
"background-tasks-description": "Here you can view all the running background tasks and their status",
|
"background-tasks-description": "Here you can view all the running background tasks and their status",
|
||||||
"no-logs-found": "No Logs Found",
|
"no-logs-found": "No Logs Found",
|
||||||
"tasks": "Tasks"
|
"tasks": "Tasks",
|
||||||
|
"setup": {
|
||||||
|
"first-time-setup": "First Time Setup",
|
||||||
|
"welcome-to-mealie-get-started": "Welcome to Mealie! Let's get started",
|
||||||
|
"already-set-up-bring-to-homepage": "I'm already set up, just bring me to the homepage",
|
||||||
|
"common-settings-for-new-sites": "Here are some common settings for new sites",
|
||||||
|
"setup-complete": "Setup Complete!",
|
||||||
|
"here-are-a-few-things-to-help-you-get-started": "Here are a few things to help you get started with Mealie",
|
||||||
|
"restore-from-v1-backup": "Have a backup from a previous instance of Mealie v1? You can restore it here.",
|
||||||
|
"manage-profile-or-get-invite-link": "Manage your own profile, or grab an invite link to share with others."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"welcome-user": "👋 Welcome, {0}",
|
"welcome-user": "👋 Welcome, {0}",
|
||||||
|
|
|
@ -43,6 +43,7 @@ export interface AppInfo {
|
||||||
}
|
}
|
||||||
export interface AppStartupInfo {
|
export interface AppStartupInfo {
|
||||||
isFirstLogin: boolean;
|
isFirstLogin: boolean;
|
||||||
|
isDemo: boolean;
|
||||||
}
|
}
|
||||||
export interface AppStatistics {
|
export interface AppStatistics {
|
||||||
totalRecipes: number;
|
totalRecipes: number;
|
||||||
|
|
|
@ -22,6 +22,7 @@ export interface CreateUserRegistration {
|
||||||
groupToken?: string;
|
groupToken?: string;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
fullName: string;
|
||||||
password: string;
|
password: string;
|
||||||
passwordConfirm: string;
|
passwordConfirm: string;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
|
|
|
@ -8,8 +8,6 @@ const routes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RegisterAPI extends BaseAPI {
|
export class RegisterAPI extends BaseAPI {
|
||||||
/** Returns a list of available .zip files for import into Mealie.
|
|
||||||
*/
|
|
||||||
async register(payload: CreateUserRegistration) {
|
async register(payload: CreateUserRegistration) {
|
||||||
return await this.requests.post<any>(routes.register, payload);
|
return await this.requests.post<any>(routes.register, payload);
|
||||||
}
|
}
|
||||||
|
|
431
frontend/pages/admin/setup.vue
Normal file
431
frontend/pages/admin/setup.vue
Normal file
|
@ -0,0 +1,431 @@
|
||||||
|
<template>
|
||||||
|
<v-container
|
||||||
|
fill-height
|
||||||
|
fluid
|
||||||
|
class="d-flex justify-center align-center"
|
||||||
|
width="1200px"
|
||||||
|
min-height="700px"
|
||||||
|
:class="{
|
||||||
|
'bg-off-white': !$vuetify.theme.dark,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<BaseWizard
|
||||||
|
v-model="currentPage"
|
||||||
|
:max-page-number="totalPages"
|
||||||
|
:title="$i18n.tc('admin.setup.first-time-setup')"
|
||||||
|
:prev-button-show="activeConfig.showPrevButton"
|
||||||
|
:next-button-show="activeConfig.showNextButton"
|
||||||
|
:next-button-text="activeConfig.nextButtonText"
|
||||||
|
:next-button-icon="activeConfig.nextButtonIcon"
|
||||||
|
:next-button-color="activeConfig.nextButtonColor"
|
||||||
|
:next-button-is-submit="activeConfig.isSubmit"
|
||||||
|
:is-submitting="isSubmitting"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
>
|
||||||
|
<v-container v-if="currentPage === Pages.LANDING" class="mb-12">
|
||||||
|
<v-card-title class="text-h4 justify-center">
|
||||||
|
{{ $i18n.tc('admin.setup.welcome-to-mealie-get-started') }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-btn
|
||||||
|
:to="groupSlug ? `/g/${groupSlug}` : '/login'"
|
||||||
|
rounded
|
||||||
|
outlined
|
||||||
|
text
|
||||||
|
color="grey lighten-1"
|
||||||
|
class="text-subtitle-2 d-flex mx-auto"
|
||||||
|
style="width: fit-content;"
|
||||||
|
>
|
||||||
|
{{ $i18n.tc('admin.setup.already-set-up-bring-to-homepage') }}
|
||||||
|
</v-btn>
|
||||||
|
</v-container>
|
||||||
|
<v-container v-if="currentPage === Pages.USER_INFO">
|
||||||
|
<UserRegistrationForm />
|
||||||
|
</v-container>
|
||||||
|
<v-container v-if="currentPage === Pages.PAGE_2">
|
||||||
|
<v-card-title class="headline justify-center">
|
||||||
|
{{ $i18n.tc('admin.setup.common-settings-for-new-sites') }}
|
||||||
|
</v-card-title>
|
||||||
|
<AutoForm v-model="commonSettings" :items="commonSettingsForm" />
|
||||||
|
</v-container>
|
||||||
|
<v-container v-if="currentPage === Pages.CONFIRM">
|
||||||
|
<v-card-title class="headline justify-center">
|
||||||
|
{{ $t("general.confirm-how-does-everything-look") }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-list>
|
||||||
|
<template v-for="(item, idx) in confirmationData">
|
||||||
|
<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.length - 1" :key="`divider-${idx}`" />
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</v-container>
|
||||||
|
<v-container v-if="currentPage === Pages.END">
|
||||||
|
<v-card-title class="text-h4 justify-center">
|
||||||
|
{{ $i18n.tc('admin.setup.setup-complete') }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-title class="text-h6 justify-center">
|
||||||
|
{{ $i18n.tc('admin.setup.here-are-a-few-things-to-help-you-get-started') }}
|
||||||
|
</v-card-title>
|
||||||
|
<div v-for="link, idx in setupCompleteLinks" :key="idx" class="px-4 pt-4">
|
||||||
|
<div v-if="link.section">
|
||||||
|
<v-divider v-if="idx" />
|
||||||
|
<v-card-text class="headline pl-0">
|
||||||
|
{{ link.section }}
|
||||||
|
</v-card-text>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
:to="link.to"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
{{ link.text }}
|
||||||
|
</v-btn>
|
||||||
|
<v-card-text class="subtitle px-0 py-2">
|
||||||
|
{{ link.description }}
|
||||||
|
</v-card-text>
|
||||||
|
</div>
|
||||||
|
</v-container>
|
||||||
|
</BaseWizard>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent, ref, useContext, useRouter } from "@nuxtjs/composition-api";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { useLocales } from "~/composables/use-locales";
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
||||||
|
import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form";
|
||||||
|
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: { UserRegistrationForm },
|
||||||
|
layout: "blank",
|
||||||
|
setup() {
|
||||||
|
// ================================================================
|
||||||
|
// Setup
|
||||||
|
const { $auth, $globals, i18n } = useContext();
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
|
const groupSlug = computed(() => $auth.user?.groupSlug);
|
||||||
|
const { locale } = useLocales();
|
||||||
|
const router = useRouter();
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
if (!$auth.loggedIn) {
|
||||||
|
router.push("/login");
|
||||||
|
} else if (!$auth.user?.admin) {
|
||||||
|
router.push(groupSlug.value ? `/g/${groupSlug.value}` : "/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
nextButtonText: string | undefined;
|
||||||
|
nextButtonIcon: string | undefined;
|
||||||
|
nextButtonColor: string | undefined;
|
||||||
|
showPrevButton: boolean;
|
||||||
|
showNextButton: boolean;
|
||||||
|
isSubmit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = 4;
|
||||||
|
enum Pages {
|
||||||
|
LANDING = 0,
|
||||||
|
USER_INFO = 1,
|
||||||
|
PAGE_2 = 2,
|
||||||
|
CONFIRM = 3,
|
||||||
|
END = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Forms
|
||||||
|
const { accountDetails, credentials } = useUserRegistrationForm();
|
||||||
|
const { commonSettingsForm } = useCommonSettingsForm();
|
||||||
|
const commonSettings = ref({
|
||||||
|
makeGroupRecipesPublic: false,
|
||||||
|
useSeedData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmationData = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
display: true,
|
||||||
|
text: i18n.tc("user.email"),
|
||||||
|
value: accountDetails.email.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: true,
|
||||||
|
text: i18n.tc("user.username"),
|
||||||
|
value: accountDetails.username.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: true,
|
||||||
|
text: i18n.tc("user.full-name"),
|
||||||
|
value: accountDetails.fullName.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: true,
|
||||||
|
text: i18n.tc("user.enable-advanced-content"),
|
||||||
|
value: accountDetails.advancedOptions.value ? i18n.tc("general.yes") : i18n.tc("general.no"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: true,
|
||||||
|
text: i18n.tc("group.enable-public-access"),
|
||||||
|
value: commonSettings.value.makeGroupRecipesPublic ? i18n.tc("general.yes") : i18n.tc("general.no"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: true,
|
||||||
|
text: i18n.tc("user-registration.use-seed-data"),
|
||||||
|
value: commonSettings.value.useSeedData ? i18n.tc("general.yes") : i18n.tc("general.no"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupCompleteLinks = ref([
|
||||||
|
{
|
||||||
|
section: i18n.tc("profile.data-migrations"),
|
||||||
|
to: "/admin/backups",
|
||||||
|
text: i18n.tc("settings.backup.backup-restore"),
|
||||||
|
description: i18n.tc("admin.setup.restore-from-v1-backup"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/group/migrations",
|
||||||
|
text: i18n.tc("migration.recipe-migration"),
|
||||||
|
description: i18n.tc("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: i18n.tc("recipe.create-recipes"),
|
||||||
|
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
|
||||||
|
text: i18n.tc("recipe.create-recipe"),
|
||||||
|
description: i18n.tc("recipe.create-recipe-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
|
||||||
|
text: i18n.tc("recipe.import-with-url"),
|
||||||
|
description: i18n.tc("recipe.scrape-recipe-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: i18n.tc("user.manage-users"),
|
||||||
|
to: "/admin/manage/users",
|
||||||
|
text: i18n.tc("user.manage-users"),
|
||||||
|
description: i18n.tc("user.manage-users-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/user/profile",
|
||||||
|
text: i18n.tc("profile.manage-user-profile"),
|
||||||
|
description: i18n.tc("admin.setup.manage-profile-or-get-invite-link"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Page Navigation
|
||||||
|
const currentPage = ref(0);
|
||||||
|
const activeConfig = computed<Config>(() => {
|
||||||
|
const config: Config = {
|
||||||
|
nextButtonText: undefined,
|
||||||
|
nextButtonIcon: undefined,
|
||||||
|
nextButtonColor: undefined,
|
||||||
|
showPrevButton: true,
|
||||||
|
showNextButton: true,
|
||||||
|
isSubmit: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (currentPage.value) {
|
||||||
|
case Pages.LANDING:
|
||||||
|
config.showPrevButton = false;
|
||||||
|
config.nextButtonText = i18n.tc("general.start");
|
||||||
|
config.nextButtonIcon = $globals.icons.forward;
|
||||||
|
break;
|
||||||
|
case Pages.USER_INFO:
|
||||||
|
config.showPrevButton = false;
|
||||||
|
config.nextButtonText = i18n.tc("general.next");
|
||||||
|
config.nextButtonIcon = $globals.icons.forward;
|
||||||
|
config.isSubmit = true;
|
||||||
|
break;
|
||||||
|
case Pages.CONFIRM:
|
||||||
|
config.isSubmit = true;
|
||||||
|
break;
|
||||||
|
case Pages.END:
|
||||||
|
config.nextButtonText = i18n.tc("general.home");
|
||||||
|
config.nextButtonIcon = $globals.icons.home;
|
||||||
|
config.nextButtonColor = "primary";
|
||||||
|
config.showPrevButton = false;
|
||||||
|
config.isSubmit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
})
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Page Submission
|
||||||
|
|
||||||
|
async function updateUser() {
|
||||||
|
// @ts-ignore-next-line user will never be null here
|
||||||
|
const { response } = await api.users.updateOne($auth.user?.id, {
|
||||||
|
...$auth.user,
|
||||||
|
email: accountDetails.email.value,
|
||||||
|
username: accountDetails.username.value,
|
||||||
|
fullName: accountDetails.fullName.value,
|
||||||
|
advancedOptions: accountDetails.advancedOptions.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
} else {
|
||||||
|
$auth.setUser({
|
||||||
|
...$auth.user,
|
||||||
|
email: accountDetails.email.value,
|
||||||
|
username: accountDetails.username.value,
|
||||||
|
fullName: accountDetails.fullName.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword() {
|
||||||
|
const { response } = await api.users.changePassword({
|
||||||
|
currentPassword: "MyPassword",
|
||||||
|
newPassword: credentials.password1.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRegistration() {
|
||||||
|
// the backend will only update the password without the "currentPassword" field if the user is the default user,
|
||||||
|
// so we update the password first, then update the user's details
|
||||||
|
await updatePassword().then(updateUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGroup() {
|
||||||
|
// @ts-ignore-next-line user will never be null here
|
||||||
|
const { data } = await api.groups.getOne($auth.user?.groupId);
|
||||||
|
if (!data || !data.preferences) {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = {
|
||||||
|
...data.preferences,
|
||||||
|
privateGroup: !commonSettings.value.makeGroupRecipesPublic,
|
||||||
|
recipePublic: commonSettings.value.makeGroupRecipesPublic,
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
preferences,
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore-next-line user will never be null here
|
||||||
|
const { response } = await api.groups.updateOne($auth.user?.groupId, payload);
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedFoods() {
|
||||||
|
const { response } = await api.seeders.foods({ locale: locale.value })
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUnits() {
|
||||||
|
const { response } = await api.seeders.units({ locale: locale.value })
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedLabels() {
|
||||||
|
const { response } = await api.seeders.labels({ locale: locale.value })
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedData() {
|
||||||
|
if (!commonSettings.value.useSeedData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
seedFoods(),
|
||||||
|
seedUnits(),
|
||||||
|
seedLabels(),
|
||||||
|
]
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCommonSettings() {
|
||||||
|
const tasks = [
|
||||||
|
updateGroup(),
|
||||||
|
seedData(),
|
||||||
|
]
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAll() {
|
||||||
|
const tasks = [
|
||||||
|
submitRegistration(),
|
||||||
|
submitCommonSettings(),
|
||||||
|
]
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(page: number) {
|
||||||
|
if (isSubmitting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
switch (page) {
|
||||||
|
case Pages.USER_INFO:
|
||||||
|
if (await accountDetails.validate()) {
|
||||||
|
currentPage.value += 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Pages.CONFIRM:
|
||||||
|
await submitAll();
|
||||||
|
currentPage.value += 1;
|
||||||
|
break;
|
||||||
|
case Pages.END:
|
||||||
|
router.push(groupSlug.value ? `/g/${groupSlug.value}` : "/login");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Setup
|
||||||
|
groupSlug,
|
||||||
|
// Forms
|
||||||
|
commonSettingsForm,
|
||||||
|
commonSettings,
|
||||||
|
confirmationData,
|
||||||
|
setupCompleteLinks,
|
||||||
|
// Page Navigation
|
||||||
|
Pages,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
activeConfig,
|
||||||
|
// Page Submission
|
||||||
|
isSubmitting,
|
||||||
|
handleSubmit,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
head() {
|
||||||
|
return {
|
||||||
|
title: this.$i18n.tc("admin.setup.first-time-setup"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -3,8 +3,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, useRouter } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useAsync, useContext, useRouter } from "@nuxtjs/composition-api";
|
||||||
import { AppInfo } from "~/lib/api/types/admin";
|
import { useAsyncKey } from "~/composables/use-utils";
|
||||||
|
import { AppInfo, AppStartupInfo } from "~/lib/api/types/admin";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
layout: "blank",
|
layout: "blank",
|
||||||
|
@ -22,11 +23,20 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupSlug.value) {
|
useAsync(async () => {
|
||||||
router.push(`/g/${groupSlug.value}`);
|
if (groupSlug.value) {
|
||||||
} else {
|
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
|
||||||
redirectPublicUserToDefaultGroup();
|
const isDemo = data.data.isDemo;
|
||||||
}
|
const isFirstLogin = data.data.isFirstLogin;
|
||||||
|
if (!isDemo && isFirstLogin && $auth.user?.admin) {
|
||||||
|
router.push("/admin/setup");
|
||||||
|
} else {
|
||||||
|
router.push(`/g/${groupSlug.value}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
redirectPublicUserToDefaultGroup();
|
||||||
|
}
|
||||||
|
}, useAsyncKey());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -152,14 +152,8 @@ export default defineComponent({
|
||||||
const { $auth, i18n, $axios } = useContext();
|
const { $auth, i18n, $axios } = useContext();
|
||||||
const { loggedIn } = useLoggedInState();
|
const { loggedIn } = useLoggedInState();
|
||||||
const groupSlug = computed(() => $auth.user?.groupSlug);
|
const groupSlug = computed(() => $auth.user?.groupSlug);
|
||||||
|
const isDemo = ref(false);
|
||||||
whenever(
|
const isFirstLogin = ref(false);
|
||||||
() => loggedIn.value && groupSlug.value,
|
|
||||||
() => {
|
|
||||||
router.push(`/g/${groupSlug.value || ""}`);
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
|
@ -167,12 +161,23 @@ export default defineComponent({
|
||||||
remember: false,
|
remember: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFirstLogin = ref(false)
|
|
||||||
|
|
||||||
useAsync(async () => {
|
useAsync(async () => {
|
||||||
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
|
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
|
||||||
|
isDemo.value = data.data.isDemo;
|
||||||
isFirstLogin.value = data.data.isFirstLogin;
|
isFirstLogin.value = data.data.isFirstLogin;
|
||||||
}, useAsyncKey());
|
}, useAsyncKey());
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
() => loggedIn.value && groupSlug.value,
|
||||||
|
() => {
|
||||||
|
if (!isDemo.value && isFirstLogin.value && $auth.user?.admin) {
|
||||||
|
router.push("/admin/setup");
|
||||||
|
} else {
|
||||||
|
router.push(`/g/${groupSlug.value || ""}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const loggingIn = ref(false);
|
const loggingIn = ref(false);
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
fluid
|
fluid
|
||||||
class="d-flex justify-center align-center"
|
class="d-flex justify-center align-center"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-off-white': !$vuetify.theme.dark && !isDark.value,
|
'bg-off-white': !$vuetify.theme.dark && !isDark,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<LanguageDialog v-model="langDialog" />
|
<LanguageDialog v-model="langDialog" />
|
||||||
|
@ -136,73 +136,14 @@
|
||||||
|
|
||||||
<template v-else-if="state.ctx.state === States.ProvideAccountDetails">
|
<template v-else-if="state.ctx.state === States.ProvideAccountDetails">
|
||||||
<div>
|
<div>
|
||||||
<v-card-title>
|
<UserRegistrationForm />
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UserPasswordStrength :value="credentials.password1.value" />
|
|
||||||
|
|
||||||
<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-divider />
|
||||||
<v-card-actions class="justify-space-between">
|
<v-card-actions class="justify-space-between">
|
||||||
<BaseButton cancel @click="state.back">
|
<BaseButton cancel @click="state.back">
|
||||||
<template #icon> {{ $globals.icons.back }}</template>
|
<template #icon> {{ $globals.icons.back }}</template>
|
||||||
{{ $t("general.back") }}
|
{{ $t("general.back") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<BaseButton icon-right @click="accountDetails.next">
|
<BaseButton icon-right @click="accountDetailsNext">
|
||||||
<template #icon> {{ $globals.icons.forward }}</template>
|
<template #icon> {{ $globals.icons.forward }}</template>
|
||||||
{{ $t("general.next") }}
|
{{ $t("general.next") }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
@ -258,6 +199,7 @@
|
||||||
import { defineComponent, onMounted, ref, useRouter, Ref, useContext, computed } from "@nuxtjs/composition-api";
|
import { defineComponent, onMounted, ref, useRouter, Ref, useContext, computed } from "@nuxtjs/composition-api";
|
||||||
import { useDark } from "@vueuse/core";
|
import { useDark } from "@vueuse/core";
|
||||||
import { States, RegistrationType, useRegistration } from "./states";
|
import { States, RegistrationType, useRegistration } from "./states";
|
||||||
|
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
|
||||||
import { useRouteQuery } from "~/composables/use-router";
|
import { useRouteQuery } from "~/composables/use-router";
|
||||||
import { validators, useAsyncValidator } from "~/composables/use-validators";
|
import { validators, useAsyncValidator } from "~/composables/use-validators";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
@ -267,7 +209,7 @@ import { VForm } from "~/types/vuetify";
|
||||||
import { usePasswordField } from "~/composables/use-passwords";
|
import { usePasswordField } from "~/composables/use-passwords";
|
||||||
import { usePublicApi } from "~/composables/api/api-client";
|
import { usePublicApi } from "~/composables/api/api-client";
|
||||||
import { useLocales } from "~/composables/use-locales";
|
import { useLocales } from "~/composables/use-locales";
|
||||||
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
|
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
|
||||||
|
|
||||||
const inputAttrs = {
|
const inputAttrs = {
|
||||||
filled: true,
|
filled: true,
|
||||||
|
@ -277,7 +219,7 @@ const inputAttrs = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { UserPasswordStrength },
|
components: { UserRegistrationForm },
|
||||||
layout: "blank",
|
layout: "blank",
|
||||||
setup() {
|
setup() {
|
||||||
const { i18n } = useContext();
|
const { i18n } = useContext();
|
||||||
|
@ -370,48 +312,22 @@ export default defineComponent({
|
||||||
state.setState(States.ProvideAccountDetails);
|
state.setState(States.ProvideAccountDetails);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// ================================================================
|
const pwFields = usePasswordField();
|
||||||
// Provide Account Details
|
const {
|
||||||
const domAccountForm = ref<VForm | null>(null);
|
accountDetails,
|
||||||
const username = ref("");
|
credentials,
|
||||||
const email = ref("");
|
domAccountForm,
|
||||||
const advancedOptions = ref(false);
|
emailErrorMessages,
|
||||||
const usernameErrorMessages = ref<string[]>([]);
|
usernameErrorMessages,
|
||||||
const { validate: validateUsername, valid: validUsername } = useAsyncValidator(
|
validateUsername,
|
||||||
username,
|
validateEmail,
|
||||||
(v: string) => publicApi.validators.username(v),
|
} = useUserRegistrationForm();
|
||||||
i18n.tc("validation.username-is-taken"),
|
async function accountDetailsNext() {
|
||||||
usernameErrorMessages
|
if (!await accountDetails.validate()) {
|
||||||
);
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
state.setState(States.Confirmation);
|
state.setState(States.Confirmation);
|
||||||
},
|
}
|
||||||
};
|
|
||||||
// ================================================================
|
|
||||||
// Provide Credentials
|
|
||||||
const password1 = ref("");
|
|
||||||
const password2 = ref("");
|
|
||||||
const pwFields = usePasswordField();
|
|
||||||
const passwordMatch = () => password1.value === password2.value || i18n.tc("user.password-must-match");
|
|
||||||
const credentials = {
|
|
||||||
password1,
|
|
||||||
password2,
|
|
||||||
passwordMatch,
|
|
||||||
};
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// Locale
|
// Locale
|
||||||
const { locale } = useLocales();
|
const { locale } = useLocales();
|
||||||
|
@ -438,17 +354,22 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
display: true,
|
display: true,
|
||||||
text: i18n.tc("user.email"),
|
text: i18n.tc("user.email"),
|
||||||
value: email.value,
|
value: accountDetails.email.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: true,
|
||||||
|
text: i18n.tc("user.full-name"),
|
||||||
|
value: accountDetails.fullName.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
display: true,
|
display: true,
|
||||||
text: i18n.tc("user.username"),
|
text: i18n.tc("user.username"),
|
||||||
value: username.value,
|
value: accountDetails.username.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
display: true,
|
display: true,
|
||||||
text: i18n.tc("user.enable-advanced-content"),
|
text: i18n.tc("user.enable-advanced-content"),
|
||||||
value: advancedOptions.value ? i18n.tc("general.yes") : i18n.tc("general.no"),
|
value: accountDetails.advancedOptions.value ? i18n.tc("general.yes") : i18n.tc("general.no"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
@ -456,12 +377,13 @@ export default defineComponent({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
async function submitRegistration() {
|
async function submitRegistration() {
|
||||||
const payload: CreateUserRegistration = {
|
const payload: CreateUserRegistration = {
|
||||||
email: email.value,
|
email: accountDetails.email.value,
|
||||||
username: username.value,
|
username: accountDetails.username.value,
|
||||||
password: password1.value,
|
fullName: accountDetails.fullName.value,
|
||||||
passwordConfirm: password2.value,
|
password: credentials.password1.value,
|
||||||
|
passwordConfirm: credentials.password2.value,
|
||||||
locale: locale.value,
|
locale: locale.value,
|
||||||
advanced: advancedOptions.value,
|
advanced: accountDetails.advancedOptions.value,
|
||||||
};
|
};
|
||||||
if (state.ctx.type === RegistrationType.CreateGroup) {
|
if (state.ctx.type === RegistrationType.CreateGroup) {
|
||||||
payload.group = groupName.value;
|
payload.group = groupName.value;
|
||||||
|
@ -472,12 +394,17 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
const { response } = await api.register.register(payload);
|
const { response } = await api.register.register(payload);
|
||||||
if (response?.status === 201) {
|
if (response?.status === 201) {
|
||||||
|
accountDetails.reset();
|
||||||
|
credentials.reset();
|
||||||
alert.success(i18n.tc("user-registration.registration-success"));
|
alert.success(i18n.tc("user-registration.registration-success"));
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
|
} else {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
accountDetails,
|
accountDetails,
|
||||||
|
accountDetailsNext,
|
||||||
confirmationData,
|
confirmationData,
|
||||||
credentials,
|
credentials,
|
||||||
emailErrorMessages,
|
emailErrorMessages,
|
||||||
|
|
2
frontend/types/components.d.ts
vendored
2
frontend/types/components.d.ts
vendored
|
@ -32,6 +32,7 @@ import ReportTable from "@/components/global/ReportTable.vue";
|
||||||
import SafeMarkdown from "@/components/global/SafeMarkdown.vue";
|
import SafeMarkdown from "@/components/global/SafeMarkdown.vue";
|
||||||
import StatsCards from "@/components/global/StatsCards.vue";
|
import StatsCards from "@/components/global/StatsCards.vue";
|
||||||
import ToggleState from "@/components/global/ToggleState.vue";
|
import ToggleState from "@/components/global/ToggleState.vue";
|
||||||
|
import BaseWizard from "@/components/global/BaseWizard.vue";
|
||||||
import DefaultLayout from "@/components/layout/DefaultLayout.vue";
|
import DefaultLayout from "@/components/layout/DefaultLayout.vue";
|
||||||
|
|
||||||
declare module "vue" {
|
declare module "vue" {
|
||||||
|
@ -70,6 +71,7 @@ declare module "vue" {
|
||||||
SafeMarkdown: typeof SafeMarkdown;
|
SafeMarkdown: typeof SafeMarkdown;
|
||||||
StatsCards: typeof StatsCards;
|
StatsCards: typeof StatsCards;
|
||||||
ToggleState: typeof ToggleState;
|
ToggleState: typeof ToggleState;
|
||||||
|
BaseWizard: typeof BaseWizard;
|
||||||
// Layout Components
|
// Layout Components
|
||||||
DefaultLayout: typeof DefaultLayout;
|
DefaultLayout: typeof DefaultLayout;
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,6 +47,7 @@ def get_startup_info(session: Session = Depends(generate_session)):
|
||||||
|
|
||||||
return AppStartupInfo(
|
return AppStartupInfo(
|
||||||
is_first_login=is_first_login,
|
is_first_login=is_first_login,
|
||||||
|
is_demo=settings.IS_DEMO,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,8 @@ class AppStartupInfo(MealieModel):
|
||||||
it is removed, this will always return False.
|
it is removed, this will always return False.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
is_demo: bool
|
||||||
|
|
||||||
|
|
||||||
class AdminAboutInfo(AppInfo):
|
class AdminAboutInfo(AppInfo):
|
||||||
versionLatest: str
|
versionLatest: str
|
||||||
|
|
|
@ -10,8 +10,9 @@ from mealie.schema._mealie.validators import validate_locale
|
||||||
class CreateUserRegistration(MealieModel):
|
class CreateUserRegistration(MealieModel):
|
||||||
group: str | None = None
|
group: str | None = None
|
||||||
group_token: Annotated[str | None, Field(validate_default=True)] = None
|
group_token: Annotated[str | None, Field(validate_default=True)] = None
|
||||||
email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore
|
email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)]
|
||||||
username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore
|
username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)]
|
||||||
|
full_name: Annotated[str, StringConstraints(strip_whitespace=True)]
|
||||||
password: str
|
password: str
|
||||||
password_confirm: str
|
password_confirm: str
|
||||||
advanced: bool = False
|
advanced: bool = False
|
||||||
|
|
|
@ -49,7 +49,7 @@ class DeleteTokenResponse(MealieModel):
|
||||||
|
|
||||||
|
|
||||||
class ChangePassword(MealieModel):
|
class ChangePassword(MealieModel):
|
||||||
current_password: str
|
current_password: str = ""
|
||||||
new_password: str = Field(..., min_length=8)
|
new_password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ class RegistrationService:
|
||||||
email=self.registration.email,
|
email=self.registration.email,
|
||||||
username=self.registration.username,
|
username=self.registration.username,
|
||||||
password=hash_password(self.registration.password),
|
password=hash_password(self.registration.password),
|
||||||
full_name=self.registration.username,
|
full_name=self.registration.full_name,
|
||||||
advanced=self.registration.advanced,
|
advanced=self.registration.advanced,
|
||||||
group=group,
|
group=group,
|
||||||
can_invite=new_group,
|
can_invite=new_group,
|
||||||
|
|
|
@ -11,6 +11,8 @@ test('password login', async ({ page }) => {
|
||||||
await page.locator('div').filter({ hasText: /^Password$/ }).nth(3).click();
|
await page.locator('div').filter({ hasText: /^Password$/ }).nth(3).click();
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel('Password').fill(password);
|
||||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||||
|
// skip admin setup page
|
||||||
|
await page.getByRole('link', { name: "I'm already set up, just bring me to the homepage" }).click();
|
||||||
await expect(page.getByRole('navigation')).toContainText(name);
|
await expect(page.getByRole('navigation')).toContainText(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -40,6 +42,8 @@ test('ldap admin login', async ({ page }) => {
|
||||||
await page.locator('div').filter({ hasText: /^Password$/ }).nth(3).click();
|
await page.locator('div').filter({ hasText: /^Password$/ }).nth(3).click();
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel('Password').fill(password);
|
||||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||||
|
// skip admin setup page
|
||||||
|
await page.getByRole('link', { name: "I'm already set up, just bring me to the homepage" }).click();
|
||||||
await expect(page.getByRole('navigation')).toContainText(name);
|
await expect(page.getByRole('navigation')).toContainText(name);
|
||||||
await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
@ -113,6 +117,8 @@ test('settings page verify oidc', async ({ page }) => {
|
||||||
await page.getByLabel('Password').click();
|
await page.getByLabel('Password').click();
|
||||||
await page.getByLabel('Password').fill('MyPassword');
|
await page.getByLabel('Password').fill('MyPassword');
|
||||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||||
|
// skip admin setup page
|
||||||
|
await page.getByRole('link', { name: "I'm already set up, just bring me to the homepage" }).click();
|
||||||
await page.getByRole('link', { name: 'Settings' }).click();
|
await page.getByRole('link', { name: 'Settings' }).click();
|
||||||
await page.getByRole('link', { name: 'Users' }).click();
|
await page.getByRole('link', { name: 'Users' }).click();
|
||||||
await page.getByRole('cell', { name: username, exact: true }).click();
|
await page.getByRole('cell', { name: username, exact: true }).click();
|
||||||
|
@ -135,6 +141,8 @@ test('oidc admin user', async ({ page }) => {
|
||||||
await page.getByPlaceholder('Enter any user/subject').fill(username);
|
await page.getByPlaceholder('Enter any user/subject').fill(username);
|
||||||
await page.getByPlaceholder('Optional claims JSON value,').fill(JSON.stringify(claims));
|
await page.getByPlaceholder('Optional claims JSON value,').fill(JSON.stringify(claims));
|
||||||
await page.getByRole('button', { name: 'Sign-in' }).click();
|
await page.getByRole('button', { name: 'Sign-in' }).click();
|
||||||
|
// skip admin setup page
|
||||||
|
await page.getByRole('link', { name: "I'm already set up, just bring me to the homepage" }).click();
|
||||||
await expect(page.getByRole('navigation')).toContainText(name);
|
await expect(page.getByRole('navigation')).toContainText(name);
|
||||||
await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,11 +3,14 @@ import json
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from mealie.core.config import get_app_settings
|
||||||
from mealie.db.db_setup import session_context
|
from mealie.db.db_setup import session_context
|
||||||
from mealie.schema.user.user import PrivateUser
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
|
from mealie.schema.response.pagination import PaginationQuery
|
||||||
|
from mealie.schema.user.user import ChangePassword, PrivateUser
|
||||||
from mealie.services.user_services.password_reset_service import PasswordResetService
|
from mealie.services.user_services.password_reset_service import PasswordResetService
|
||||||
from tests.utils import api_routes
|
from tests.utils import api_routes
|
||||||
from tests.utils.factories import random_string
|
from tests.utils.factories import random_email, random_string
|
||||||
from tests.utils.fixture_schemas import TestUser
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ def test_create_user_registration() -> None:
|
||||||
group_token=None,
|
group_token=None,
|
||||||
email="SomeValidEmail@example.com",
|
email="SomeValidEmail@example.com",
|
||||||
username="SomeValidUsername",
|
username="SomeValidUsername",
|
||||||
|
full_name="SomeValidFullName",
|
||||||
password="SomeValidPassword",
|
password="SomeValidPassword",
|
||||||
password_confirm="SomeValidPassword",
|
password_confirm="SomeValidPassword",
|
||||||
advanced=False,
|
advanced=False,
|
||||||
|
@ -20,6 +21,7 @@ def test_create_user_registration() -> None:
|
||||||
group_token="asdfadsfasdfasdfasdf",
|
group_token="asdfadsfasdfasdfasdf",
|
||||||
email="SomeValidEmail@example.com",
|
email="SomeValidEmail@example.com",
|
||||||
username="SomeValidUsername",
|
username="SomeValidUsername",
|
||||||
|
full_name="SomeValidFullName",
|
||||||
password="SomeValidPassword",
|
password="SomeValidPassword",
|
||||||
password_confirm="SomeValidPassword",
|
password_confirm="SomeValidPassword",
|
||||||
advanced=False,
|
advanced=False,
|
||||||
|
@ -35,6 +37,7 @@ def test_group_or_token_validator(group, group_token) -> None:
|
||||||
group_token=group_token,
|
group_token=group_token,
|
||||||
email="SomeValidEmail@example.com",
|
email="SomeValidEmail@example.com",
|
||||||
username="SomeValidUsername",
|
username="SomeValidUsername",
|
||||||
|
full_name="SomeValidFullName",
|
||||||
password="SomeValidPassword",
|
password="SomeValidPassword",
|
||||||
password_confirm="SomeValidPassword",
|
password_confirm="SomeValidPassword",
|
||||||
advanced=False,
|
advanced=False,
|
||||||
|
@ -47,6 +50,7 @@ def test_group_no_args_passed() -> None:
|
||||||
CreateUserRegistration(
|
CreateUserRegistration(
|
||||||
email="SomeValidEmail@example.com",
|
email="SomeValidEmail@example.com",
|
||||||
username="SomeValidUsername",
|
username="SomeValidUsername",
|
||||||
|
full_name="SomeValidFullName",
|
||||||
password="SomeValidPassword",
|
password="SomeValidPassword",
|
||||||
password_confirm="SomeValidPassword",
|
password_confirm="SomeValidPassword",
|
||||||
advanced=False,
|
advanced=False,
|
||||||
|
@ -61,6 +65,7 @@ def test_password_validator() -> None:
|
||||||
group_token="asdfadsfasdfasdfasdf",
|
group_token="asdfadsfasdfasdfasdf",
|
||||||
email="SomeValidEmail@example.com",
|
email="SomeValidEmail@example.com",
|
||||||
username="SomeValidUsername",
|
username="SomeValidUsername",
|
||||||
|
full_name="SomeValidFullName",
|
||||||
password="SomeValidPassword",
|
password="SomeValidPassword",
|
||||||
password_confirm="PasswordDefNotMatch",
|
password_confirm="PasswordDefNotMatch",
|
||||||
advanced=False,
|
advanced=False,
|
||||||
|
|
|
@ -25,6 +25,7 @@ def user_registration_factory(advanced=None, private=None) -> CreateUserRegistra
|
||||||
group=random_string(),
|
group=random_string(),
|
||||||
email=random_email(),
|
email=random_email(),
|
||||||
username=random_string(),
|
username=random_string(),
|
||||||
|
full_name=random_string(),
|
||||||
password="fake-password",
|
password="fake-password",
|
||||||
password_confirm="fake-password",
|
password_confirm="fake-password",
|
||||||
advanced=advanced or random_bool(),
|
advanced=advanced or random_bool(),
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue