1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-05 21:45:25 +02:00

feat: Migrate to Nuxt 3 framework (#5184)

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Hoa (Kyle) Trinh 2025-06-20 00:09:12 +07:00 committed by GitHub
parent 89ab7fac25
commit c24d532608
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
403 changed files with 23959 additions and 19557 deletions

View file

@ -4,9 +4,10 @@
<!-- Delete Dialog -->
<BaseDialog
v-model="deleteDialog"
:title="$tc('settings.backup.delete-backup')"
:title="$t('settings.backup.delete-backup')"
color="error"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteBackup()"
>
<v-card-text>
@ -15,60 +16,83 @@
</BaseDialog>
<!-- Import Dialog -->
<BaseDialog v-model="importDialog" color="error" :title="$t('settings.backup.backup-restore')" :icon="$globals.icons.database">
<v-divider></v-divider>
<BaseDialog
v-model="importDialog"
color="error"
:title="$t('settings.backup.backup-restore')"
:icon="$globals.icons.database"
>
<v-divider />
<v-card-text>
<i18n path="settings.backup.back-restore-description">
<i18n-t keypath="settings.backup.back-restore-description">
<template #cannot-be-undone>
<b> {{ $t('settings.backup.cannot-be-undone') }} </b>
</template>
</i18n>
</i18n-t>
<p class="mt-3">
<i18n path="settings.backup.postgresql-note">
<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 >
<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>
</i18n-t>
{{ $t('') }}
</p>
<v-checkbox
v-model="confirmImport"
class="checkbox-top"
color="error"
hide-details
:label="$t('settings.backup.irreversible-acknowledgment')"
></v-checkbox>
/>
</v-card-text>
<v-card-actions class="justify-center pt-0">
<BaseButton delete :disabled="!confirmImport || runningRestore" @click="restoreBackup(selected)">
<template #icon> {{ $globals.icons.database }} </template>
<BaseButton
delete
:disabled="!confirmImport || 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="runningRestore" indeterminate></v-progress-linear>
<v-progress-linear
v-if="runningRestore"
indeterminate
/>
</BaseDialog>
<section>
<BaseCardSectionTitle :title="$tc('settings.backup-and-exports')">
<BaseCardSectionTitle :title="$t('settings.backup-and-exports')">
<v-card-text class="py-0 px-1">
<i18n path="settings.backup.experimental-description" />
<i18n-t keypath="settings.backup.experimental-description" />
</v-card-text>
</BaseCardSectionTitle>
<v-toolbar color="transparent" flat class="justify-between">
<BaseButton class="mr-2" @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
color="transparent"
flat
class="justify-between"
>
<BaseButton
class="mr-2"
@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
@ -80,14 +104,15 @@
:search="search"
@click:row="setSelected"
>
<template #item.date="{ item }">
<template #[`item.date`]="{ item }">
{{ $d(Date.parse(item.date), "medium") }}
</template>
<template #item.actions="{ item }">
<template #[`item.actions`]="{ item }">
<v-btn
icon
class="mx-1"
color="error"
variant="text"
@click.stop="
deleteDialog = true;
deleteTarget = item.name;
@ -95,18 +120,27 @@
>
<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); importDialog = true">
<template #icon> {{ $globals.icons.backupRestore }}</template>
<BaseButton
small
download
:download-url="backupsFileNameDownload(item.name)"
class="mx-1"
@click.stop="() => { }"
/>
<BaseButton
small
@click.stop="setSelected(item); importDialog = true"
>
<template #icon>
{{ $globals.icons.backupRestore }}
</template>
{{ $t("settings.backup.backup-restore") }}
</BaseButton>
</BaseButton>
</template>
</v-data-table>
<v-divider></v-divider>
<v-divider />
<div class="d-flex justify-end mt-6">
<div>
</div>
<div />
</div>
</section>
</section>
@ -117,17 +151,20 @@
</template>
<script lang="ts">
import { computed, defineComponent, reactive, ref, toRefs, useContext, onMounted, useRoute } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { AllBackups } from "~/lib/api/types/admin";
import type { AllBackups } from "~/lib/api/types/admin";
import { alert } from "~/composables/use-toast";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
const { i18n, $auth } = useContext();
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const adminApi = useAdminApi();
const selected = ref("");
@ -149,9 +186,10 @@ export default defineComponent({
if (data?.error === false) {
refreshBackups();
alert.success(i18n.tc("settings.backup.backup-created"));
} else {
alert.error(i18n.tc("settings.backup.error-creating-backup-see-log-file"));
alert.success(i18n.t("settings.backup.backup-created"));
}
else {
alert.error(i18n.t("settings.backup.error-creating-backup-see-log-file"));
}
}
@ -163,10 +201,13 @@ export default defineComponent({
console.log(error);
state.importDialog = false;
state.runningRestore = false;
alert.error(i18n.tc("settings.backup.restore-fail"));
} else {
alert.success(i18n.tc("settings.backup.restore-success"));
$auth.logout();
alert.error(i18n.t("settings.backup.restore-fail"));
}
else {
alert.success(i18n.t("settings.backup.restore-success"));
setTimeout(() => {
window.location.reload();
}, 500);
}
}
@ -176,7 +217,7 @@ export default defineComponent({
const { data } = await adminApi.backups.delete(deleteTarget.value);
if (!data?.error) {
alert.success(i18n.tc("settings.backup.backup-deleted"));
alert.success(i18n.t("settings.backup.backup-deleted"));
refreshBackups();
}
}
@ -189,10 +230,10 @@ export default defineComponent({
runningRestore: false,
search: "",
headers: [
{ text: i18n.t("general.name"), value: "name" },
{ text: i18n.t("general.created"), value: "date" },
{ text: i18n.t("export.size"), value: "size" },
{ text: "", value: "actions", align: "right" },
{ 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" },
],
});
@ -205,6 +246,10 @@ export default defineComponent({
const backupsFileNameDownload = (fileName: string) => `api/admin/backups/${fileName}`;
useSeoMeta({
title: i18n.t("sidebar.backups"),
});
onMounted(refreshBackups);
return {
@ -223,7 +268,7 @@ export default defineComponent({
},
head() {
return {
title: this.$t("sidebar.backups") as string,
title: useI18n().t("sidebar.backups"),
};
},
});

View file

@ -1,25 +1,34 @@
<template>
<v-container class="pa-0">
<v-container>
<BaseCardSectionTitle :title="$tc('admin.debug-openai-services')">
<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" />
<br>
<DocLink
class="mt-2"
link="/documentation/getting-started/installation/open-ai"
/>
</BaseCardSectionTitle>
</v-container>
<v-form ref="uploadForm" @submit.prevent="testOpenAI">
<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">
<v-col
cols="auto"
align-self="center"
>
<AppButtonUpload
v-if="!uploadedImage"
class="ml-auto"
url="none"
file-name="image"
accept="image/*"
:text="$i18n.tc('recipe.upload-image')"
:text="$t('recipe.upload-image')"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
@ -29,13 +38,18 @@
color="error"
@click="clearImage"
>
<v-icon left>{{ $globals.icons.close }}</v-icon>
{{ $i18n.tc("recipe.remove-image") }}
<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-row
v-if="uploadedImage && uploadedImagePreviewUrl"
style="max-width: 25%;"
>
<v-spacer />
<v-col cols="12">
<v-img :src="uploadedImagePreviewUrl" />
@ -47,7 +61,7 @@
<v-card-actions>
<BaseButton
type="submit"
:text="$i18n.tc('admin.run-test')"
:text="$t('admin.run-test')"
:icon="$globals.icons.check"
:loading="loading"
class="ml-auto"
@ -55,8 +69,14 @@
</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-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>
@ -64,15 +84,23 @@
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { VForm } from "~/types/vuetify";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
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("");
@ -102,7 +130,8 @@ export default defineComponent({
if (!data) {
alert.error("Unable to test OpenAI services");
} else {
}
else {
response.value = data.response || (data.success ? "Test Successful" : "Test Failed");
}
}
@ -118,10 +147,5 @@ export default defineComponent({
testOpenAI,
};
},
head() {
return {
title: this.$t("admin.debug-openai-services"),
};
},
});
</script>

View file

@ -10,43 +10,86 @@
</BaseCardSectionTitle>
<div class="d-flex align-center justify-center justify-md-start flex-wrap">
<v-btn-toggle v-model="parser" dense mandatory @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-model="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-checkbox v-model="showConfidence" class="ml-5" :label="$t('admin.show-individual-confidence')"></v-checkbox>
<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="ingredient" :label="$t('admin.ingredient-text')"> </v-text-field>
<v-text-field
v-model="ingredient"
:label="$t('admin.ingredient-text')"
/>
</v-card-text>
<v-card-actions>
<BaseButton class="ml-auto" @click="processIngredient">
<template #icon> {{ $globals.icons.check }}</template>
<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="results">
<div v-if="parser !== 'brute' && getConfidence('average')" class="d-flex">
<v-chip dark :color="getColor('average')" class="mx-auto mb-2">
<div
v-if="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">
<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">
<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">
<v-chip
v-if="prop.confidence && showConfidence"
dark
:color="prop.color!"
class="mt-2"
>
{{ $t('admin.average-confident', [prop.confidence]) }}
</v-chip>
</div>
@ -55,7 +98,13 @@
</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
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>
@ -63,17 +112,19 @@
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import { IngredientConfidence } from "~/lib/api/types/recipe";
import { Parser } from "~/lib/api/user/recipes/recipe";
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";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
const api = useUserApi();
const state = reactive({
@ -83,7 +134,12 @@ export default defineComponent({
parser: "nlp" as Parser,
});
const { i18n } = useContext();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.parser"),
});
const confidence = ref<IngredientConfidence>({});
@ -96,9 +152,11 @@ export default defineComponent({
// Set color based off range
if (p_as_num > 75) {
return "success";
} else if (p_as_num > 60) {
}
else if (p_as_num > 60) {
return "warning";
} else {
}
else {
return "error";
}
}
@ -109,8 +167,8 @@ export default defineComponent({
}
const property = confidence.value[attribute];
if (property !== undefined) {
return `${(property * 100).toFixed(0)}%`;
if (property !== undefined && property !== null) {
return `${(+property * 100).toFixed(0)}%`;
}
return undefined;
}
@ -154,15 +212,14 @@ export default defineComponent({
const color = getColor(property);
const confidence = getConfidence(property);
if (color) {
// @ts-ignore See above
properties[property].color = color;
}
if (confidence) {
// @ts-ignore See above
properties[property].confidence = confidence;
}
});
} else {
}
else {
alert.error(i18n.t("events.something-went-wrong") as string);
state.results = false;
}
@ -210,11 +267,6 @@ export default defineComponent({
processIngredient,
};
},
head() {
return {
title: this.$t("admin.parser"),
};
},
});
</script>

View file

@ -1,70 +1,75 @@
<template>
<v-container fluid class="narrow-container">
<BaseDialog
v-model="state.storageDetails"
:title="$t('admin.maintenance.storage-details')"
<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">
<v-list-item :key="`item-${key}`">
<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-subtitle class="text-end">
{{ value }}
</v-list-item-subtitle>
</v-list-item>
<v-divider v-if="idx != 4" :key="`divider-${key}`" class="mx-2"></v-divider>
<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>
<template #title>
{{ $t("admin.maintenance.page-title") }}
</template>
</BasePageTitle>
<section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.wrench" :title="$tc('admin.maintenance.summary-title')">
</BaseCardSectionTitle>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.wrench" :title="$t('admin.maintenance.summary-title')" />
<div class="mb-6 ml-2 d-flex" style="gap: 0.3rem">
<BaseButton color="info" @click="getSummary">
<template #icon> {{ $globals.icons.tools }} </template>
<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>
<template #icon>
{{ $globals.icons.folderOutline }}
</template>
{{ $t("admin.maintenance.button-label-open-details") }}
</BaseButton>
</div>
<v-card class="ma-2" :loading="state.fetchingInfo">
<template v-for="(value, idx) in info">
<v-list-item :key="`item-${idx}`">
<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-subtitle class="text-end">
{{ value.value }}
</v-list-item-subtitle>
</v-list-item-title>
</v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
<v-divider class="mx-2" />
</template>
</v-card>
</section>
<section>
<BaseCardSectionTitle
class="pb-0 mt-8"
:icon="$globals.icons.wrench"
:title="$tc('admin.mainentance.actions-title')"
>
<i18n path="admin.maintenance.actions-description">
<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>
</i18n-t>
</BaseCardSectionTitle>
<v-card class="ma-2" :loading="state.actionLoading">
<template v-for="(action, idx) in actions">
<v-list-item :key="`item-${idx}`" class="py-1">
<template v-for="(action, idx) in actions" :key="`item-${idx}`">
<v-list-item class="py-1">
<v-list-item-title>
<div>{{ action.name }}</div>
<v-list-item-subtitle class="wrap-word">
@ -72,11 +77,13 @@
</v-list-item-subtitle>
</v-list-item-title>
<BaseButton color="info" @click="action.handler">
<template #icon> {{ $globals.icons.robot }}</template>
<template #icon>
{{ $globals.icons.robot }}
</template>
{{ $t("general.run") }}
</BaseButton>
</v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
<v-divider class="mx-2" />
</template>
</v-card>
</section>
@ -84,13 +91,15 @@
</template>
<script lang="ts">
import { computed, ref, defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { MaintenanceStorageDetails, MaintenanceSummary } from "~/lib/api/types/admin";
import type { MaintenanceStorageDetails, MaintenanceSummary } from "~/lib/api/types/admin";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
const state = reactive({
storageDetails: false,
storageDetailsLoading: false,
@ -99,13 +108,18 @@ export default defineComponent({
});
const adminApi = useAdminApi();
const { i18n } = useContext();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.maintenance.page-title"),
});
// ==========================================================================
// General Info
const infoResults = ref<MaintenanceSummary>({
dataDirSize: i18n.tc("about.unknown-version"),
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
});
@ -115,7 +129,7 @@ export default defineComponent({
const { data } = await adminApi.maintenance.getInfo();
infoResults.value = data ?? {
dataDirSize: i18n.tc("about.unknown-version"),
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
};
@ -152,7 +166,7 @@ export default defineComponent({
};
function storageDetailsText(key: string) {
return storageTitles[key] ?? i18n.tc("about.unknown-version");
return storageTitles[key] ?? i18n.t("about.unknown-version");
}
const storageDetails = ref<MaintenanceStorageDetails | null>(null);
@ -219,11 +233,6 @@ export default defineComponent({
actions,
};
},
head() {
return {
title: this.$t("admin.maintenance.page-title") as string,
};
},
});
</script>

View file

@ -1,47 +1,73 @@
<template>
<v-container v-if="group" class="narrow-container">
<v-container
v-if="group"
class="narrow-container"
>
<BasePageTitle>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-group-settings.svg')"></v-img>
<v-img
width="100%"
max-height="125"
max-width="125"
:src="require('~/static/svgs/manage-group-settings.svg')"
/>
</template>
<template #title>
{{ $t('group.admin-group-management') }}
</template>
<template #title> {{ $t('group.admin-group-management') }} </template>
{{ $t('group.admin-group-management-text') }}
</BasePageTitle>
<AppToolbar back> </AppToolbar>
<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 outlined>
<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')"> </v-text-field>
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
<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>
<BaseButton
type="submit"
edit
class="ml-auto"
>
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { GroupInDB } from "~/lib/api/types/user";
import { VForm } from "~/types/vuetify";
export default defineComponent({
export default defineNuxtComponent({
components: {
GroupPreferencesEditor,
},
layout: "admin",
setup() {
definePageMeta({
layout: "admin",
});
const route = useRoute();
const { i18n } = useContext();
const i18n = useI18n();
const groupId = route.value.params.id;
const groupId = computed(() => route.params.id as string);
// ==============================================
// New User Form
@ -50,22 +76,20 @@ export default defineComponent({
const adminApi = useAdminApi();
const group = ref<GroupInDB | null>(null);
const userError = ref(false);
onMounted(async () => {
const { data, error } = await adminApi.groups.getOne(groupId);
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.tc("user.user-not-found"));
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
if (data) {
group.value = data;
}
});
return data;
}, { watch: [groupId] });
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || group.value === null) {
@ -79,8 +103,9 @@ export default defineComponent({
window.location.reload();
}
group.value = data;
} else {
alert.error(i18n.tc("settings.settings-update-failed"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}

View file

@ -4,11 +4,16 @@
v-model="createDialog"
:title="$t('group.create-group')"
:icon="$globals.icons.group"
can-submit
@submit="createGroup(createGroupForm.data)"
>
<template #activator> </template>
<template #activator />
<v-card-text>
<AutoForm v-model="createGroupForm.data" :update-mode="updateMode" :items="createGroupForm.items" />
<AutoForm
v-model="createGroupForm.data"
:update-mode="updateMode"
:items="createGroupForm.items"
/>
</v-card-text>
</BaseDialog>
@ -16,18 +21,25 @@
v-model="confirmDialog"
:title="$t('general.confirm')"
color="error"
can-confirm
@confirm="deleteGroup(deleteTarget)"
>
<template #activator> </template>
<template #activator />
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle :title="$tc('group.group-management')"> </BaseCardSectionTitle>
<BaseCardSectionTitle :title="$t('group.group-management')" />
<section>
<v-toolbar flat color="transparent" class="justify-between">
<BaseButton @click="openDialog"> {{ $t("general.create") }} </BaseButton>
<v-toolbar
flat
color="transparent"
class="justify-between"
>
<BaseButton @click="openDialog">
{{ $t("general.create") }}
</BaseButton>
</v-toolbar>
<v-data-table
@ -38,26 +50,30 @@
hide-default-footer
disable-pagination
:search="search"
@click:row="handleRowClick"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #item.households="{ item }">
{{ item.households.length }}
<template #[`item.households`]="{ item }">
{{ item.households!.length }}
</template>
<template #item.users="{ item }">
{{ item.users.length }}
<template #[`item.users`]="{ item }">
{{ item.users!.length }}
</template>
<template #item.actions="{ item }">
<v-tooltip bottom :disabled="!(item && (item.households.length > 0 || item.users.length > 0))">
<template #activator="{ on, attrs }">
<div v-bind="attrs" v-on="on" >
<template #[`item.actions`]="{ item }">
<v-tooltip
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)"
:disabled="item && (item.households!.length > 0 || item.users!.length > 0)"
class="mr-1"
icon
color="error"
variant="text"
@click.stop="
confirmDialog = true;
deleteTarget = item.id;
deleteTarget = +item.id;
"
>
<v-icon>
@ -66,25 +82,33 @@
</v-btn>
</div>
</template>
<span>{{ $tc("admin.group-delete-note") }}</span>
<span>{{ $t("admin.group-delete-note") }}</span>
</v-tooltip>
</template>
</v-data-table>
<v-divider></v-divider>
<v-divider />
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
import { GroupInDB } from "~/lib/api/types/user";
import type { GroupInDB } from "~/lib/api/types/user";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
const { i18n } = useContext();
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("group.manage-groups"),
});
const { groups, refreshAllGroups, deleteGroup, createGroup } = useGroups();
const state = reactive({
@ -94,15 +118,15 @@ export default defineComponent({
search: "",
headers: [
{
text: i18n.t("group.group"),
title: i18n.t("group.group"),
align: "start",
sortable: false,
value: "id",
},
{ text: i18n.t("general.name"), value: "name" },
{ text: i18n.t("group.total-households"), value: "households" },
{ text: i18n.t("user.total-users"), value: "users" },
{ text: i18n.t("general.delete"), value: "actions" },
{ 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: {
@ -125,17 +149,15 @@ export default defineComponent({
state.createGroupForm.data.name = "";
}
const router = useRouter();
function handleRowClick(item: GroupInDB) {
router.push(`/admin/manage/groups/${item.id}`);
navigateTo(`/admin/manage/groups/${item.id}`);
}
return { ...toRefs(state), groups, refreshAllGroups, deleteGroup, createGroup, openDialog, handleRowClick };
},
head() {
return {
title: this.$t("group.manage-groups") as string,
title: useI18n().t("group.manage-groups"),
};
},
});

View file

@ -1,67 +1,94 @@
<template>
<v-container v-if="household" class="narrow-container">
<v-container
v-if="household"
class="narrow-container"
>
<BasePageTitle>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-group-settings.svg')"></v-img>
<v-img
width="100%"
max-height="125"
max-width="125"
:src="require('~/static/svgs/manage-group-settings.svg')"
/>
</template>
<template #title>
{{ $t('household.admin-household-management') }}
</template>
<template #title> {{ $t('household.admin-household-management') }} </template>
{{ $t('household.admin-household-management-text') }}
</BasePageTitle>
<AppToolbar back> </AppToolbar>
<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 outlined>
<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"
rounded
variant="solo-filled"
flat
class="rounded-lg"
item-text="name"
item-title="name"
item-value="id"
:return-object="false"
filled
:label="$tc('group.user-group')"
: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" />
<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>
<BaseButton
type="submit"
edit
class="ml-auto"
>
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api";
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 { HouseholdInDB } from "~/lib/api/types/household";
import { VForm } from "~/types/vuetify";
export default defineComponent({
export default defineNuxtComponent({
components: {
HouseholdPreferencesEditor,
},
layout: "admin",
setup() {
definePageMeta({
layout: "admin",
});
const route = useRoute();
const { i18n } = useContext();
const i18n = useI18n();
const { groups } = useGroups();
const householdId = route.value.params.id;
const householdId = computed(() => route.params.id as string);
// ==============================================
// New User Form
@ -70,22 +97,20 @@ export default defineComponent({
const adminApi = useAdminApi();
const household = ref<HouseholdInDB | null>(null);
const userError = ref(false);
onMounted(async () => {
const { data, error } = await adminApi.households.getOne(householdId);
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.tc("user.user-not-found"));
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
if (data) {
household.value = data;
}
});
return data;
}, { watch: [householdId] });
async function handleSubmit() {
if (!refHouseholdEditForm.value?.validate() || household.value === null) {
@ -95,9 +120,10 @@ export default defineComponent({
const { response, data } = await adminApi.households.updateOne(household.value.id, household.value);
if (response?.status === 200 && data) {
household.value = data;
alert.success(i18n.tc("settings.settings-updated"));
} else {
alert.error(i18n.tc("settings.settings-update-failed"));
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}

View file

@ -5,7 +5,7 @@
:title="$t('household.create-household')"
:icon="$globals.icons.household"
>
<template #activator> </template>
<template #activator />
<v-card-text>
<v-form ref="refNewHouseholdForm">
<v-select
@ -14,18 +14,27 @@
:items="groups"
rounded
class="rounded-lg"
item-text="name"
item-title="name"
item-value="id"
:return-object="false"
filled
:label="$tc('household.household-group')"
variant="filled"
:label="$t('household.household-group')"
:rules="[validators.required]"
/>
<AutoForm v-model="createHouseholdForm.data" :update-mode="updateMode" :items="createHouseholdForm.items" />
<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>
<BaseButton
type="submit"
@click="handleCreateSubmit"
>
{{ $t("general.create") }}
</BaseButton>
</template>
</BaseDialog>
@ -33,51 +42,63 @@
v-model="confirmDialog"
:title="$t('general.confirm')"
color="error"
can-confirm
@confirm="deleteHousehold(deleteTarget)"
>
<template #activator> </template>
<template #activator />
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle :title="$tc('household.household-management')"> </BaseCardSectionTitle>
<BaseCardSectionTitle :title="$t('household.household-management')" />
<section>
<v-toolbar flat color="transparent" class="justify-between">
<BaseButton @click="openDialog"> {{ $t("general.create") }} </BaseButton>
<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 || []"
:items="households"
item-key="id"
class="elevation-0"
hide-default-footer
disable-pagination
:search="search"
@click:row="handleRowClick"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #item.users="{ item }">
{{ item.users.length }}
<template #[`item.users`]="{ item }">
{{ item.users?.length }}
</template>
<template #item.group="{ item }">
<template #[`item.group`]="{ item }">
{{ item.group }}
</template>
<template #item.webhookEnable="{ item }">
{{ item.webhooks.length > 0 ? $t("general.yes") : $t("general.no") }}
<template #[`item.webhookEnable`]="{ item }">
{{ item.webhooks!.length > 0 ? $t("general.yes") : $t("general.no") }}
</template>
<template #item.actions="{ item }">
<v-tooltip bottom :disabled="!(item && item.users.length > 0)">
<template #activator="{ on, attrs }">
<div v-bind="attrs" v-on="on" >
<template #[`item.actions`]="{ item }">
<v-tooltip
bottom
:disabled="!(item && item.users!.length > 0)"
>
<template #activator="{ props }">
<div v-bind="props">
<v-btn
:disabled="item && item.users.length > 0"
:disabled="item && item.users!.length > 0"
class="mr-1"
icon
color="error"
variant="text"
@click.stop="
confirmDialog = true;
deleteTarget = item.id;
deleteTarget = +item.id;
"
>
<v-icon>
@ -86,28 +107,36 @@
</v-btn>
</div>
</template>
<span>{{ $tc("admin.household-delete-note") }}</span>
<span>{{ $t("admin.household-delete-note") }}</span>
</v-tooltip>
</template>
</v-data-table>
<v-divider></v-divider>
<v-divider />
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
import { useAdminHouseholds } from "~/composables/use-households";
import { validators } from "~/composables/use-validators";
import { HouseholdInDB } from "~/lib/api/types/household";
import { VForm } from "~/types/vuetify";
import type { HouseholdInDB } from "~/lib/api/types/household";
import type { VForm } from "~/types/auto-forms";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
const { i18n } = useContext();
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("household.manage-households"),
});
const { groups } = useGroups();
const { households, refreshAllHouseholds, deleteHousehold, createHousehold } = useAdminHouseholds();
const refNewHouseholdForm = ref<VForm | null>(null);
@ -120,16 +149,16 @@ export default defineComponent({
search: "",
headers: [
{
text: i18n.t("household.household"),
title: i18n.t("household.household"),
align: "start",
sortable: false,
value: "id",
},
{ text: i18n.t("general.name"), value: "name" },
{ text: i18n.t("group.group"), value: "group" },
{ text: i18n.t("user.total-users"), value: "users" },
{ text: i18n.t("user.webhooks-enabled"), value: "webhookEnable" },
{ text: i18n.t("general.delete"), value: "actions" },
{ 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" },
],
updateMode: false,
createHouseholdForm: {
@ -170,21 +199,16 @@ export default defineComponent({
}
return {
...toRefs(state),
refNewHouseholdForm,
groups,
households,
validators,
refreshAllHouseholds,
deleteHousehold,
handleCreateSubmit,
openDialog,
handleRowClick,
};
},
head() {
return {
title: this.$t("household.manage-households") as string,
...toRefs(state),
refNewHouseholdForm,
groups,
households,
validators,
refreshAllHouseholds,
deleteHousehold,
handleCreateSubmit,
openDialog,
handleRowClick,
};
},
});

View file

@ -1,18 +1,35 @@
<template>
<v-container v-if="user" class="narrow-container">
<v-container
v-if="user"
class="narrow-container"
>
<BasePageTitle>
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-profile.svg')"></v-img>
<v-img
width="100%"
max-height="125"
max-width="125"
:src="require('~/static/svgs/manage-profile.svg')"
/>
</template>
<template #title>
{{ $t("user.admin-user-management") }}
</template>
<template #title> {{ $t("user.admin-user-management") }} </template>
{{ $t("user.changes-reflected-immediately") }}
</BasePageTitle>
<AppToolbar back> </AppToolbar>
<v-form v-if="!userError" ref="refNewUserForm" @submit.prevent="handleSubmit">
<v-card outlined>
<AppToolbar back />
<v-form
v-if="!userError"
ref="refNewUserForm"
@submit.prevent="handleSubmit"
>
<v-card
variant="outlined"
style="border-color: lightgrey;"
>
<v-card-text>
<div class="d-flex">
<p> {{ $t("user.user-id-with-value", {id: user.id} ) }}</p>
<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-select
@ -20,83 +37,118 @@
v-model="user.group"
disabled
:items="groups"
rounded
class="rounded-lg"
item-text="name"
variant="solo-filled"
flat
item-title="name"
item-value="name"
:return-object="false"
filled
:label="$tc('group.user-group')"
:label="$t('group.user-group')"
:rules="[validators.required]"
/>
<v-select
v-if="households"
v-model="user.household"
:items="households"
rounded
class="rounded-lg"
item-text="name"
variant="solo-filled"
flat
item-title="name"
item-value="name"
:return-object="false"
filled
:label="$tc('household.user-household')"
:label="$t('household.user-household')"
:rules="[validators.required]"
/>
<div class="d-flex py-2 pr-2">
<BaseButton type="button" :loading="generatingToken" create @click.prevent="handlePasswordReset">
<BaseButton
type="button"
:loading="generatingToken"
create
@click.prevent="handlePasswordReset"
>
{{ $t("user.generate-password-reset-link") }}
</BaseButton>
</div>
<div v-if="resetUrl" class="mb-2">
<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></v-spacer>
<BaseButton v-if="user.email" color="info" class="mr-1" @click="sendResetEmail">
<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" />
<AppButtonCopy
:icon="false"
color="info"
:copy-text="resetUrl"
/>
</v-card-actions>
</div>
<AutoForm v-model="user" :items="userForm" update-mode :disabled-fields="disabledFields" />
<AutoForm
v-model="user"
:items="userForm"
update-mode
:disabled-fields="disabledFields"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto"> {{ $t("general.update") }}</BaseButton>
<BaseButton
type="submit"
edit
class="ml-auto"
>
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, useRoute, onMounted, ref, useContext } from "@nuxtjs/composition-api";
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 { VForm } from "~/types/vuetify";
import { UserOut } from "~/lib/api/types/user";
import type { UserOut } from "~/lib/api/types/user";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
const { userForm } = useUserForm();
const { groups } = useGroups();
const { useHouseholdsInGroup } = useAdminHouseholds();
const { i18n } = useContext();
const i18n = useI18n();
const route = useRoute();
const userId = route.value.params.id;
const userId = route.params.id as string;
// ==============================================
// New User Form
@ -110,7 +162,7 @@ export default defineComponent({
const disabledFields = computed(() => {
return user.value?.authMethod !== "Mealie" ? ["admin"] : [];
})
});
const userError = ref(false);
@ -121,7 +173,7 @@ export default defineComponent({
const { data, error } = await adminApi.users.getOne(userId);
if (error?.response?.status === 404) {
alert.error(i18n.tc("user.user-not-found"));
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
@ -159,9 +211,10 @@ export default defineComponent({
if (!user.value?.email) return;
const { response } = await userApi.email.sendForgotPassword({ email: user.value.email });
if (response && response.status === 200) {
alert.success(i18n.tc("profile.email-sent"));
} else {
alert.error(i18n.tc("profile.error-sending-email"));
alert.success(i18n.t("profile.email-sent"));
}
else {
alert.error(i18n.t("profile.error-sending-email"));
}
}

View file

@ -2,23 +2,34 @@
<v-container class="narrow-container">
<BasePageTitle class="mb-2">
<template #header>
<v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-profile.svg')"></v-img>
<v-img
width="100%"
max-height="125"
max-width="125"
:src="require('~/static/svgs/manage-profile.svg')"
/>
</template>
<template #title>
{{ $t('user.admin-user-creation') }}
</template>
<template #title> {{ $t('user.admin-user-creation') }} </template>
</BasePageTitle>
<AppToolbar back> </AppToolbar>
<v-form ref="refNewUserForm" @submit.prevent="handleSubmit">
<v-card outlined>
<AppToolbar back />
<v-form
ref="refNewUserForm"
@submit.prevent="handleSubmit"
>
<v-card variant="outlined">
<v-card-text>
<v-select
v-if="groups"
v-model="selectedGroupId"
:items="groups"
rounded
class="rounded-lg"
item-text="name"
item-title="name"
item-value="id"
:return-object="false"
filled
variant="filled"
:label="$t('group.user-group')"
:rules="[validators.required]"
/>
@ -28,37 +39,46 @@
:items="households"
rounded
class="rounded-lg"
item-text="name"
item-title="name"
item-value="name"
:return-object="false"
filled
variant="filled"
:label="$t('household.user-household')"
:hint="selectedGroupId ? '' : $tc('group.you-must-select-a-group-before-selecting-a-household')"
:hint="selectedGroupId ? '' : $t('group.you-must-select-a-group-before-selecting-a-household')"
persistent-hint
:rules="[validators.required]"
/>
<AutoForm v-model="newUserData" :items="userForm" />
<AutoForm
v-model="newUserData"
:items="userForm"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" class="ml-auto"></BaseButton>
<BaseButton
type="submit"
class="ml-auto"
/>
</div>
</v-form>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, useRouter, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useAdminHouseholds } from "~/composables/use-households";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify";
import type { UserIn } from "~/lib/api/types/user";
import type { VForm } from "~/types/auto-forms";
export default defineComponent({
layout: "admin",
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
const { userForm } = useUserForm();
const { groups } = useGroups();
const { useHouseholdsInGroup } = useAdminHouseholds();
@ -75,7 +95,7 @@ export default defineComponent({
const households = useHouseholdsInGroup(selectedGroupId);
const selectedGroup = computed(() => {
return groups.value?.find((group) => group.id === selectedGroupId.value);
return groups.value?.find(group => group.id === selectedGroupId.value);
});
const state = reactive({
newUserData: {
@ -101,7 +121,7 @@ export default defineComponent({
async function handleSubmit() {
if (!refNewUserForm.value?.validate()) return;
const { response } = await adminApi.users.createOne(state.newUserData);
const { response } = await adminApi.users.createOne(state.newUserData as UserIn);
if (response?.status === 201) {
router.push("/admin/manage/users");

View file

@ -3,32 +3,52 @@
<UserInviteDialog v-model="inviteDialog" />
<BaseDialog
v-model="deleteDialog"
:title="$tc('general.confirm')"
:title="$t('general.confirm')"
color="error"
can-confirm
@confirm="deleteUser(deleteTargetId)"
>
<template #activator> </template>
<template #activator />
<v-card-text>
<v-alert v-if="isUserOwnAccount" type="warning" text outlined>
{{ $t("general.confirm-delete-own-admin-account") }}
</v-alert>
<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="$tc('user.user-management')"> </BaseCardSectionTitle>
<BaseCardSectionTitle :title="$t('user.user-management')" />
<section>
<v-toolbar color="transparent" flat class="justify-between">
<BaseButton to="/admin/manage/users/create" class="mr-2">
<v-toolbar
color="transparent"
flat
class="justify-between"
>
<BaseButton
to="/admin/manage/users/create"
class="mr-2"
>
{{ $t("general.create") }}
</BaseButton>
<BaseButton class="mr-2" color="info" :icon="$globals.icons.link" @click="inviteDialog = true">
<BaseButton
class="mr-2"
color="info"
:icon="$globals.icons.link"
@click="inviteDialog = true"
>
{{ $t("group.invite") }}
</BaseButton>
<BaseOverflowButton mode="event" :items="ACTIONS_OPTIONS" @unlock-all-users="unlockAllUsers">
</BaseOverflowButton>
<BaseOverflowButton
mode="event"
variant="elevated"
:items="ACTIONS_OPTIONS"
@unlock-all-users="unlockAllUsers"
/>
</v-toolbar>
<v-data-table
:headers="headers"
@ -39,18 +59,22 @@
hide-default-footer
disable-pagination
:search="search"
@click:row="handleRowClick"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #item.admin="{ item }">
<v-icon right :color="item.admin ? 'success' : null">
<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 }">
<template #[`item.actions`]="{ item }">
<v-btn
icon
:disabled="item.id == 1"
:disabled="+item.id == 1"
color="error"
variant="text"
@click.stop="
deleteDialog = true;
deleteTargetId = item.id;
@ -62,33 +86,36 @@
</v-btn>
</template>
</v-data-table>
<v-divider></v-divider>
<v-divider />
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, useRouter, computed } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useUser, useAllUsers } from "~/composables/use-user";
import { UserOut } from "~/lib/api/types/user";
import type { UserOut } from "~/lib/api/types/user";
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
export default defineComponent({
export default defineNuxtComponent({
components: {
UserInviteDialog,
},
layout: "admin",
setup() {
definePageMeta({
layout: "admin",
});
const api = useAdminApi();
const refUserDialog = ref();
const inviteDialog = ref();
const { $auth } = useContext();
const $auth = useMealieAuth();
const user = computed(() => $auth.user);
const user = computed(() => $auth.user.value);
const { $globals, i18n } = useContext();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const router = useRouter();
@ -120,7 +147,7 @@ export default defineComponent({
deleteUserMixin(id);
if (isUserOwnAccount.value) {
$auth.logout();
$auth.refresh();
}
}
@ -133,18 +160,18 @@ export default defineComponent({
const headers = [
{
text: i18n.t("user.user-id"),
title: i18n.t("user.user-id"),
align: "start",
value: "id",
},
{ text: i18n.t("user.username"), value: "username" },
{ text: i18n.t("user.full-name"), value: "fullName" },
{ text: i18n.t("user.email"), value: "email" },
{ text: i18n.t("group.group"), value: "group" },
{ text: i18n.t("household.household"), value: "household" },
{ text: i18n.t("user.auth-method"), value: "authMethod" },
{ text: i18n.t("user.admin"), value: "admin" },
{ text: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" },
{ 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> {
@ -158,6 +185,10 @@ export default defineComponent({
}
}
useSeoMeta({
title: i18n.t("sidebar.manage-users"),
});
return {
isUserOwnAccount,
unlockAllUsers,
@ -175,7 +206,7 @@ export default defineComponent({
},
head() {
return {
title: this.$t("sidebar.manage-users") as string,
title: useI18n().t("sidebar.manage-users"),
};
},
});

View file

@ -1,41 +1,39 @@
<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,
}"
<v-container fill-height
fluid
class="d-flex justify-center align-center"
width="1200px"
min-height="700px"
:class="{
'bg-off-white': !$vuetify.theme.current.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"
<BaseWizard v-model="currentPage"
:max-page-number="totalPages"
:title="$t('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-container v-if="currentPage === Pages.LANDING"
class="mb-12"
>
<v-card-title class="text-h4 justify-center text-center">
{{ $t('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;"
<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;"
>
{{ $i18n.tc('admin.setup.already-set-up-bring-to-homepage') }}
{{ $t('admin.setup.already-set-up-bring-to-homepage') }}
</v-btn>
</v-container>
<v-container v-if="currentPage === Pages.USER_INFO">
@ -43,9 +41,11 @@
</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') }}
{{ $t('admin.setup.common-settings-for-new-sites') }}
</v-card-title>
<AutoForm v-model="commonSettings" :items="commonSettingsForm" />
<AutoForm v-model="commonSettings"
:items="commonSettingsForm"
/>
</v-container>
<v-container v-if="currentPage === Pages.CONFIRM">
<v-card-title class="headline justify-center">
@ -53,33 +53,37 @@
</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-if="item.display"
:key="idx"
>
<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}`" />
<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') }}
{{ $t('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') }}
{{ $t('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-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"
<v-btn :to="link.to"
color="info"
>
{{ link.text }}
</v-btn>
@ -93,7 +97,6 @@
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext, useRouter } from "@nuxtjs/composition-api";
import { useAdminApi, useUserApi } from "~/composables/api";
import { useLocales } from "~/composables/use-locales";
import { alert } from "~/composables/use-toast";
@ -101,358 +104,363 @@ import { useUserRegistrationForm } from "~/composables/use-users/user-registrati
import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form";
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
export default defineComponent({
export default defineNuxtComponent({
components: { UserRegistrationForm },
layout: "blank",
setup() {
definePageMeta({
layout: "blank",
});
// ================================================================
// Setup
const { $auth, $globals, i18n } = useContext();
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const userApi = useUserApi();
const adminApi = useAdminApi();
const groupSlug = computed(() => $auth.user?.groupSlug);
const groupSlug = computed(() => $auth.user.value?.groupSlug);
const { locale } = useLocales();
const router = useRouter();
const isSubmitting = ref(false);
useSeoMeta({
title: i18n.t("admin.setup.first-time-setup"),
});
if (!$auth.loggedIn) {
if (!$auth.loggedIn.value) {
router.push("/login");
} else if (!$auth.user?.admin) {
}
else if (!$auth.user.value?.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;
}
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,
}
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,
})
// ================================================================
// 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 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"),
},
];
});
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"),
},
]);
const setupCompleteLinks = ref([
{
section: i18n.t("profile.data-migrations"),
to: "/admin/backups",
text: i18n.t("settings.backup.backup-restore"),
description: i18n.t("admin.setup.restore-from-v1-backup"),
},
{
to: "/group/migrations",
text: i18n.t("migration.recipe-migration"),
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
},
{
section: i18n.t("recipe.create-recipes"),
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
text: i18n.t("recipe.create-recipe"),
description: i18n.t("recipe.create-recipe-description"),
},
{
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
text: i18n.t("recipe.import-with-url"),
description: i18n.t("recipe.scrape-recipe-description"),
},
{
section: i18n.t("user.manage-users"),
to: "/admin/manage/users",
text: i18n.t("user.manage-users"),
description: i18n.t("user.manage-users-description"),
},
{
to: "/user/profile",
text: i18n.t("profile.manage-user-profile"),
description: i18n.t("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,
}
// ================================================================
// 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;
}
switch (currentPage.value) {
case Pages.LANDING:
config.showPrevButton = false;
config.nextButtonText = i18n.t("general.start");
config.nextButtonIcon = $globals.icons.forward;
break;
case Pages.USER_INFO:
config.showPrevButton = false;
config.nextButtonText = i18n.t("general.next");
config.nextButtonIcon = $globals.icons.forward;
config.isSubmit = true;
break;
case Pages.CONFIRM:
config.isSubmit = true;
break;
case Pages.END:
config.nextButtonText = i18n.t("general.home");
config.nextButtonIcon = $globals.icons.home;
config.nextButtonColor = "primary";
config.showPrevButton = false;
config.isSubmit = true;
break;
}
return config;
})
return config;
});
// ================================================================
// Page Submission
// ================================================================
// Page Submission
async function updateUser() {
// @ts-ignore-next-line user will never be null here
const { response } = await userApi.users.updateOne($auth.user?.id, {
...$auth.user,
email: accountDetails.email.value,
username: accountDetails.username.value,
fullName: accountDetails.fullName.value,
advancedOptions: accountDetails.advancedOptions.value,
})
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,
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,
})
}
}
if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong"));
}
else {
$auth.refresh();
/* $auth.setUser({
...$auth.user.value,
email: accountDetails.email.value,
username: accountDetails.username.value,
fullName: accountDetails.fullName.value,
}) */
}
}
async function updatePassword() {
const { response } = await userApi.users.changePassword({
currentPassword: "MyPassword",
newPassword: credentials.password1.value,
});
async function updatePassword() {
const { response } = await userApi.users.changePassword({
currentPassword: "MyPassword",
newPassword: credentials.password1.value,
});
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
if (!response || response.status !== 200) {
alert.error(i18n.t("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 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 userApi.groups.getOne($auth.user?.groupId);
if (!data || !data.preferences) {
alert.error(i18n.tc("events.something-went-wrong"));
return;
}
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 preferences = {
...data.preferences,
privateGroup: !commonSettings.value.makeGroupRecipesPublic,
};
const payload = {
...data,
preferences,
}
const payload = {
...data,
preferences,
};
// @ts-ignore-next-line user will never be null here
const { response } = await userApi.groups.updateOne($auth.user?.groupId, payload);
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
// 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() {
// @ts-ignore-next-line user will never be null here
const { data } = await adminApi.households.getOne($auth.user?.householdId);
if (!data || !data.preferences) {
alert.error(i18n.tc("events.something-went-wrong"));
return;
}
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 preferences = {
...data.preferences,
privateHousehold: !commonSettings.value.makeGroupRecipesPublic,
recipePublic: commonSettings.value.makeGroupRecipesPublic,
};
const payload = {
...data,
preferences,
}
const payload = {
...data,
preferences,
};
// @ts-ignore-next-line user will never be null here
const { response } = await adminApi.households.updateOne($auth.user?.householdId, payload);
if (!response || response.status !== 200) {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
// 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.tc("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.tc("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.tc("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;
}
async function seedData() {
if (!commonSettings.value.useSeedData) {
return;
}
const tasks = [
seedFoods(),
seedUnits(),
seedLabels(),
]
const tasks = [
seedFoods(),
seedUnits(),
seedLabels(),
];
await Promise.all(tasks);
}
await Promise.all(tasks);
}
async function submitCommonSettings() {
const tasks = [
updateGroup(),
updateHousehold(),
seedData(),
]
async function submitCommonSettings() {
const tasks = [
updateGroup(),
updateHousehold(),
seedData(),
];
await Promise.all(tasks);
}
await Promise.all(tasks);
}
async function submitAll() {
const tasks = [
submitRegistration(),
submitCommonSettings(),
]
async function submitAll() {
const tasks = [
submitRegistration(),
submitCommonSettings(),
];
await Promise.all(tasks);
}
await Promise.all(tasks);
}
async function handleSubmit(page: number) {
if (isSubmitting.value) {
return;
}
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;
}
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,
}
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

@ -1,26 +1,60 @@
<template>
<v-container fluid class="narrow-container">
<v-container
fluid
class="narrow-container"
>
<!-- Image -->
<BasePageTitle divider>
<template #header>
<v-img max-height="200" max-width="150" :src="require('~/static/svgs/admin-site-settings.svg')"></v-img>
<v-img
width="100%"
max-height="200"
max-width="150"
:src="require('~/static/svgs/admin-site-settings.svg')"
/>
</template>
<template #title>
{{ $t("settings.site-settings") }}
</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">
<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" outlined rows="18" readonly> </v-textarea>
<div class="d-flex justify-end" style="gap: 5px">
<BaseButton color="gray" secondary target="_blank" href="https://github.com/hay-kot/mealie/issues/new/choose">
<template #icon> {{ $globals.icons.github }}</template>
<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/hay-kot/mealie/issues/new/choose"
>
<template #icon>
{{ $globals.icons.github }}
</template>
{{ $t('settings.tracker') }}
</BaseButton>
<AppButtonCopy :copy-text="bugReportText" color="info" :icon="false" />
<AppButtonCopy
:copy-text="bugReportText"
color="info"
:icon="false"
/>
</div>
</v-card-text>
</BaseDialog>
@ -32,60 +66,88 @@
bugReportDialog = true;
"
>
<template #icon> {{ $globals.icons.github }}</template>
<template #icon>
{{ $globals.icons.github }}
</template>
{{ $t('settings.bug-report') }}
</BaseButton>
</div>
<!-- Configuration -->
<section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" :title="$tc('settings.configuration')"> </BaseCardSectionTitle>
<BaseCardSectionTitle
class="pb-0"
:icon="$globals.icons.cog"
:title="$t('settings.configuration')"
/>
<v-card class="mb-4">
<template v-for="(check, idx) in simpleChecks">
<v-list-item :key="`list-item-${idx}`">
<v-list-item-icon>
<v-icon :color="check.color">
<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>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ check.text }}
</v-list-item-title>
<v-list-item-subtitle class="wrap-word">
{{ check.status ? check.successText : check.errorText }}
</v-list-item-subtitle>
</v-list-item-content>
</template>
<v-list-item-subtitle class="wrap-word">
{{ check.status ? check.successText : check.errorText }}
</v-list-item-subtitle>
</v-list-item>
<v-divider :key="`divider-${idx}`"></v-divider>
<v-divider />
</template>
</v-card>
</section>
<!-- Email -->
<section>
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" :title="$tc('user.email')" />
<v-alert border="left" colored-border :type="appConfig.emailReady ? 'success' : 'error'" elevation="2">
<div class="font-weight-medium">{{ $t('settings.email-configuration-status') }}</div>
<div>
{{ appConfig.emailReady ? $t('settings.ready') : $t('settings.not-ready') }}
<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>
<v-text-field v-model="address" class="mr-4" :label="$t('user.email')" :rules="[validators.email]">
</v-text-field>
{{ appConfig.emailReady ? $t('settings.ready') : $t('settings.not-ready') }}
</div>
<div>
<v-text-field
v-model="address"
class="mr-4"
:label="$t('user.email')"
:rules="[validators.email]"
/>
<BaseButton
color="info"
variant="elevated"
:disabled="!appConfig.emailReady || !validEmail"
:loading="loading"
class="opacity-100"
@click="testEmail"
>
<template #icon> {{ $globals.icons.email }} </template>
<template #icon>
{{ $globals.icons.email }}
</template>
{{ $t("general.test") }}
</BaseButton>
<template v-if="tested">
<v-divider class="my-x mt-6"></v-divider>
<v-divider class="my-x mt-6" />
<v-card-text class="px-0">
<h4> {{ $tc("settings.email-test-results") }}</h4>
<h4> {{ $t("settings.email-test-results") }}</h4>
<span class="pl-4">
{{ success ? $t('settings.succeeded') : $t('settings.failed') }}
</span>
@ -97,48 +159,56 @@
<!-- General App Info -->
<section class="mt-4">
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" :title="$tc('settings.general-about')"> </BaseCardSectionTitle>
<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">
<v-list-item :key="property.name">
<v-list-item-icon>
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
<div>{{ property.name }}</div>
</v-list-item-title>
<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>
<v-list-item-subtitle>
<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 }}
</v-list-item-subtitle>
</template>
</v-list-item-content>
</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>
<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}`"></v-divider>
<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="$tc('general.loading')" />
<AppLoader :waiting-text="$t('general.loading')" />
</div>
</template>
</v-card>
@ -147,21 +217,11 @@
</template>
<script lang="ts">
import {
computed,
onMounted,
reactive,
toRefs,
ref,
defineComponent,
useAsync,
useContext,
} from "@nuxtjs/composition-api";
import { TranslateResult } from "vue-i18n";
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 { CheckAppConfig } from "~/lib/api/types/admin";
import type { CheckAppConfig } from "~/lib/api/types/admin";
import AppLoader from "~/components/global/AppLoader.vue";
enum DockerVolumeState {
@ -184,255 +244,262 @@ interface CheckApp extends CheckAppConfig {
isSiteSecure?: boolean;
}
export default defineComponent({
components: { AppLoader },
layout: "admin",
setup() {
const state = reactive({
loading: false,
address: "",
success: false,
error: "",
tested: false,
});
const appConfig = ref<CheckApp>({
emailReady: true,
baseUrlSet: true,
isSiteSecure: true,
isUpToDate: false,
ldapReady: false,
oidcReady: 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: i18n.t("settings.ldap-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: i18n.t("settings.oidc-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: i18n.t("settings.openai-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 { $globals, i18n } = useContext();
const rawAppInfo = ref({
version: "null",
versionLatest: "null",
});
function getAppInfo() {
const statistics = useAsync(async () => {
const { data } = await adminApi.about.about();
if (data) {
rawAppInfo.value.version = data.version;
rawAppInfo.value.versionLatest = data.versionLatest;
const prettyInfo = [
{
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;
}, useAsyncKey());
return statistics;
}
const appInfo = getAppInfo();
const bugReportDialog = ref(false);
const bugReportText = computed(() => {
const ignore = {
[i18n.tc("about.database-url")]: true,
[i18n.tc("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.tc("general.yes") : i18n.tc("general.no");
text += `${item.text.toString()}: ${status}\n`;
});
text += `${i18n.tc("settings.email-configured")}: ${appConfig.value.emailReady ? i18n.tc("general.yes") : i18n.tc("general.no")}\n`;
return text;
});
return {
bugReportDialog,
bugReportText,
DockerVolumeState,
simpleChecks,
appConfig,
validEmail,
validators,
...toRefs(state),
testEmail,
appInfo,
};
},
head() {
return {
title: this.$t("settings.site-settings") as string,
};
export default defineNuxtComponent({
components: { AppLoader },
setup() {
definePageMeta({
layout: "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: i18n.t("settings.ldap-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: i18n.t("settings.oidc-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: i18n.t("settings.openai-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 = [
{
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;
});
return {
bugReportDialog,
bugReportText,
DockerVolumeState,
simpleChecks,
appConfig,
validEmail,
validators,
...toRefs(state),
testEmail,
appInfo,
};
},
});
</script>