Merge branch 'mealie-next' into feat/announcements

This commit is contained in:
Michael Genson
2026-04-08 15:49:46 +00:00
598 changed files with 17278 additions and 17950 deletions

View File

@@ -0,0 +1,263 @@
<template>
<v-container fluid>
<section>
<!-- Delete Dialog -->
<BaseDialog
v-model="state.deleteDialog"
:title="$t('settings.backup.delete-backup')"
color="error"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteBackup()"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<!-- Import Dialog -->
<BaseDialog
v-model="state.importDialog"
color="error"
:title="$t('settings.backup.backup-restore')"
:icon="$globals.icons.database"
>
<v-divider />
<v-card-text>
<i18n-t keypath="settings.backup.back-restore-description">
<template #cannot-be-undone>
<b> {{ $t('settings.backup.cannot-be-undone') }} </b>
</template>
</i18n-t>
<p class="mt-3">
<i18n-t keypath="settings.backup.postgresql-note">
<template #backup-restore-process>
<a href="https://nightly.mealie.io/documentation/getting-started/usage/backups-and-restoring/">{{
$t('settings.backup.backup-restore-process-in-the-documentation') }}</a>
</template>
</i18n-t>
</p>
<v-checkbox
v-model="state.confirmImport"
class="checkbox-top"
color="error"
hide-details
:label="$t('settings.backup.irreversible-acknowledgment')"
/>
</v-card-text>
<v-card-actions class="justify-center pt-0">
<BaseButton
delete
:disabled="!state.confirmImport || state.runningRestore"
@click="restoreBackup(selected)"
>
<template #icon>
{{ $globals.icons.database }}
</template>
{{ $t('settings.backup.restore-backup') }}
</BaseButton>
</v-card-actions>
<p class="caption pb-0 mb-1 text-center">
{{ selected }}
</p>
<v-progress-linear
v-if="state.runningRestore"
indeterminate
/>
</BaseDialog>
<section>
<BaseCardSectionTitle :title="$t('settings.backup-and-exports')">
<v-card-text class="py-0 px-1">
<i18n-t keypath="settings.backup.experimental-description" />
</v-card-text>
</BaseCardSectionTitle>
<v-toolbar
color="transparent"
flat
class="justify-between"
>
<BaseButton
class="mr-2"
:loading="state.runningBackup"
@click="createBackup"
>
{{ $t("settings.backup.create-heading") }}
</BaseButton>
<AppButtonUpload
:text-btn="false"
url="/api/admin/backups/upload"
accept=".zip"
color="info"
@uploaded="refreshBackups()"
/>
</v-toolbar>
<v-data-table
:headers="state.headers"
:items="backups.imports || []"
class="elevation-0"
:items-per-page="-1"
hide-default-footer
disable-pagination
:search="state.search"
@click:row="setSelected"
>
<template #[`item.date`]="{ item }">
{{ $d(Date.parse(item.date)) }}
</template>
<template #[`item.actions`]="{ item }">
<v-btn
icon
class="mx-1"
color="error"
variant="text"
@click.stop="
state.deleteDialog = true;
deleteTarget = item.name;
"
>
<v-icon> {{ $globals.icons.delete }} </v-icon>
</v-btn>
<BaseButton
small
download
:download-url="backupsFileNameDownload(item.name)"
class="mx-1"
@click.stop="() => { }"
/>
<BaseButton
small
@click.stop="setSelected(item); state.importDialog = true"
>
<template #icon>
{{ $globals.icons.backupRestore }}
</template>
{{ $t("settings.backup.backup-restore") }}
</BaseButton>
</template>
</v-data-table>
<v-divider />
<div class="d-flex justify-end mt-6">
<div />
</div>
</section>
</section>
<v-container class="mt-4 d-flex justify-center text-center">
<nuxt-link :to="`/group/migrations`"> {{ $t('recipe.looking-for-migrations') }} </nuxt-link>
</v-container>
</v-container>
</template>
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import type { AllBackups } from "~/lib/api/types/admin";
import { alert } from "~/composables/use-toast";
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
const adminApi = useAdminApi();
const selected = ref("");
const backups = ref<AllBackups>({
imports: [],
templates: [],
});
async function refreshBackups() {
const { data } = await adminApi.backups.getAll();
if (data) {
backups.value = data;
}
}
async function createBackup() {
state.runningBackup = true;
const { data } = await adminApi.backups.create();
if (data?.error === false) {
refreshBackups();
alert.success(i18n.t("settings.backup.backup-created"));
}
else {
alert.error(i18n.t("settings.backup.error-creating-backup-see-log-file"));
}
state.runningBackup = false;
}
async function restoreBackup(fileName: string) {
state.runningRestore = true;
const { error } = await adminApi.backups.restore(fileName);
if (error) {
console.log(error);
state.importDialog = false;
state.runningRestore = false;
alert.error(i18n.t("settings.backup.restore-fail"));
}
else {
alert.success(i18n.t("settings.backup.restore-success"));
setTimeout(() => {
window.location.reload();
}, 500);
}
}
const deleteTarget = ref("");
async function deleteBackup() {
const { data } = await adminApi.backups.delete(deleteTarget.value);
if (!data?.error) {
alert.success(i18n.t("settings.backup.backup-deleted"));
refreshBackups();
}
}
const state = reactive({
confirmImport: false,
deleteDialog: false,
createDialog: false,
importDialog: false,
runningBackup: false,
runningRestore: false,
search: "",
headers: [
{ title: i18n.t("general.name"), value: "name" },
{ title: i18n.t("general.created"), value: "date" },
{ title: i18n.t("export.size"), value: "size" },
{ title: "", value: "actions", align: "right" },
],
});
function setSelected(data: { name: string; date: string }) {
if (!data.name) {
return;
}
selected.value = data.name;
}
const backupsFileNameDownload = (fileName: string) => `api/admin/backups/${fileName}`;
useSeoMeta({
title: i18n.t("sidebar.backups"),
});
onMounted(refreshBackups);
useHead({
title: i18n.t("sidebar.backups"),
});
</script>
<style>
.v-input--selection-controls__input {
margin-bottom: auto;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<v-container class="pa-0">
<v-container>
<BaseCardSectionTitle :title="$t('admin.debug-openai-services')">
{{ $t('admin.debug-openai-services-description') }}
<br>
<DocLink
class="mt-2"
link="/documentation/getting-started/installation/open-ai"
/>
</BaseCardSectionTitle>
</v-container>
<v-form
ref="uploadForm"
@submit.prevent="testOpenAI"
>
<div>
<v-card-text>
<v-container class="pa-0">
<v-row>
<v-col
cols="auto"
align-self="center"
>
<AppButtonUpload
v-if="!uploadedImage"
class="ml-auto"
url="none"
file-name="image"
accept="image/*"
:text="$t('recipe.upload-image')"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<v-btn
v-if="!!uploadedImage"
color="error"
@click="clearImage"
>
<v-icon start>
{{ $globals.icons.close }}
</v-icon>
{{ $t("recipe.remove-image") }}
</v-btn>
</v-col>
<v-spacer />
</v-row>
<v-row
v-if="uploadedImage && uploadedImagePreviewUrl"
style="max-width: 25%;"
>
<v-spacer />
<v-col cols="12">
<v-img :src="uploadedImagePreviewUrl" />
</v-col>
<v-spacer />
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<BaseButton
type="submit"
:text="$t('admin.run-test')"
:icon="$globals.icons.check"
:loading="loading"
class="ml-auto"
/>
</v-card-actions>
</div>
</v-form>
<v-divider
v-if="response"
class="mt-4"
/>
<v-container
v-if="response"
class="ma-0 pa-0"
>
<v-card-title> {{ $t('admin.test-results') }} </v-card-title>
<v-card-text> {{ response }} </v-card-text>
</v-container>
</v-container>
</template>
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
definePageMeta({
layout: "admin",
});
const api = useAdminApi();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.debug-openai-services"),
});
const loading = ref(false);
const response = ref("");
const uploadedImage = ref<Blob | File>();
const uploadedImageName = ref<string>("");
const uploadedImagePreviewUrl = ref<string>();
function uploadImage(fileObject: File) {
uploadedImage.value = fileObject;
uploadedImageName.value = fileObject.name;
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function clearImage() {
uploadedImage.value = undefined;
uploadedImageName.value = "";
uploadedImagePreviewUrl.value = undefined;
}
async function testOpenAI() {
response.value = "";
loading.value = true;
const { data } = await api.debug.debugOpenAI(uploadedImage.value);
loading.value = false;
if (!data) {
alert.error("Unable to test OpenAI services");
}
else {
response.value = data.response || (data.success ? "Test Successful" : "Test Failed");
}
}
</script>

View File

@@ -0,0 +1,257 @@
<template>
<v-container class="pa-0">
<v-container>
<BaseCardSectionTitle :title="$t('admin.ingredients-natural-language-processor')">
{{ $t('admin.ingredients-natural-language-processor-explanation') }}
<p class="pt-3">
{{ $t('admin.ingredients-natural-language-processor-explanation-2') }}
</p>
</BaseCardSectionTitle>
<div class="d-flex align-center justify-center justify-md-start flex-wrap">
<v-btn-toggle
v-model="state.parser"
density="compact"
mandatory="force"
@change="processIngredient"
>
<v-btn value="nlp">
{{ $t('admin.nlp') }}
</v-btn>
<v-btn value="brute">
{{ $t('admin.brute') }}
</v-btn>
<v-btn value="openai">
{{ $t('admin.openai') }}
</v-btn>
</v-btn-toggle>
<v-spacer />
<v-checkbox
v-model="showConfidence"
class="ml-5"
:label="$t('admin.show-individual-confidence')"
hide-details
/>
</div>
<v-card flat>
<v-card-text>
<v-text-field
v-model="state.ingredient"
:label="$t('admin.ingredient-text')"
/>
</v-card-text>
<v-card-actions>
<BaseButton
class="ml-auto"
@click="processIngredient"
>
<template #icon>
{{ $globals.icons.check }}
</template>
{{ $t("general.submit") }}
</BaseButton>
</v-card-actions>
</v-card>
</v-container>
<v-container v-if="state.results">
<div
v-if="state.parser !== 'brute' && getConfidence('average')"
class="d-flex"
>
<v-chip
dark
:color="getColor('average')"
class="mx-auto mb-2"
>
{{ $t('admin.average-confident', [getConfidence("average")]) }}
</v-chip>
</div>
<div
class="d-flex justify-center flex-wrap"
style="gap: 1.5rem"
>
<template v-for="(prop, index) in properties">
<div
v-if="prop.value"
:key="index"
class="flex-grow-1"
>
<v-card min-width="200px">
<v-card-title> {{ prop.value }} </v-card-title>
<v-card-text>
{{ prop.subtitle }}
</v-card-text>
</v-card>
<v-chip
v-if="prop.confidence && showConfidence"
dark
:color="prop.color!"
class="mt-2"
>
{{ $t('admin.average-confident', [prop.confidence]) }}
</v-chip>
</div>
</template>
</div>
</v-container>
<v-container class="narrow-container">
<v-card-title> {{ $t('admin.try-an-example') }} </v-card-title>
<v-card
v-for="(text, idx) in tryText"
:key="idx"
class="my-2"
hover
@click="processTryText(text)"
>
<v-card-text> {{ text }} </v-card-text>
</v-card>
</v-container>
</v-container>
</template>
<script setup lang="ts">
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import type { IngredientConfidence } from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe";
type ConfidenceAttribute = "average" | "comment" | "name" | "unit" | "quantity" | "food";
definePageMeta({
layout: "admin",
});
const api = useUserApi();
const state = reactive({
loading: false,
ingredient: "",
results: false,
parser: "nlp" as Parser,
});
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.parser"),
});
const confidence = ref<IngredientConfidence>({});
function getColor(attribute: ConfidenceAttribute) {
const percentage = getConfidence(attribute);
if (percentage === undefined) return;
const p_as_num = parseFloat(percentage.replace("%", ""));
// Set color based off range
if (p_as_num > 75) {
return "success";
}
else if (p_as_num > 60) {
return "warning";
}
else {
return "error";
}
}
function getConfidence(attribute: ConfidenceAttribute) {
if (!confidence.value) {
return;
}
const property = confidence.value[attribute];
if (property !== undefined && property !== null) {
return `${(+property * 100).toFixed(0)}%`;
}
return undefined;
}
const tryText = [
"2 tbsp minced cilantro, leaves and stems",
"1 large yellow onion, coarsely chopped",
"1 1/2 tsp garam masala",
"1 inch piece fresh ginger, (peeled and minced)",
"2 cups mango chunks, (2 large mangoes) (fresh or frozen)",
];
function processTryText(str: string) {
state.ingredient = str;
processIngredient();
}
async function processIngredient() {
if (state.ingredient === "") {
return;
}
state.loading = true;
const { data } = await api.recipes.parseIngredient(state.parser, state.ingredient);
if (data) {
state.results = true;
if (data.confidence) confidence.value = data.confidence;
// TODO: Remove ts-ignore
// ts-ignore because data will likely change significantly once I figure out how to return results
// for the parser. For now we'll leave it like this
properties.comment.value = data.ingredient.note || "";
properties.quantity.value = data.ingredient.quantity || "";
properties.unit.value = data.ingredient?.unit?.name || "";
properties.food.value = data.ingredient?.food?.name || "";
(["comment", "quantity", "unit", "food"] as ConfidenceAttribute[]).forEach((property) => {
const color = getColor(property);
const confidence = getConfidence(property);
if (color) {
properties[property].color = color;
}
if (confidence) {
properties[property].confidence = confidence;
}
});
}
else {
alert.error(i18n.t("events.something-went-wrong") as string);
state.results = false;
}
state.loading = false;
}
const properties = reactive({
quantity: {
subtitle: i18n.t("recipe.quantity"),
value: "" as string | number,
color: null,
confidence: null,
},
unit: {
subtitle: i18n.t("recipe.unit"),
value: "",
color: null,
confidence: null,
},
food: {
subtitle: i18n.t("shopping-list.food"),
value: "",
color: null,
confidence: null,
},
comment: {
subtitle: i18n.t("recipe.comment"),
value: "",
color: null,
confidence: null,
},
});
const showConfidence = ref(false);
</script>
<style scoped></style>

