1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-03 04:25:24 +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:
Michael Genson 2024-03-11 08:28:54 -05:00 committed by GitHub
parent 430e1d7d4e
commit 403038a5b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1103 additions and 141 deletions

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

View file

@ -3,8 +3,9 @@
</template>
<script lang="ts">
import { computed, defineComponent, useContext, useRouter } from "@nuxtjs/composition-api";
import { AppInfo } from "~/lib/api/types/admin";
import { computed, defineComponent, useAsync, useContext, useRouter } from "@nuxtjs/composition-api";
import { useAsyncKey } from "~/composables/use-utils";
import { AppInfo, AppStartupInfo } from "~/lib/api/types/admin";
export default defineComponent({
layout: "blank",
@ -22,11 +23,20 @@ export default defineComponent({
}
}
if (groupSlug.value) {
router.push(`/g/${groupSlug.value}`);
} else {
redirectPublicUserToDefaultGroup();
}
useAsync(async () => {
if (groupSlug.value) {
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
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>

View file

@ -152,14 +152,8 @@ export default defineComponent({
const { $auth, i18n, $axios } = useContext();
const { loggedIn } = useLoggedInState();
const groupSlug = computed(() => $auth.user?.groupSlug);
whenever(
() => loggedIn.value && groupSlug.value,
() => {
router.push(`/g/${groupSlug.value || ""}`);
},
{ immediate: true },
);
const isDemo = ref(false);
const isFirstLogin = ref(false);
const form = reactive({
email: "",
@ -167,12 +161,23 @@ export default defineComponent({
remember: false,
});
const isFirstLogin = ref(false)
useAsync(async () => {
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
isDemo.value = data.data.isDemo;
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);

View file

@ -4,7 +4,7 @@
fluid
class="d-flex justify-center align-center"
:class="{
'bg-off-white': !$vuetify.theme.dark && !isDark.value,
'bg-off-white': !$vuetify.theme.dark && !isDark,
}"
>
<LanguageDialog v-model="langDialog" />
@ -136,73 +136,14 @@
<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"
/>
<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>
<UserRegistrationForm />
<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">
<BaseButton icon-right @click="accountDetailsNext">
<template #icon> {{ $globals.icons.forward }}</template>
{{ $t("general.next") }}
</BaseButton>
@ -258,6 +199,7 @@
import { defineComponent, onMounted, ref, useRouter, Ref, useContext, computed } from "@nuxtjs/composition-api";
import { useDark } from "@vueuse/core";
import { States, RegistrationType, useRegistration } from "./states";
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
import { useRouteQuery } from "~/composables/use-router";
import { validators, useAsyncValidator } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
@ -267,7 +209,7 @@ import { VForm } from "~/types/vuetify";
import { usePasswordField } from "~/composables/use-passwords";
import { usePublicApi } from "~/composables/api/api-client";
import { useLocales } from "~/composables/use-locales";
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
const inputAttrs = {
filled: true,
@ -277,7 +219,7 @@ const inputAttrs = {
};
export default defineComponent({
components: { UserPasswordStrength },
components: { UserRegistrationForm },
layout: "blank",
setup() {
const { i18n } = useContext();
@ -370,48 +312,22 @@ export default defineComponent({
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) {
const pwFields = usePasswordField();
const {
accountDetails,
credentials,
domAccountForm,
emailErrorMessages,
usernameErrorMessages,
validateUsername,
validateEmail,
} = useUserRegistrationForm();
async function accountDetailsNext() {
if (!await accountDetails.validate()) {
return;
}
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
const { locale } = useLocales();
@ -438,17 +354,22 @@ export default defineComponent({
{
display: true,
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,
text: i18n.tc("user.username"),
value: username.value,
value: accountDetails.username.value,
},
{
display: true,
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();
async function submitRegistration() {
const payload: CreateUserRegistration = {
email: email.value,
username: username.value,
password: password1.value,
passwordConfirm: password2.value,
email: accountDetails.email.value,
username: accountDetails.username.value,
fullName: accountDetails.fullName.value,
password: credentials.password1.value,
passwordConfirm: credentials.password2.value,
locale: locale.value,
advanced: advancedOptions.value,
advanced: accountDetails.advancedOptions.value,
};
if (state.ctx.type === RegistrationType.CreateGroup) {
payload.group = groupName.value;
@ -472,12 +394,17 @@ export default defineComponent({
}
const { response } = await api.register.register(payload);
if (response?.status === 201) {
accountDetails.reset();
credentials.reset();
alert.success(i18n.tc("user-registration.registration-success"));
router.push("/login");
} else {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
return {
accountDetails,
accountDetailsNext,
confirmationData,
credentials,
emailErrorMessages,