View File

@@ -0,0 +1,236 @@
<template>
<v-container fluid class="narrow-container">
<BaseDialog
v-model="state.storageDetails"
:title="$t('admin.maintenance.storage-details')"
:icon="$globals.icons.folderOutline"
>
<div class="py-2">
<template v-for="(value, key, idx) in storageDetails" :key="`item-${key}`">
<v-list-item>
<v-list-item-title>
<div>{{ storageDetailsText(key) }}</div>
</v-list-item-title>
<v-list-item-subtitle class="text-end">
{{ value }}
</v-list-item-subtitle>
</v-list-item>
<v-divider v-if="idx != 4" :key="`divider-${key}`" class="mx-2" />
</template>
</div>
</BaseDialog>
<BasePageTitle divider>
<template #title>
{{ $t("admin.maintenance.page-title") }}
</template>
</BasePageTitle>
<section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.wrench" :title="$t('admin.maintenance.summary-title')" />
<div class="mb-6 d-flex" style="gap: 0.3rem">
<BaseButton color="info" @click="getSummary">
<template #icon>
{{ $globals.icons.tools }}
</template>
{{ $t("admin.maintenance.button-label-get-summary") }}
</BaseButton>
<BaseButton color="info" @click="openDetails">
<template #icon>
{{ $globals.icons.folderOutline }}
</template>
{{ $t("admin.maintenance.button-label-open-details") }}
</BaseButton>
</div>
<v-card class="" :loading="state.fetchingInfo">
<template v-for="(value, idx) in info" :key="`item-${idx}`">
<v-list-item>
<v-list-item-title class="py-2">
<div>{{ value.name }}</div>
<v-list-item-subtitle class="text-end">
{{ value.value }}
</v-list-item-subtitle>
</v-list-item-title>
</v-list-item>
<v-divider class="mx-2" />
</template>
</v-card>
</section>
<section>
<BaseCardSectionTitle
class="pb-0 mt-8"
:icon="$globals.icons.wrench"
:title="$t('admin.mainentance.actions-title')"
>
<i18n-t keypath="admin.maintenance.actions-description">
<template #destructive_in_bold>
<b>{{ $t("admin.maintenance.actions-description-destructive") }}</b>
</template>
<template #irreversible_in_bold>
<b>{{ $t("admin.maintenance.actions-description-irreversible") }}</b>
</template>
</i18n-t>
</BaseCardSectionTitle>
<v-card class="ma-0" flat :loading="state.actionLoading">
<template v-for="(action, idx) in actions" :key="`item-${idx}`">
<v-list-item class="py-2 px-0">
<v-list-item-title>
<div>{{ action.name }}</div>
<v-list-item-subtitle class="wrap-word">
{{ action.subtitle }}
</v-list-item-subtitle>
</v-list-item-title>
<template #append>
<BaseButton color="info" @click="action.handler">
<template #icon>
{{ $globals.icons.robot }}
</template>
{{ $t("general.run") }}
</BaseButton>
</template>
</v-list-item>
<v-divider class="mx-2" />
</template>
</v-card>
</section>
</v-container>
</template>
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import type { MaintenanceStorageDetails, MaintenanceSummary } from "~/lib/api/types/admin";
definePageMeta({
layout: "admin",
});
const state = reactive({
storageDetails: false,
storageDetailsLoading: false,
fetchingInfo: false,
actionLoading: false,
});
const adminApi = useAdminApi();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.maintenance.page-title"),
});
// ==========================================================================
// General Info
const infoResults = ref<MaintenanceSummary>({
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
});
async function getSummary() {
state.fetchingInfo = true;
const { data } = await adminApi.maintenance.getInfo();
infoResults.value = data ?? {
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
};
state.fetchingInfo = false;
}
const info = computed(() => {
return [
{
name: i18n.t("admin.maintenance.info-description-data-dir-size"),
value: infoResults.value.dataDirSize,
},
{
name: i18n.t("admin.maintenance.info-description-cleanable-directories"),
value: infoResults.value.cleanableDirs,
},
{
name: i18n.t("admin.maintenance.info-description-cleanable-images"),
value: infoResults.value.cleanableImages,
},
];
});
// ==========================================================================
// Storage Details
const storageTitles: { [key: string]: string } = {
tempDirSize: i18n.t("admin.maintenance.storage.title-temporary-directory") as string,
backupsDirSize: i18n.t("admin.maintenance.storage.title-backups-directory") as string,
groupsDirSize: i18n.t("admin.maintenance.storage.title-groups-directory") as string,
recipesDirSize: i18n.t("admin.maintenance.storage.title-recipes-directory") as string,
userDirSize: i18n.t("admin.maintenance.storage.title-user-directory") as string,
};
function storageDetailsText(key: string) {
return storageTitles[key] ?? i18n.t("about.unknown-version");
}
const storageDetails = ref<MaintenanceStorageDetails | null>(null);
async function openDetails() {
state.storageDetailsLoading = true;
state.storageDetails = true;
const { data } = await adminApi.maintenance.getStorageDetails();
if (data) {
storageDetails.value = data;
}
state.storageDetailsLoading = true;
}
// ==========================================================================
// Actions
async function handleCleanDirectories() {
state.actionLoading = true;
await adminApi.maintenance.cleanRecipeFolders();
state.actionLoading = false;
}
async function handleCleanImages() {
state.actionLoading = true;
await adminApi.maintenance.cleanImages();
state.actionLoading = false;
}
async function handleCleanTemp() {
state.actionLoading = true;
await adminApi.maintenance.cleanTemp();
state.actionLoading = false;
}
const actions = [
{
name: i18n.t("admin.maintenance.action-clean-directories-name"),
handler: handleCleanDirectories,
subtitle: i18n.t("admin.maintenance.action-clean-directories-description"),
},
{
name: i18n.t("admin.maintenance.action-clean-temporary-files-name"),
handler: handleCleanTemp,
subtitle: i18n.t("admin.maintenance.action-clean-temporary-files-description"),
},
{
name: i18n.t("admin.maintenance.action-clean-images-name"),
handler: handleCleanImages,
subtitle: i18n.t("admin.maintenance.action-clean-images-description"),
},
];
</script>
<style scoped>
.wrap-word {
white-space: normal;
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<v-container
v-if="group"
class="narrow-container"
>
<BasePageTitle>
<template #header>
<v-img
width="100%"
max-height="125"
max-width="125"
src="/svgs/manage-group-settings.svg"
/>
</template>
<template #title>
{{ $t('group.admin-group-management') }}
</template>
</BasePageTitle>
<AppToolbar back />
<v-card-text> {{ $t('group.group-id-value', [group.id]) }} </v-card-text>
<v-form
v-if="!userError"
ref="refGroupEditForm"
@submit.prevent="handleSubmit"
>
<v-card variant="outlined" style="border-color: lightgrey;">
<v-card-text>
<v-text-field
v-model="group.name"
:label="$t('group.group-name')"
/>
<GroupPreferencesEditor
v-if="group.preferences"
v-model="group.preferences"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton
type="submit"
edit
class="ml-auto"
>
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</v-container>
</template>
<script setup lang="ts">
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import type { VForm } from "vuetify/components";
definePageMeta({
layout: "admin",
});
const route = useRoute();
const i18n = useI18n();
const groupId = computed(() => route.params.id as string);
// ==============================================
// New User Form
const refGroupEditForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const userError = ref(false);
const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
if (!groupId.value) {
return null;
}
const { data, error } = await adminApi.groups.getOne(groupId.value);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
return data;
}, { watch: [groupId] });
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || group.value === null) {
return;
}
const { response, data } = await adminApi.groups.updateOne(group.value.id, group.value);
if (response?.status === 200 && data) {
if (group.value.slug !== data.slug) {
// the slug updated, which invalidates the nav URLs
window.location.reload();
}
group.value = data;
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<v-container fluid>
<BaseDialog
v-model="state.createDialog"
:title="$t('group.create-group')"
:icon="$globals.icons.group"
can-submit
@submit="createGroup(state.createGroupForm.data)"
>
<template #activator />
<v-card-text>
<AutoForm
v-model="state.createGroupForm.data"
:update-mode="state.updateMode"
:items="state.createGroupForm.items"
/>
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="state.confirmDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteGroup(state.deleteTarget)"
>
<template #activator />
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle :title="$t('group.group-management')" />
<section>
<v-toolbar
flat
color="transparent"
class="justify-between"
>
<BaseButton @click="openDialog">
{{ $t("general.create") }}
</BaseButton>
</v-toolbar>
<v-data-table
:headers="state.headers"
:items="groups || []"
item-key="id"
class="elevation-0"
:items-per-page="-1"
hide-default-footer
disable-pagination
:search="state.search"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #[`item.households`]="{ item }">
{{ item.households!.length }}
</template>
<template #[`item.users`]="{ item }">
{{ item.users!.length }}
</template>
<template #[`item.actions`]="{ item }">
<v-tooltip
location="bottom"
:disabled="!(item && (item.households!.length > 0 || item.users!.length > 0))"
>
<template #activator="{ props }">
<div v-bind="props">
<v-btn
:disabled="item && (item.households!.length > 0 || item.users!.length > 0)"
class="mr-1"
icon
color="error"
variant="text"
@click.stop="
state.confirmDialog = true;
state.deleteTarget = item.id;
"
>
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</div>
</template>
<span>{{ $t("admin.group-delete-note") }}</span>
</v-tooltip>
</template>
</v-data-table>
<v-divider />
</section>
</v-container>
</template>
<script setup lang="ts">
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
import { validators } from "~/composables/use-validators";
import type { GroupInDB } from "~/lib/api/types/user";
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
useHead({
title: i18n.t("group.manage-groups"),
});
// Set page title
useSeoMeta({
title: i18n.t("group.manage-groups"),
});
const { groups, deleteGroup, createGroup } = useGroups();
const state = reactive({
createDialog: false,
confirmDialog: false,
deleteTarget: "",
search: "",
headers: [
{
title: i18n.t("group.group"),
align: "start",
sortable: false,
value: "id",
},
{ title: i18n.t("general.name"), value: "name" },
{ title: i18n.t("group.total-households"), value: "households" },
{ title: i18n.t("user.total-users"), value: "users" },
{ title: i18n.t("general.delete"), value: "actions" },
],
updateMode: false,
createGroupForm: {
items: [
{
label: i18n.t("group.group-name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
],
data: {
name: "",
},
},
});
function openDialog() {
state.createDialog = true;
state.createGroupForm.data.name = "";
}
function handleRowClick(item: GroupInDB) {
navigateTo(`/admin/manage/groups/${item.id}`);
}
</script>

View File

@@ -0,0 +1,123 @@
<template>
<v-container
v-if="household"
class="narrow-container"
>
<BasePageTitle>
<template #header>
<v-img
width="100%"
max-height="125"
max-width="125"
src="/svgs/manage-group-settings.svg"
/>
</template>
<template #title>
{{ $t('household.admin-household-management') }}
</template>
</BasePageTitle>
<AppToolbar back />
<v-card-text> {{ $t('household.household-id-value', [household.id]) }} </v-card-text>
<v-form
v-if="!userError"
ref="refHouseholdEditForm"
@submit.prevent="handleSubmit"
>
<v-card variant="outlined" style="border-color: lightgrey;">
<v-card-text>
<v-select
v-if="groups"
v-model="household.groupId"
disabled
:items="groups"
variant="solo-filled"
flat
item-title="name"
item-value="id"
:return-object="false"
:label="$t('group.user-group')"
:rules="[validators.required]"
/>
<v-text-field
v-model="household.name"
variant="solo-filled"
flat
:label="$t('household.household-name')"
:rules="[validators.required]"
/>
<HouseholdPreferencesEditor
v-if="household.preferences"
v-model="household.preferences"
variant="solo-filled"
flat
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton
type="submit"
edit
class="ml-auto"
>
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</v-container>
</template>
<script setup lang="ts">
import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue";
import { useGroups } from "~/composables/use-groups";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { validators } from "~/composables/use-validators";
import type { VForm } from "vuetify/components";
definePageMeta({
layout: "admin",
});
const route = useRoute();
const i18n = useI18n();
const { groups } = useGroups();
const householdId = computed(() => route.params.id as string);
// ==============================================
// New User Form
const refHouseholdEditForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const userError = ref(false);
const { data: household } = useAsyncData(`get-household-${householdId.value}`, async () => {
if (!householdId.value) {
return null;
}
const { data, error } = await adminApi.households.getOne(householdId.value);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
return data;
}, { watch: [householdId] });
async function handleSubmit() {
if (!refHouseholdEditForm.value?.validate() || household.value === null) {
return;
}
const { response, data } = await adminApi.households.updateOne(household.value.id, household.value);
if (response?.status === 200 && data) {
household.value = data;
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
</script>

View File

@@ -0,0 +1,192 @@
<template>
<v-container fluid>
<BaseDialog
v-model="createDialog"
:title="$t('household.create-household')"
:icon="$globals.icons.household"
>
<template #activator />
<v-card-text>
<v-form ref="refNewHouseholdForm" @keydown.enter.prevent="handleCreateSubmit">
<v-select
v-if="groups"
v-model="createHouseholdForm.data.groupId"
:items="groups"
item-title="name"
item-value="id"
variant="filled"
:label="$t('household.household-group')"
:rules="[validators.required]"
/>
<AutoForm
v-model="createHouseholdForm.data"
:update-mode="updateMode"
:items="createHouseholdForm.items"
/>
</v-form>
</v-card-text>
<template #custom-card-action>
<BaseButton
type="submit"
@click="handleCreateSubmit"
>
{{ $t("general.create") }}
</BaseButton>
</template>
</BaseDialog>
<BaseDialog
v-model="confirmDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteHousehold(deleteTarget)"
>
<template #activator />
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle :title="$t('household.household-management')" />
<section>
<v-toolbar
flat
color="transparent"
class="justify-between"
>
<BaseButton @click="openDialog">
{{ $t("general.create") }}
</BaseButton>
</v-toolbar>
<v-data-table
v-if="headers && households"
:headers="headers"
:items="households"
item-key="id"
class="elevation-0"
:items-per-page="-1"
hide-default-footer
disable-pagination
:search="search"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #[`item.users`]="{ item }">
{{ item.users?.length }}
</template>
<template #[`item.group`]="{ item }">
{{ item.group }}
</template>
<template #[`item.webhookEnable`]="{ item }">
{{ item.webhooks!.length > 0 ? $t("general.yes") : $t("general.no") }}
</template>
<template #[`item.actions`]="{ item }">
<v-tooltip
location="bottom"
:disabled="!(item && item.users!.length > 0)"
>
<template #activator="{ props }">
<div v-bind="props">
<v-btn
:disabled="item && item.users!.length > 0"
class="mr-1"
icon
color="error"
variant="text"
@click.stop="confirmDialog = true; deleteTarget = item.id"
>
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</div>
</template>
<span>{{ $t("admin.household-delete-note") }}</span>
</v-tooltip>
</template>
</v-data-table>
<v-divider />
</section>
</v-container>
</template>
<script setup lang="ts">
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
import { useAdminHouseholds } from "~/composables/use-households";
import { validators } from "~/composables/use-validators";
import type { HouseholdInDB } from "~/lib/api/types/household";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
useSeoMeta({
title: i18n.t("household.manage-households"),
});
const { groups } = useGroups();
const { households, deleteHousehold, createHousehold } = useAdminHouseholds();
const refNewHouseholdForm = ref<VForm | null>(null);
const createDialog = ref(false);
const confirmDialog = ref(false);
const deleteTarget = ref<string>("");
const search = ref("");
const updateMode = ref(false);
const headers = [
{
title: i18n.t("household.household"),
align: "start",
sortable: false,
value: "id",
},
{ title: i18n.t("general.name"), value: "name" },
{ title: i18n.t("group.group"), value: "group" },
{ title: i18n.t("user.total-users"), value: "users" },
{ title: i18n.t("user.webhooks-enabled"), value: "webhookEnable" },
{ title: i18n.t("general.delete"), value: "actions" },
];
const createHouseholdForm = reactive({
items: [
{
label: i18n.t("household.household-name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
],
data: {
groupId: "",
name: "",
},
});
function openDialog() {
createDialog.value = true;
createHouseholdForm.data.name = "";
createHouseholdForm.data.groupId = "";
}
const router = useRouter();
function handleRowClick(item: HouseholdInDB) {
router.push(`/admin/manage/households/${item.id}`);
}
async function handleCreateSubmit() {
if (!refNewHouseholdForm.value?.validate()) {
return;
}
createDialog.value = false;
await createHousehold(createHouseholdForm.data);
}
</script>

View File

@@ -0,0 +1,228 @@
<template>
<v-container
v-if="user"
class="narrow-container"
>
<BasePageTitle>
<template #header>
<v-img
width="100%"
max-height="125"
max-width="125"
src="/svgs/manage-profile.svg"
/>
</template>
<template #title>
{{ $t("user.admin-user-management") }}
</template>
{{ $t("user.changes-reflected-immediately") }}
</BasePageTitle>
<AppToolbar back />
<v-form
v-if="!userError"
ref="refNewUserForm"
@submit.prevent="handleSubmit"
>
<v-card
variant="outlined"
style="border-color: lightgrey;"
>
<v-sheet class="pt-4">
<v-card-text>
<div class="d-flex">
<p> {{ $t("user.user-id-with-value", { id: user.id }) }}</p>
</div>
<!-- This is disabled since we can't properly handle changing the user's group in most scenarios -->
<v-row>
<v-col cols="6">
<v-select
v-if="groups"
v-model="user.group"
disabled
:items="groups"
variant="solo-filled"
flat
item-title="name"
item-value="name"
:return-object="false"
:label="$t('group.user-group')"
:rules="[validators.required]"
/>
</v-col>
<v-col cols="6">
<v-select
v-if="households"
v-model="user.household"
:items="households"
variant="solo-filled"
flat
item-title="name"
item-value="name"
:return-object="false"
:label="$t('household.user-household')"
:rules="[validators.required]"
/>
</v-col>
</v-row>
<div class="d-flex py-2 pr-2">
<BaseButton
type="button"
:loading="generatingToken"
create
@click.prevent="handlePasswordReset"
>
{{ $t("user.generate-password-reset-link") }}
</BaseButton>
</div>
<div
v-if="resetUrl"
class="mb-2"
>
<v-card-text>
<p class="text-center pb-0">
{{ resetUrl }}
</p>
</v-card-text>
<v-card-actions
class="align-center pt-0"
style="gap: 4px"
>
<BaseButton
cancel
@click="resetUrl = ''"
>
{{ $t("general.close") }}
</BaseButton>
<v-spacer />
<BaseButton
v-if="user.email"
color="info"
class="mr-1"
@click="sendResetEmail"
>
<template #icon>
{{ $globals.icons.email }}
</template>
{{ $t("user.email") }}
</BaseButton>
<AppButtonCopy
:icon="false"
color="info"
:copy-text="resetUrl"
/>
</v-card-actions>
</div>
<AutoForm
v-model="user"
:items="userForm"
update-mode
:disabled-fields="disabledFields"
/>
</v-card-text>
</v-sheet>
</v-card>
<div class="d-flex pa-2">
<BaseButton
type="submit"
edit
class="ml-auto"
>
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</v-container>
</template>
<script setup lang="ts">
import { useAdminApi, useUserApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useAdminHouseholds } from "~/composables/use-households";
import { alert } from "~/composables/use-toast";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
import type { UserOut } from "~/lib/api/types/user";
definePageMeta({
layout: "admin",
});
const { userForm } = useUserForm();
const { groups } = useGroups();
const { useHouseholdsInGroup } = useAdminHouseholds();
const i18n = useI18n();
const route = useRoute();
const userId = route.params.id as string;
// ==============================================
// New User Form
const refNewUserForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const user = ref<UserOut | null>(null);
const households = useHouseholdsInGroup(computed(() => user.value?.groupId || ""));
const disabledFields = computed(() => {
return user.value?.authMethod !== "Mealie" ? ["admin"] : [];
});
const userError = ref(false);
const resetUrl = ref<string | null>(null);
const generatingToken = ref(false);
onMounted(async () => {
const { data, error } = await adminApi.users.getOne(userId);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
if (data) {
user.value = data;
}
});
async function handleSubmit() {
if (!refNewUserForm.value?.validate() || user.value === null) return;
const { response, data } = await adminApi.users.updateOne(user.value.id, user.value);
if (response?.status === 200 && data) {
user.value = data;
}
}
async function handlePasswordReset() {
if (user.value === null) return;
generatingToken.value = true;
const { response, data } = await adminApi.users.generatePasswordResetToken({ email: user.value.email });
if (response?.status === 201 && data) {
const token: string = data.token;
resetUrl.value = `${window.location.origin}/reset-password/?token=${token}`;
}
generatingToken.value = false;
}
const userApi = useUserApi();
async function sendResetEmail() {
if (!user.value?.email) return;
const { response } = await userApi.email.sendForgotPassword({ email: user.value.email });
if (response && response.status === 200) {
alert.success(i18n.t("profile.email-sent"));
}
else {
alert.error(i18n.t("profile.error-sending-email"));
}
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<v-container class="narrow-container">
<BasePageTitle class="mb-2">
<template #header>
<v-img
width="100%"
max-height="125"
max-width="125"
src="/svgs/manage-profile.svg"
/>
</template>
<template #title>
{{ $t('user.admin-user-creation') }}
</template>
</BasePageTitle>
<AppToolbar back />
<v-form
ref="refNewUserForm"
@submit.prevent="handleSubmit"
>
<v-card variant="outlined">
<v-card-text>
<v-sheet>
<v-row>
<v-col cols="6">
<v-select
v-model="selectedGroup"
:items="groups || []"
item-title="name"
return-object
variant="filled"
:label="$t('group.user-group')"
:rules="[validators.required]"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="newUserData.household"
:disabled="!selectedGroup"
:items="households"
item-title="name"
item-value="name"
variant="filled"
:label="$t('household.user-household')"
:hint="selectedGroup ? '' : $t('group.you-must-select-a-group-before-selecting-a-household')"
persistent-hint
:rules="[validators.required]"
/>
</v-col>
</v-row>
</v-sheet>
<AutoForm
v-model="newUserData"
:items="userForm"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton
type="submit"
class="ml-auto"
/>
</div>
</v-form>
</v-container>
</template>
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
import type { GroupInDB, UserIn } from "~/lib/api/types/user";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
layout: "admin",
});
const { userForm } = useUserForm();
const { groups } = useGroups();
const router = useRouter();
const refNewUserForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const selectedGroup = ref<GroupInDB | undefined>(undefined);
const households = computed(() => selectedGroup.value?.households || []);
const newUserData = ref({
username: "",
fullName: "",
email: "",
admin: false,
group: computed(() => selectedGroup.value?.name || ""),
household: "",
advanced: false,
canInvite: false,
canManage: false,
canOrganize: false,
password: "",
authMethod: "Mealie",
});
async function handleSubmit() {
if (!refNewUserForm.value?.validate()) return;
const { response } = await adminApi.users.createOne(newUserData.value as UserIn);
if (response?.status === 201) {
router.push("/admin/manage/users");
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,192 @@
<template>
<v-container fluid>
<UserInviteDialog v-model="inviteDialog" />
<BaseDialog
v-model="state.deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteUser(state.deleteTargetId)"
>
<template #activator />
<v-card-text>
<v-alert
v-if="isUserOwnAccount"
type="warning"
:text="$t('general.confirm-delete-own-admin-account')"
variant="outlined"
/>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle :title="$t('user.user-management')" />
<section>
<v-toolbar
color="transparent"
flat
class="justify-between"
>
<BaseButton
to="/admin/manage/users/create"
class="mr-2"
>
{{ $t("general.create") }}
</BaseButton>
<BaseButton
v-if="$appInfo.allowPasswordLogin"
class="mr-2"
color="info"
:icon="$globals.icons.link"
@click="inviteDialog = true"
>
{{ $t("group.invite") }}
</BaseButton>
<BaseOverflowButton
mode="event"
variant="elevated"
:items="ACTIONS_OPTIONS"
@unlock-all-users="unlockAllUsers"
/>
</v-toolbar>
<v-data-table
:headers="headers"
:items="users || []"
item-key="id"
class="elevation-0"
elevation="0"
:items-per-page="-1"
hide-default-footer
disable-pagination
:search="state.search"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #[`item.admin`]="{ item }">
<v-icon
end
:color="item.admin ? 'success' : undefined"
>
{{ item.admin ? $globals.icons.checkboxMarkedCircle : $globals.icons.windowClose }}
</v-icon>
</template>
<template #[`item.actions`]="{ item }">
<v-btn
icon
:disabled="+item.id == 1"
color="error"
variant="text"
@click.stop="
state.deleteDialog = true;
state.deleteTargetId = item.id;
"
>
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</template>
</v-data-table>
<v-divider />
</section>
</v-container>
</template>
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useUser, useAllUsers } from "~/composables/use-user";
import type { UserOut } from "~/lib/api/types/user";
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
useHead({
title: i18n.t("sidebar.manage-users"),
});
const api = useAdminApi();
const inviteDialog = ref();
const auth = useMealieAuth();
const user = computed(() => auth.user.value);
const { $globals } = useNuxtApp();
const router = useRouter();
const isUserOwnAccount = computed(() => {
return state.deleteTargetId === user.value?.id;
});
const ACTIONS_OPTIONS = [
{
text: i18n.t("user.reset-locked-users"),
icon: $globals.icons.lock,
event: "unlock-all-users",
},
];
const state = reactive({
deleteDialog: false,
deleteTargetId: "",
search: "",
groups: [],
households: [],
sendTo: "",
});
const { users, refreshAllUsers } = useAllUsers();
const { deleteUser: deleteUserMixin } = useUser(refreshAllUsers);
function deleteUser(id: string) {
deleteUserMixin(id);
if (isUserOwnAccount.value) {
auth.refresh();
}
}
function handleRowClick(item: UserOut) {
router.push(`/admin/manage/users/${item.id}`);
}
// ==========================================================
// Constants / Non-reactive
const headers = [
{
title: i18n.t("user.user-id"),
align: "start",
value: "id",
},
{ title: i18n.t("user.username"), value: "username" },
{ title: i18n.t("user.full-name"), value: "fullName" },
{ title: i18n.t("user.email"), value: "email" },
{ title: i18n.t("group.group"), value: "group" },
{ title: i18n.t("household.household"), value: "household" },
{ title: i18n.t("user.auth-method"), value: "authMethod" },
{ title: i18n.t("user.admin"), value: "admin" },
{ title: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" },
];
async function unlockAllUsers(): Promise<void> {
const { data } = await api.users.unlockAllUsers(true);
if (data) {
const unlocked = data.unlocked ?? 0;
alert.success(`${unlocked} user(s) unlocked`);
refreshAllUsers();
}
}
useSeoMeta({
title: i18n.t("sidebar.manage-users"),
});
</script>

View File

@@ -0,0 +1,578 @@
<template>
<v-container
fluid
class="d-flex justify-center align-start fill-height"
:class="{
'bg-off-white': !$vuetify.theme.current.dark && !isDark,
}"
>
<!-- Header Toolbar -->
<v-card class="elevation-4" width="1200" :class="{ 'my-10': $vuetify.display.mdAndUp }">
<v-toolbar
color="primary"
class="d-flex justify-center"
dark
>
<v-toolbar-title class="headline text-h4 text-center mx-0">
Mealie
</v-toolbar-title>
</v-toolbar>
<!-- Stepper Wizard -->
<v-stepper v-model="currentPage" mobile-breakpoint="sm" alt-labels>
<v-stepper-header>
<v-stepper-item
:value="Pages.LANDING"
:icon="$globals.icons.wave"
:complete="currentPage > Pages.LANDING"
:color="getStepperColor(currentPage, Pages.LANDING)"
:title="$t('general.start')"
/>
<v-divider />
<v-stepper-item
:value="Pages.USER_INFO"
:icon="$globals.icons.user"
:complete="currentPage > Pages.USER_INFO"
:color="getStepperColor(currentPage, Pages.USER_INFO)"
:title="$t('user-registration.account-details')"
/>
<v-divider />
<v-stepper-item
:value="Pages.PAGE_2"
:icon="$globals.icons.cog"
:complete="currentPage > Pages.PAGE_2"
:color="getStepperColor(currentPage, Pages.PAGE_2)"
:title="$t('settings.site-settings')"
/>
<v-divider />
<v-stepper-item
:value="Pages.CONFIRM"
:icon="$globals.icons.chefHat"
:complete="currentPage > Pages.CONFIRM"
:color="getStepperColor(currentPage, Pages.CONFIRM)"
:title="$t('admin.maintenance.summary-title')"
/>
<v-divider />
<v-stepper-item
:value="Pages.END"
:icon="$globals.icons.check"
:complete="currentPage > Pages.END"
:color="getStepperColor(currentPage, Pages.END)"
:title="$t('admin.setup.setup-complete')"
/>
</v-stepper-header>
<v-progress-linear
v-if="isSubmitting && currentPage === Pages.CONFIRM"
color="primary"
indeterminate
class="mb-2"
/>
<v-stepper-window :transition="false" class="stepper-window">
<!-- LANDING -->
<v-stepper-window-item :value="Pages.LANDING">
<v-container class="mb-12">
<AppLogo />
<v-card-title class="text-h4 justify-center text-center text-break text-pre-wrap">
{{ $t('admin.setup.welcome-to-mealie-get-started') }}
</v-card-title>
<v-btn
:to="groupSlug ? `/g/${groupSlug}` : '/login'"
rounded
variant="outlined"
color="grey-lighten-1"
class="text-subtitle-2 d-flex mx-auto"
style="width: fit-content;"
>
{{ $t('admin.setup.already-set-up-bring-to-homepage') }}
</v-btn>
</v-container>
<v-card-actions class="justify-center flex-column py-8">
<BaseButton
size="large"
color="primary"
class="px-10"
rounded
:icon="$globals.icons.translate"
@click="langDialog = true"
>
{{ $t('language-dialog.choose-language') }}
</BaseButton>
</v-card-actions>
<v-stepper-actions
class="justify-end"
:disabled="isSubmitting"
next-text="general.next"
@click:next="onNext"
>
<template #next>
<v-btn
variant="flat"
color="success"
:disabled="isSubmitting"
:loading="isSubmitting"
:text="$t('general.next')"
@click="onNext"
/>
</template>
<template #prev />
</v-stepper-actions>
</v-stepper-window-item>
<!-- USER INFO -->
<v-stepper-window-item :value="Pages.USER_INFO" eager>
<v-container max-width="880">
<UserRegistrationForm />
</v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<v-btn
variant="flat"
color="success"
:disabled="isSubmitting"
:loading="isSubmitting"
:text="$t('general.next')"
@click="onNext"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
<!-- COMMON SETTINGS -->
<v-stepper-window-item :value="Pages.PAGE_2">
<v-container max-width="880">
<v-card-title class="headline pa-0">
{{ $t('admin.setup.common-settings-for-new-sites') }}
</v-card-title>
<AutoForm
v-model="commonSettings"
:items="commonSettingsForm"
/>
</v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<v-btn
variant="flat"
color="success"
:disabled="isSubmitting"
:loading="isSubmitting"
:text="$t('general.next')"
@click="onNext"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
<!-- CONFIRMATION -->
<v-stepper-window-item :value="Pages.CONFIRM">
<v-container max-width="880">
<v-card-title class="headline pa-0">
{{ $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"
class="px-0"
>
<v-list-item-title>{{ item.text }}</v-list-item-title>
<v-list-item-subtitle>{{ item.value }}</v-list-item-subtitle>
</v-list-item>
<v-divider
v-if="idx !== confirmationData.length - 1"
:key="`divider-${idx}`"
/>
</template>
</v-list>
</v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<BaseButton
create
flat
:disabled="isSubmitting"
:loading="isSubmitting"
:icon="$globals.icons.check"
:text="$t('general.submit')"
@click="onNext"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
<!-- END -->
<v-stepper-window-item :value="Pages.END">
<EndPageContent />
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<BaseButton
flat
color="primary"
:disabled="isSubmitting"
:loading="isSubmitting"
:icon="$globals.icons.home"
:text="$t('general.home')"
@click="onFinish"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
</v-stepper-window>
</v-stepper>
<!-- Dialog Language -->
<LanguageDialog v-model="langDialog" />
</v-card>
</v-container>
</template>
<script setup lang="ts">
import { useDark } from "@vueuse/core";
import { useAdminApi, 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";
definePageMeta({
layout: "blank",
middleware: ["admin-only"],
});
// ================================================================
// Setup
const i18n = useI18n();
const auth = useMealieAuth();
const userApi = useUserApi();
const adminApi = useAdminApi();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const { locale } = useLocales();
const router = useRouter();
const isSubmitting = ref(false);
const langDialog = ref(false);
const isDark = useDark();
useSeoMeta({
title: i18n.t("admin.setup.first-time-setup"),
});
enum Pages {
LANDING = 1,
USER_INFO = 2,
PAGE_2 = 3,
CONFIRM = 4,
END = 5,
}
function getStepperColor(currentPage: Pages, page: Pages) {
if (currentPage == page) {
return "info";
}
if (currentPage > page) {
return "success";
}
return "";
}
// ================================================================
// Forms
const { accountDetails, credentials } = useUserRegistrationForm();
const { commonSettingsForm } = useCommonSettingsForm();
const commonSettings = ref({
makeGroupRecipesPublic: false,
useSeedData: true,
});
const confirmationData = computed(() => {
return [
{
display: true,
text: i18n.t("user.email"),
value: accountDetails.email.value,
},
{
display: true,
text: i18n.t("user.username"),
value: accountDetails.username.value,
},
{
display: true,
text: i18n.t("user.full-name"),
value: accountDetails.fullName.value,
},
{
display: true,
text: i18n.t("user.enable-advanced-content"),
value: accountDetails.advancedOptions.value ? i18n.t("general.yes") : i18n.t("general.no"),
},
{
display: true,
text: i18n.t("group.enable-public-access"),
value: commonSettings.value.makeGroupRecipesPublic ? i18n.t("general.yes") : i18n.t("general.no"),
},
{
display: true,
text: i18n.t("user-registration.use-seed-data"),
value: commonSettings.value.useSeedData ? i18n.t("general.yes") : i18n.t("general.no"),
},
];
});
// ================================================================
// Page Navigation
const currentPage = ref(Pages.LANDING);
// ================================================================
// Page Submission
async function updateUser() {
// Note: auth.user is now a ref
const { response } = await userApi.users.updateOne(auth.user.value!.id, {
...auth.user.value,
email: accountDetails.email.value,
username: accountDetails.username.value,
fullName: accountDetails.fullName.value,
advanced: accountDetails.advancedOptions.value,
});
if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong"));
}
else {
auth.refresh();
}
}
async function updatePassword() {
const { response } = await userApi.users.changePassword({
currentPassword: "MyPassword",
newPassword: credentials.password1.value,
});
if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong"));
}
}
async function submitRegistration() {
// we update the password first, then update the user's details
await updatePassword().then(updateUser);
}
async function updateGroup() {
// Note: auth.user is now a ref
const { data } = await userApi.groups.getOne(auth.user.value!.groupId);
if (!data || !data.preferences) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
const preferences = {
...data.preferences,
privateGroup: !commonSettings.value.makeGroupRecipesPublic,
};
const payload = {
...data,
preferences,
};
// Note: auth.user is now a ref
const { response } = await userApi.groups.updateOne(auth.user.value!.groupId, payload);
if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong"));
}
}
async function updateHousehold() {
// Note: auth.user is now a ref
const { data } = await adminApi.households.getOne(auth.user.value!.householdId);
if (!data || !data.preferences) {
alert.error(i18n.t("events.something-went-wrong"));
return;
}
const preferences = {
...data.preferences,
privateHousehold: !commonSettings.value.makeGroupRecipesPublic,
recipePublic: commonSettings.value.makeGroupRecipesPublic,
};
const payload = {
...data,
preferences,
};
// Note: auth.user is now a ref
const { response } = await adminApi.households.updateOne(auth.user.value!.householdId, payload);
if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong"));
}
}
async function seedFoods() {
const { response } = await userApi.seeders.foods({ locale: locale.value });
if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong"));
}
}
async function seedUnits() {
const { response } = await userApi.seeders.units({ locale: locale.value });
if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong"));
}
}
async function seedLabels() {
const { response } = await userApi.seeders.labels({ locale: locale.value });
if (!response || response.status !== 200) {
alert.error(i18n.t("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(),
updateHousehold(),
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;
}
// ================================================================
// Stepper Navigation Handlers
function onPrev() {
if (isSubmitting.value) return;
if (currentPage.value > Pages.LANDING) currentPage.value -= 1;
}
async function onNext() {
if (isSubmitting.value) return;
if (currentPage.value === Pages.USER_INFO) {
await handleSubmit(Pages.USER_INFO);
return;
}
if (currentPage.value === Pages.CONFIRM) {
await handleSubmit(Pages.CONFIRM);
return;
}
currentPage.value += 1;
}
async function onFinish() {
if (isSubmitting.value) return;
await handleSubmit(Pages.END);
}
</script>
<style>
.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;
}
.v-stepper-item__avatar.v-avatar.v-stepper-item__avatar.v-avatar {
width: 3rem !important; /** Override inline style :( */
height: 3rem !important; /** Override inline style :( */
margin-inline-end: 0; /** reset weird margin */
.v-icon {
font-size: 1.4rem;
}
}
.v-stepper--alt-labels .v-stepper-header .v-divider {
margin: 48px -42px 0 !important;
}
</style>

View File

@@ -0,0 +1,505 @@
<template>
<v-container
fluid
class="narrow-container"
>
<!-- Image -->
<BasePageTitle divider>
<template #header>
<v-img
width="100%"
max-height="200"
max-width="150"
src="/svgs/admin-site-settings.svg"
/>
</template>
<template #title>
{{ $t("settings.site-settings") }}
</template>
</BasePageTitle>
<!-- Bug Report -->
<BaseDialog
v-model="bugReportDialog"
:title="$t('settings.bug-report')"
:width="800"
:icon="$globals.icons.github"
>
<v-card-text>
<div class="pb-4">
{{ $t('settings.bug-report-information') }}
</div>
<v-textarea
v-model="bugReportText"
variant="outlined"
rows="18"
readonly
/>
<div
class="d-flex justify-end"
style="gap: 5px"
>
<BaseButton
color="gray"
secondary
target="_blank"
href="https://github.com/mealie-recipes/mealie/issues/new/choose"
>
<template #icon>
{{ $globals.icons.github }}
</template>
{{ $t('settings.tracker') }}
</BaseButton>
<AppButtonCopy
:copy-text="bugReportText"
color="info"
:icon="false"
/>
</div>
</v-card-text>
</BaseDialog>
<div class="d-flex justify-end">
<BaseButton
color="info"
@click="
bugReportDialog = true;
"
>
<template #icon>
{{ $globals.icons.github }}
</template>
{{ $t('settings.bug-report') }}
</BaseButton>
</div>
<!-- Configuration -->
<section>
<BaseCardSectionTitle
class="pb-0"
:icon="$globals.icons.cog"
:title="$t('settings.configuration')"
/>
<v-card class="mb-4">
<template
v-for="(check, idx) in simpleChecks"
:key="`list-item-${idx}`"
>
<v-list-item :title="check.text">
<template #prepend>
<v-icon :color="check.color" class="opacity-100">
{{ check.icon }}
</v-icon>
</template>
<v-list-item-subtitle class="wrap-word">
{{ check.status ? check.successText : check.errorText }}
</v-list-item-subtitle>
</v-list-item>
<v-divider />
</template>
</v-card>
</section>
<!-- Email -->
<section>
<BaseCardSectionTitle
class="pt-2"
:icon="$globals.icons.email"
:title="$t('user.email')"
/>
<v-alert
border="start"
:border-color="appConfig.emailReady ? 'success' : 'error'"
variant="text"
elevation="2"
>
<template #prepend>
<v-icon :color="appConfig.emailReady ? 'success' : 'warning'">
{{ appConfig.emailReady ? $globals.icons.checkboxMarkedCircle : $globals.icons.alertCircle }}
</v-icon>
</template>
<div class="font-weight-medium">
{{ $t('settings.email-configuration-status') }}
</div>
<div>
{{ appConfig.emailReady ? $t('settings.ready') : $t('settings.not-ready') }}
</div>
<div>
<v-text-field
v-model="state.address"
class="mr-4"
:label="$t('user.email')"
:rules="[validators.email]"
/>
<BaseButton
color="info"
variant="elevated"
:disabled="!appConfig.emailReady || !validEmail"
:loading="state.loading"
class="opacity-100"
@click="testEmail"
>
<template #icon>
{{ $globals.icons.email }}
</template>
{{ $t("general.test") }}
</BaseButton>
<template v-if="state.tested">
<v-divider class="my-x mt-6" />
<v-card-text class="px-0">
<h4> {{ $t("settings.email-test-results") }}</h4>
<span class="pl-4">
{{ state.success ? $t('settings.succeeded') : $t('settings.failed') }}
</span>
</v-card-text>
</template>
</div>
</v-alert>
</section>
<!-- General App Info -->
<section class="mt-4">
<BaseCardSectionTitle
class="pb-0"
:icon="$globals.icons.cog"
:title="$t('settings.general-about')"
/>
<v-card class="mb-4">
<template v-if="appInfo && appInfo.length">
<template
v-for="(property, idx) in appInfo"
:key="property.name"
>
<v-list-item
:title="property.name"
:prepend-icon="property.icon || $globals.icons.user"
>
<template v-if="property.slot === 'recipe-scraper'">
<v-list-item-subtitle>
<a
target="_blank"
:href="`https://github.com/hhursev/recipe-scrapers/releases/tag/${property.value}`"
>
{{ property.value }}
</a>
</v-list-item-subtitle>
</template>
<template v-else-if="property.slot === 'build'">
<v-list-item-subtitle>
<a
target="_blank"
:href="`https://github.com/mealie-recipes/mealie/commit/${property.value}`"
>
{{ property.value }}
</a>
</v-list-item-subtitle>
</template>
<template v-else-if="property.slot === 'version' && property.value !== 'develop' && property.value !== 'nightly'">
<v-list-item-subtitle>
<a
target="_blank"
:href="`https://github.com/mealie-recipes/mealie/releases/tag/${property.value}`"
>
{{ property.value }}
</a>
</v-list-item-subtitle>
</template>
<template v-else>
<v-list-item-subtitle>
{{ property.value }}
</v-list-item-subtitle>
</template>
</v-list-item>
<v-divider
v-if="appInfo && idx !== appInfo.length - 1"
:key="`divider-${property.name}`"
/>
</template>
</template>
<template v-else>
<div class="mb-3 text-center">
<AppLoader :waiting-text="$t('general.loading')" />
</div>
</template>
</v-card>
</section>
</v-container>
</template>
<script setup lang="ts">
import type { TranslateResult } from "vue-i18n";
import { useAdminApi, useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { useAsyncKey } from "~/composables/use-utils";
import type { CheckAppConfig } from "~/lib/api/types/admin";
import AppLoader from "~/components/global/AppLoader.vue";
interface SimpleCheck {
id: string;
text: TranslateResult;
status: boolean | undefined;
successText: TranslateResult;
errorText: TranslateResult;
color: string;
icon: string;
}
interface CheckApp extends CheckAppConfig {
isSiteSecure?: boolean;
}
definePageMeta({
layout: "admin",
});
// For some reason the layout is not set automatically, so we set it here,
// even though it's defined above in the page meta.
onMounted(() => {
setPageLayout("admin");
});
const { $globals } = useNuxtApp();
const i18n = useI18n();
const state = reactive({
loading: false,
address: "",
success: false,
error: "",
tested: false,
});
// Set page title
useSeoMeta({
title: i18n.t("settings.site-settings"),
});
const appConfig = ref<CheckApp>({
emailReady: true,
baseUrlSet: true,
isSiteSecure: true,
isUpToDate: false,
ldapReady: false,
oidcReady: false,
enableOpenai: false,
});
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
}
const api = useUserApi();
const adminApi = useAdminApi();
onMounted(async () => {
const { data } = await adminApi.about.checkApp();
if (data) {
appConfig.value = { ...data, isSiteSecure: false };
}
appConfig.value.isSiteSecure = isLocalHostOrHttps();
});
const simpleChecks = computed<SimpleCheck[]>(() => {
const goodIcon = $globals.icons.checkboxMarkedCircle;
const badIcon = $globals.icons.alert;
const warningIcon = $globals.icons.alertCircle;
const goodColor = "success";
const badColor = "error";
const warningColor = "warning";
const data: SimpleCheck[] = [
{
id: "application-version",
text: i18n.t("settings.application-version"),
status: appConfig.value.isUpToDate,
errorText: i18n.t("settings.application-version-error-text", [rawAppInfo.value.version, rawAppInfo.value.versionLatest]),
successText: i18n.t("settings.mealie-is-up-to-date"),
color: appConfig.value.isUpToDate ? goodColor : warningColor,
icon: appConfig.value.isUpToDate ? goodIcon : warningIcon,
},
{
id: "secure-site",
text: i18n.t("settings.secure-site"),
status: appConfig.value.isSiteSecure,
errorText: i18n.t("settings.secure-site-error-text"),
successText: i18n.t("settings.secure-site-success-text"),
color: appConfig.value.isSiteSecure ? goodColor : badColor,
icon: appConfig.value.isSiteSecure ? goodIcon : badIcon,
},
{
id: "server-side-base-url",
text: i18n.t("settings.server-side-base-url"),
status: appConfig.value.baseUrlSet,
errorText: i18n.t("settings.server-side-base-url-error-text"),
successText: i18n.t("settings.server-side-base-url-success-text"),
color: appConfig.value.baseUrlSet ? goodColor : badColor,
icon: appConfig.value.baseUrlSet ? goodIcon : badIcon,
},
{
id: "ldap-ready",
text: appConfig.value.ldapReady ? i18n.t("settings.ldap-ready") : i18n.t("settings.ldap-not-ready"),
status: appConfig.value.ldapReady,
errorText: i18n.t("settings.ldap-ready-error-text"),
successText: i18n.t("settings.ldap-ready-success-text"),
color: appConfig.value.ldapReady ? goodColor : warningColor,
icon: appConfig.value.ldapReady ? goodIcon : warningIcon,
},
{
id: "oidc-ready",
text: appConfig.value.oidcReady ? i18n.t("settings.oidc-ready") : i18n.t("settings.oidc-not-ready"),
status: appConfig.value.oidcReady,
errorText: i18n.t("settings.oidc-ready-error-text"),
successText: i18n.t("settings.oidc-ready-success-text"),
color: appConfig.value.oidcReady ? goodColor : warningColor,
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
},
{
id: "openai-ready",
text: appConfig.value.enableOpenai ? i18n.t("settings.openai-ready") : i18n.t("settings.openai-not-ready"),
status: appConfig.value.enableOpenai,
errorText: i18n.t("settings.openai-ready-error-text"),
successText: i18n.t("settings.openai-ready-success-text"),
color: appConfig.value.enableOpenai ? goodColor : warningColor,
icon: appConfig.value.enableOpenai ? goodIcon : warningIcon,
},
];
return data;
});
async function testEmail() {
state.loading = true;
state.tested = false;
const { data } = await api.email.test({ email: state.address });
if (data) {
if (data.success) {
state.success = true;
}
else {
state.error = data.error ?? "";
state.success = false;
}
}
state.loading = false;
state.tested = true;
}
const validEmail = computed(() => {
if (state.address === "") {
return false;
}
const valid = validators.email(state.address);
// Explicit bool check because validators.email sometimes returns a string
if (valid === true) {
return true;
}
return false;
});
// ============================================================
// General About Info
const rawAppInfo = ref({
version: "null",
versionLatest: "null",
});
function getAppInfo() {
const { data: statistics } = useAsyncData(useAsyncKey(), async () => {
const { data } = await adminApi.about.about();
if (data) {
rawAppInfo.value.version = data.version;
rawAppInfo.value.versionLatest = data.versionLatest;
const prettyInfo = [
{
slot: "version",
name: i18n.t("about.version"),
icon: $globals.icons.information,
value: data.version,
},
{
slot: "build",
name: i18n.t("settings.build"),
icon: $globals.icons.information,
value: data.buildId,
},
{
name: i18n.t("about.application-mode"),
icon: $globals.icons.devTo,
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
},
{
name: i18n.t("about.demo-status"),
icon: $globals.icons.testTube,
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
},
{
name: i18n.t("about.api-port"),
icon: $globals.icons.api,
value: data.apiPort,
},
{
name: i18n.t("about.api-docs"),
icon: $globals.icons.file,
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
},
{
name: i18n.t("about.database-type"),
icon: $globals.icons.database,
value: data.dbType,
},
{
name: i18n.t("about.database-url"),
icon: $globals.icons.database,
value: data.dbUrl,
},
{
name: i18n.t("about.default-group"),
icon: $globals.icons.group,
value: data.defaultGroup,
},
{
name: i18n.t("about.default-household"),
icon: $globals.icons.household,
value: data.defaultHousehold,
},
{
slot: "recipe-scraper",
name: i18n.t("settings.recipe-scraper-version"),
icon: $globals.icons.primary,
value: data.recipeScraperVersion,
},
];
return prettyInfo;
}
return data;
});
return statistics;
}
const appInfo = getAppInfo();
const bugReportDialog = ref(false);
const bugReportText = computed(() => {
const ignore = {
[i18n.t("about.database-url")]: true,
[i18n.t("about.default-group")]: true,
};
let text = "**Details**\n";
appInfo.value?.forEach((item) => {
if (ignore[item.name as string]) {
return;
}
text += `${item.name as string}: ${item.value as string}\n`;
});
const ignoreChecks: {
[key: string]: boolean;
} = {
"application-version": true,
};
text += "\n**Checks**\n";
simpleChecks.value.forEach((item) => {
if (ignoreChecks[item.id]) {
return;
}
const status = item.status ? i18n.t("general.yes") : i18n.t("general.no");
text += `${item.text.toString()}: ${status}\n`;
});
text += `${i18n.t("settings.email-configured")}: ${appConfig.value.emailReady ? i18n.t("general.yes") : i18n.t("general.no")}\n`;
return text;
});
</script>
<style scoped>
.wrap-word {
white-space: normal;
word-wrap: break-word;
}
</style>