refactor: ♻️ rewrite admin CRUD interface for admins (#825)

* docs: 📝 general documentation + add FAQ page

* fix(frontend): 🐛 readd missing upload button to backups.

* feat(backend):  add support for backup sizes to be displayed on frontend

* feat(backend):  add backend for administrator CRUD of users

* add admin support for user

* refactor(frontend): ♻️ rewrite admin CRUD interface for admins

* fix build errors

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-23 18:57:24 -09:00 committed by GitHub
parent 7afdd5b577
commit dce84c3937
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 657 additions and 563 deletions

View file

@ -0,0 +1,42 @@
# Frequently Asked Questions
## How can I change the theme?
You can change the theme by settings the environment variables on the frontend container.
Links:
- [Frontend Theme](/mealie/documentation/getting-started/installation/frontend-config#themeing)
## How can I change the language?
Languages need to be set on the frontend and backend containers as ENV variables.
Links
- [Frontend Config](/mealie/documentation/getting-started/installation/frontend-config/)
- [Backend Config](/mealie/documentation/getting-started/installation/backend-config/)
## How can I change the Login Session Timeout?
Login session can be configured by setting the `TOKEN_TIME` variable on the backend container.
- [Backend Config](/mealie/documentation/getting-started/installation/backend-config/)
## Can I serve Mealie on a subpath?
No. Due to limitations from the Javascript Framework, mealie doesn't support serving Mealie on a subpath.
## Can I install Mealie without docker?
Yes, you can install Mealie on your local machine. HOWEVER, it is recommended that you don't. Managing non-system versions of python, node, and npm is a pain. Moreover updating and upgrading your system with this configuration is unsupported and will likely require manual interventions. If you insist on installing Mealie on your local machine, you can use the links below to help guide your path.
- [Advanced Installation](/mealie/documentation/getting-started/installation/advanced/)
## How I can attach an Image or Video to a Recipe?
Yes. Mealie's Recipe Steps and other fields support the markdown syntax and therefor supports images and videos. To attach an image to the recipe, you can upload it as an asset and use the provided copy button to generate the html image tag required to render the image. For videos, Mealie provides no way to host videos. You'll need to host your videos with another provider and embed them in your recipe. Generally, the video provider will provide a link to the video and the html tag required to render the video. For example, youtube provides the following link that works inside a step. You can adjust the width and height attributes as necessary to ensure a fit.
```html
<iframe width="560" height="315" src="https://www.youtube.com/embed/nAUwKeO93bY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
```

View file

@ -2,7 +2,7 @@
To install Mealie on your server there are a few steps for proper configuration. Let's go through them.
## Step 0: Pre-work
## Pre-work
To deploy mealie on your local network it is highly recommended to use docker to deploy the image straight from dockerhub. Using the docker-compose templates provided, you should be able to get a stack up and running easily by changing a few default values and deploying. You can deploy with either SQLite (default) or Postgres. SQLite is sufficient for most use cases. Additionally, with Mealie's automated backup and restore functionality, you can easily move between SQLite and Postgres as you wish.

View file

@ -2,7 +2,7 @@
Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and Mealie will automatically import the relevant data or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
[Remember to join the Discord](https://discord.gg/QuStdQGSGK)!
[Remember to join the Discord](https://discord.gg/QuStdQGSGK)
!!! note
In some of the demo gifs the styling may be different than the finale application. demos were done during development prior to finale styling.

File diff suppressed because one or more lines are too long

View file

@ -55,7 +55,9 @@ nav:
- Getting Started:
- Introduction: "documentation/getting-started/introduction.md"
- Updating: "documentation/getting-started/updating.md"
- FAQ: "documentation/getting-started/faq.md"
- API: "documentation/getting-started/api-usage.md"
- Installation:
- Installation Checklist: "documentation/getting-started/installation/installation-checklist.md"
- SQLite (Recommended): "documentation/getting-started/installation/sqlite.md"
@ -63,6 +65,7 @@ nav:
- Frontend Configuration: "documentation/getting-started/installation/frontend-config.md"
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
- Advanced: "documentation/getting-started/installation/advanced.md"
- Recipes:
- Working With Recipes: "documentation/recipes/recipes.md"
- Organizing Recipes: "documentation/recipes/organizing-recipes.md"
@ -88,7 +91,9 @@ nav:
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
- Home Assistant: "documentation/community-guide/home-assistant.md"
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
- API Reference: "api/redoc.md"
- Contributors Guide:
- Non-Code: "contributors/non-coders.md"
- Translating: "contributors/translating.md"
@ -99,7 +104,9 @@ nav:
- Style Guide: "contributors/developers-guide/style-guide.md"
- Guides:
- Improving Ingredient Parser: "contributors/guides/ingredient-parser.md"
- Development Road Map: "roadmap.md"
- Change Log:
- v1.0.0 A Whole New App: "changelog/v1.0.0.md"
- v0.5.2 Misc Updates: "changelog/v0.5.2.md"

View file

@ -1,11 +1,13 @@
import { AdminAboutAPI } from "./admin/admin-about";
import { AdminTaskAPI } from "./admin/admin-tasks";
import { AdminUsersApi } from "./admin/admin-users";
import { ApiRequestInstance } from "~/types/api";
export class AdminAPI {
private static instance: AdminAPI;
public about: AdminAboutAPI;
public serverTasks: AdminTaskAPI;
public users: AdminUsersApi;
constructor(requests: ApiRequestInstance) {
if (AdminAPI.instance instanceof AdminAPI) {
@ -14,6 +16,7 @@ export class AdminAPI {
this.about = new AdminAboutAPI(requests);
this.serverTasks = new AdminTaskAPI(requests);
this.users = new AdminUsersApi(requests);
Object.freeze(this);
AdminAPI.instance = this;

View file

@ -0,0 +1,39 @@
import { BaseCRUDAPI } from "../_base";
const prefix = "/api";
interface UserCreate {
username: string;
fullName: string;
email: string;
admin: boolean;
group: string;
advanced: boolean;
canInvite: boolean;
canManage: boolean;
canOrganize: boolean;
password: string;
}
export interface UserToken {
name: string;
id: number;
createdAt: Date;
}
interface UserRead extends UserToken {
id: number;
groupId: number;
favoriteRecipes: any[];
tokens: UserToken[];
}
const routes = {
adminUsers: `${prefix}/admin/users`,
adminUsersId: (tag: string) => `${prefix}/admin/users/${tag}`,
};
export class AdminUsersApi extends BaseCRUDAPI<UserRead, UserCreate> {
baseRoute: string = routes.adminUsers;
itemRoute = routes.adminUsersId;
}

View file

@ -1,288 +0,0 @@
<template>
<div class="text-center d-print-none">
<BaseDialog
ref="domImportFromUrlDialog"
:title="$t('new-recipe.from-url')"
:icon="$globals.icons.link"
:submit-text="$t('general.create')"
:loading="processing"
width="600px"
@submit="createOnByUrl"
>
<v-form ref="domImportFromUrlForm" @submit.prevent="createOnByUrl">
<v-card-text>
<v-text-field
v-model="recipeURL"
:label="$t('new-recipe.recipe-url')"
validate-on-blur
autofocus
filled
rounded
class="rounded-lg"
:rules="[validators.url]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
<v-expand-transition>
<v-alert v-show="error" color="error" class="mt-6 white--text">
<v-card-title class="ma-0 pa-0">
<v-icon left color="white" x-large> {{ $globals.icons.robot }} </v-icon>
{{ $t("new-recipe.error-title") }}
</v-card-title>
<v-divider class="my-3 mx-2"></v-divider>
<p>
{{ $t("new-recipe.error-details") }}
</p>
<div class="d-flex row justify-space-around my-3 force-white">
<a
class="dark"
href="https://developers.google.com/search/docs/data-types/recipe"
target="_blank"
rel="noreferrer nofollow"
>
{{ $t("new-recipe.google-ld-json-info") }}
</a>
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.github-issues") }}
</a>
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.recipe-markup-specification") }}
</a>
</div>
<div class="d-flex justify-end">
<v-btn
white
outlined
:to="{ path: '/recipes/debugger', query: { test_url: recipeURL } }"
@click="addRecipe = false"
>
<v-icon left> {{ $globals.icons.externalLink }} </v-icon>
{{ $t("new-recipe.view-scraped-data") }}
</v-btn>
</div>
</v-alert>
</v-expand-transition>
</v-card-text>
</v-form>
</BaseDialog>
<BaseDialog
ref="domUploadZipDialog"
:title="$t('new-recipe.upload-a-recipe')"
:icon="$globals.icons.zip"
:submit-text="$t('general.import')"
:loading="processing"
@submit="uploadZip"
>
<v-card-text class="mt-1 pb-0">
{{ $t("new-recipe.upload-individual-zip-file") }}
<div class="headline mx-auto mb-0 pb-0 text-center">
{{ fileName }}
</div>
</v-card-text>
<v-card-actions>
<!-- <AppButtonUpload class="mx-auto" :text-btn="false" :post="false" @uploaded="setFile"> </AppButtonUpload> -->
</v-card-actions>
</BaseDialog>
<BaseDialog
ref="domCreateDialog"
:icon="$globals.icons.primary"
title="Create A Recipe"
@submit="manualCreateRecipe()"
>
<v-card-text class="mt-5">
<v-form>
<AutoForm v-model="createRecipeData.form" :items="createRecipeData.items" />
</v-form>
</v-card-text>
</BaseDialog>
<v-speed-dial v-model="fab" :open-on-hover="absolute" :fixed="absolute" :bottom="absolute" :right="absolute">
<template #activator>
<v-btn v-model="fab" :color="absolute ? 'accent' : 'white'" dark :icon="!absolute" :fab="absolute">
<v-icon> {{ $globals.icons.createAlt }} </v-icon>
</v-btn>
</template>
<!-- Action Buttons -->
<v-tooltip left dark color="primary">
<template #activator="{ on, attrs }">
<v-btn fab dark small color="primary" v-bind="attrs" v-on="on" @click="domImportFromUrlDialog.open()">
<v-icon>{{ $globals.icons.link }} </v-icon>
</v-btn>
</template>
<span>{{ $t("new-recipe.from-url") }}</span>
</v-tooltip>
<v-tooltip left dark color="accent">
<template #activator="{ on, attrs }">
<v-btn fab dark small color="accent" v-bind="attrs" v-on="on" @click="domCreateDialog.open()">
<v-icon>{{ $globals.icons.edit }}</v-icon>
</v-btn>
</template>
<span>{{ $t("general.new") }}</span>
</v-tooltip>
<v-tooltip left dark color="info">
<template #activator="{ on, attrs }">
<v-btn fab dark small color="info" v-bind="attrs" v-on="on" @click="domUploadZipDialog.open()">
<v-icon>{{ $globals.icons.zip }}</v-icon>
</v-btn>
</template>
<span>{{ $t("general.upload") }}</span>
</v-tooltip>
</v-speed-dial>
</div>
</template>
<script lang="ts">
// import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload.vue";
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
export default defineComponent({
props: {
absolute: {
type: Boolean,
default: false,
},
},
setup() {
const domCreateDialog = ref(null);
const domCreateForm = ref<VForm | null>(null);
const domUploadZipDialog = ref(null);
const domUploadZipForm = ref<VForm | null>(null);
const domImportFromUrlDialog = ref(null);
const domImportFromUrlForm = ref<VForm | null>(null);
const api = useUserApi();
return {
domCreateDialog,
domCreateForm,
domUploadZipDialog,
domUploadZipForm,
domImportFromUrlDialog,
domImportFromUrlForm,
api,
validators,
};
},
data() {
return {
error: false,
fab: false,
addRecipe: false,
processing: false,
uploadData: {
fileName: "archive",
file: null,
},
createRecipeData: {
items: [
{
label: "Recipe Name",
varName: "name",
type: fieldTypes.TEXT,
rules: ["required"],
},
],
form: {
name: "",
},
},
};
},
computed: {
recipeURL: {
set(recipe_import_url: string) {
this.$router.replace({ query: { ...this.$route.query, recipe_import_url } });
},
get(): string | (string | null)[] {
return this.$route.query.recipe_import_url || "";
},
},
fileName(): string {
// @ts-ignore
if (this.uploadData?.file?.name) {
// @ts-ignore
return this.uploadData.file.name;
}
return "";
},
},
mounted() {
if (this.$route.query.recipe_import_url) {
this.addRecipe = true;
this.createOnByUrl();
}
},
methods: {
reset() {
this.fab = false;
this.error = false;
this.addRecipe = false;
this.recipeURL = "";
this.processing = false;
},
resetVars() {
this.uploadData = {
fileName: "archive",
file: null,
};
},
setFile(file: any) {
this.uploadData.file = file;
console.log("Uploaded");
},
async uploadZip() {
const formData = new FormData();
// @ts-ignore
formData.append(this.uploadData.fileName, this.uploadData.file);
const { response, data } = await this.api.upload.file("/api/recipes/create-from-zip", formData);
if (response && response.status === 201) {
// @ts-ignore
this.$router.push(`/recipe/${data.slug}`);
}
},
async manualCreateRecipe() {
await this.api.recipes.createOne({ name: this.createRecipeData.form.name });
},
async createOnByUrl() {
this.error = false;
if (this.domImportFromUrlForm?.validate()) {
this.processing = true;
let response;
if (typeof this.recipeURL === "string") {
response = await this.api.recipes.createOneByUrl(this.recipeURL);
}
this.processing = false;
if (response) {
this.addRecipe = false;
this.recipeURL = "";
this.$router.push(`/recipe/${response.data}`);
} else {
this.error = true;
}
}
},
},
});
</script>
<style scoped>
</style>

View file

@ -1,8 +1,8 @@
<template>
<v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<input ref="uploader" class="d-none" type="file" :accept="accept" @change="onFileChanged" />
<slot v-bind="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" :small="small" color="accent" :text="textBtn" @click="onButtonClick">
<v-btn :loading="isSelecting" :small="small" color="info" :text="textBtn" @click="onButtonClick">
<v-icon left> {{ effIcon }}</v-icon>
{{ text ? text : defaultText }}
</v-btn>
@ -43,6 +43,10 @@ export default {
type: Boolean,
default: true,
},
accept: {
type: String,
default: "",
},
},
setup() {
const api = useUserApi();

View file

@ -0,0 +1,25 @@
<template>
<v-toolbar flat>
<BaseButton color="null" rounded secondary @click="$router.go(-1)">
<template #icon> {{ $globals.icons.arrowLeftBold }}</template>
Back
</BaseButton>
<slot></slot>
</v-toolbar>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
back: {
type: Boolean,
default: false,
},
},
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -18,23 +18,25 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:disabled="updateMode && inputField.fixed"
:disabled="updateMode && inputField.disableUpdate"
@change="emitBlur"
/>
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT"
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="value[inputField.varName]"
:readonly="inputField.fixed && updateMode"
:readonly="inputField.disableUpdate && updateMode"
:disabled="inputField.disableUpdate && updateMode"
filled
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
rounded
class="rounded-lg"
dense
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="[...rulesByKey(inputField.rules), ...defaultRules]"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules), ...defaultRules] : []"
lazy-validation
@blur="emitBlur"
/>
@ -43,7 +45,8 @@
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="value[inputField.varName]"
:readonly="inputField.fixed && updateMode"
:readonly="inputField.disableUpdate && updateMode"
:disabled="inputField.disableUpdate && updateMode"
filled
rounded
class="rounded-lg"
@ -62,7 +65,7 @@
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="value[inputField.varName]"
:readonly="inputField.fixed && updateMode"
:readonly="inputField.disableUpdate && updateMode"
filled
rounded
class="rounded-lg"

View file

@ -6,6 +6,5 @@ export const fieldTypes = {
OBJECT: "object",
BOOLEAN: "boolean",
COLOR: "color",
};
PASSWORD: "password",
} as const;

View file

@ -79,7 +79,6 @@ export const useBackups = function (fetch = true) {
async function deleteBackup() {
const { response } = await api.backups.deleteOne(deleteTarget.value);
if (response && response.status === 200) {
refreshBackups();
}

View file

@ -77,9 +77,9 @@ export const useUser = function (refreshFunc: CallableFunction | null = null) {
return data;
}
async function updateUser(slug: string, user: UserOut) {
async function updateUser(itemId: string, user: UserOut) {
loading.value = true;
const { data } = await api.users.updateOne(slug, user);
const { data } = await api.users.updateOne(itemId, user);
loading.value = false;
if (refreshFunc) {

View file

@ -0,0 +1 @@
export { useUserForm } from "./user-form";

View file

@ -0,0 +1,68 @@
import { fieldTypes } from "../forms";
import { AutoFormItems } from "~/types/auto-forms";
export const useUserForm = () => {
const userForm: AutoFormItems = [
{
section: "User Details",
label: "User Name",
varName: "username",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Full Name",
varName: "fullName",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Email",
varName: "email",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Password",
varName: "password",
disableUpdate: true,
type: fieldTypes.PASSWORD,
rules: ["required"],
},
{
section: "Permissions",
label: "Administrator",
varName: "admin",
type: fieldTypes.BOOLEAN,
rules: ["required"],
},
{
label: "User can invite other to group",
varName: "canInvite",
type: fieldTypes.BOOLEAN,
rules: ["required"],
},
{
label: "User can manage group",
varName: "canManage",
type: fieldTypes.BOOLEAN,
rules: ["required"],
},
{
label: "User can organize group data",
varName: "canOrganize",
type: fieldTypes.BOOLEAN,
rules: ["required"],
},
{
label: "Enable advanced features",
varName: "advanced",
type: fieldTypes.BOOLEAN,
rules: ["required"],
},
];
return {
userForm,
};
};

View file

@ -76,39 +76,17 @@ export default defineComponent({
to: "/admin/toolbox/units",
title: "Manage Units",
},
{
icon: $globals.icons.tags,
to: "/admin/toolbox/categories",
title: i18n.t("sidebar.tags"),
},
{
icon: $globals.icons.tags,
to: "/admin/toolbox/tags",
title: i18n.t("sidebar.categories"),
},
],
},
{
icon: $globals.icons.user,
to: "/admin/manage/users",
title: i18n.t("user.users"),
},
{
icon: $globals.icons.group,
to: "/admin/manage-users",
title: i18n.t("sidebar.manage-users"),
children: [
{
icon: $globals.icons.user,
to: "/admin/manage-users/all-users",
title: i18n.t("user.users"),
},
{
icon: $globals.icons.group,
to: "/admin/manage-users/all-groups",
title: i18n.t("group.groups"),
},
],
},
{
icon: $globals.icons.import,
to: "/admin/migrations",
title: i18n.t("sidebar.migrations"),
to: "/admin/manage/groups",
title: i18n.t("group.groups"),
},
{
icon: $globals.icons.database,

View file

@ -90,6 +90,16 @@
</template>
</v-data-table>
<v-divider></v-divider>
<div class="mt-4 d-flex justify-end">
<AppButtonUpload
:text-btn="false"
class="mr-4"
url="/api/backups/upload"
accept=".zip"
color="info"
@uploaded="refreshBackups()"
/>
</div>
</section>
</v-container>
</template>
@ -115,6 +125,7 @@ export default defineComponent({
headers: [
{ text: i18n.t("general.name"), value: "name" },
{ text: i18n.t("general.created"), value: "date" },
{ text: "Size", value: "size" },
{ text: "", value: "actions", align: "right" },
],
});

View file

@ -42,7 +42,7 @@
</template>
<template #actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users/all-users">
<v-btn color="primary" small to="/admin/manage/users">
<v-icon left>{{ $globals.icons.user }}</v-icon>
{{ $t("user.manage-users") }}
</v-btn>
@ -65,7 +65,7 @@
</template>
<template #actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users/all-groups">
<v-btn color="primary" small to="/admin/manage/groups">
<v-icon left>{{ $globals.icons.group }}</v-icon>
{{ $t("group.manage-groups") }}
</v-btn>

View file

@ -1,183 +0,0 @@
// TODO: Edit User
<template>
<v-container fluid>
<BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle>
<section>
<v-toolbar color="background" flat class="justify-between">
<BaseDialog
ref="refUserDialog"
top
:title="$t('user.create-user')"
@submit="createUser(createUserForm.data)"
@close="resetForm"
>
<template #activator="{ open }">
<BaseButton @click="open"> {{ $t("user.create-user") }} </BaseButton>
</template>
<v-card-text>
<v-select
v-model="createUserForm.data.group"
:items="groups"
rounded
class="rounded-lg"
item-text="name"
item-value="name"
:return-object="false"
filled
label="Filled style"
></v-select>
<AutoForm v-model="createUserForm.data" :update-mode="updateMode" :items="createUserForm.items" />
</v-card-text>
</BaseDialog>
</v-toolbar>
<v-data-table
:headers="headers"
:items="users || []"
item-key="id"
class="elevation-0"
hide-default-footer
disable-pagination
:search="search"
>
<template #item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template #item.actions="{ item }">
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="deleteUser(item.id)">
<template #activator="{ open }">
<v-btn :disabled="item.id == 1" class="mr-1" small color="error" @click="open">
<v-icon small left>
{{ $globals.icons.delete }}
</v-icon>
{{ $t("general.delete") }}
</v-btn>
<v-btn small color="success" @click="updateUser(item)">
<v-icon small left class="mr-2">
{{ $globals.icons.edit }}
</v-icon>
{{ $t("general.edit") }}
</v-btn>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
</template>
</v-data-table>
<v-divider></v-divider>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms";
import { useUserApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useUser, useAllUsers } from "~/composables/use-user";
export default defineComponent({
layout: "admin",
setup() {
const api = useUserApi();
const refUserDialog = ref();
const { groups } = useGroups();
const { users, refreshAllUsers } = useAllUsers();
const { loading, getUser, deleteUser, createUser } = useUser(refreshAllUsers);
return { refUserDialog, api, users, deleteUser, createUser, getUser, loading, groups };
},
data() {
return {
search: "",
headers: [
{
text: this.$t("user.user-id"),
align: "start",
sortable: false,
value: "id",
},
{ text: this.$t("user.username"), value: "username" },
{ text: this.$t("user.full-name"), value: "fullName" },
{ text: this.$t("user.email"), value: "email" },
{ text: this.$t("group.group"), value: "group" },
{ text: this.$t("user.admin"), value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
updateMode: false,
createUserForm: {
items: [
{
label: "User Name",
varName: "username",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Full Name",
varName: "fullName",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Email",
varName: "email",
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Passord",
varName: "password",
fixed: true,
type: fieldTypes.TEXT,
rules: ["required"],
},
{
label: "Administrator",
varName: "admin",
type: fieldTypes.BOOLEAN,
rules: ["required"],
},
],
data: {
username: "",
fullName: "",
email: "",
admin: false,
group: "",
favoriteRecipes: [],
password: "",
},
},
};
},
head() {
return {
title: this.$t("sidebar.manage-users") as string,
};
},
methods: {
updateUser(userData: any) {
this.updateMode = true;
this.createUserForm.data = userData;
this.refUserDialog.open();
},
resetForm() {
this.createUserForm.data = {
username: "",
fullName: "",
email: "",
admin: false,
group: "",
favoriteRecipes: [],
password: "",
};
},
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,117 @@
<template>
<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>
</template>
<template #title> Admin User Management </template>
Changes to this user will be reflected immediately.
</BasePageTitle>
<AppToolbar back> </AppToolbar>
<v-form v-if="!userError" ref="refNewUserForm" @submit.prevent="handleSubmit">
<v-card outlined>
<v-card-text>
<div class="d-flex">
<p>User Id: {{ user.id }}</p>
</div>
<v-select
v-if="groups"
v-model="user.group"
:items="groups"
rounded
class="rounded-lg"
item-text="name"
item-value="name"
:return-object="false"
filled
label="User Group"
:rules="[validators.required]"
></v-select>
<AutoForm v-model="user" :items="userForm" update-mode />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" class="ml-auto"></BaseButton>
</div>
</v-form>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useRoute, onMounted, ref } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { alert } from "~/composables/use-toast";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
export default defineComponent({
layout: "admin",
setup() {
const { userForm } = useUserForm();
const { groups } = useGroups();
const route = useRoute();
const userId = route.value.params.id;
// ==============================================
// New User Form
const refNewUserForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const user = ref({
username: "",
fullName: "",
email: "",
admin: false,
group: "",
advanced: false,
canInvite: false,
canManage: false,
canOrganize: false,
id: 0,
groupId: 0,
});
const userError = ref(false);
onMounted(async () => {
const { data, error } = await adminApi.users.getOne(userId);
if (error?.response?.status === 404) {
alert.error("User Not Found");
userError.value = true;
}
if (data) {
// @ts-ignore
user.value = data;
}
});
async function handleSubmit() {
if (!refNewUserForm.value?.validate()) return;
// @ts-ignore
const { response, data } = await adminApi.users.updateOne(user.value.id, user.value);
if (response?.status === 200 && data) {
// @ts-ignore
user.value = data;
}
}
return {
user,
userError,
userForm,
refNewUserForm,
handleSubmit,
groups,
validators,
};
},
});
</script>

View file

@ -0,0 +1,96 @@
<template>
<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>
</template>
<template #title> Admin User Creation </template>
</BasePageTitle>
<AppToolbar back> </AppToolbar>
<v-form ref="refNewUserForm" @submit.prevent="handleSubmit">
<v-card outlined>
<v-card-text>
<v-select
v-if="groups"
v-model="newUserData.group"
:items="groups"
rounded
class="rounded-lg"
item-text="name"
item-value="name"
:return-object="false"
filled
label="User Group"
:rules="[validators.required]"
></v-select>
<AutoForm v-model="newUserData" :items="userForm" />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" class="ml-auto"></BaseButton>
</div>
</v-form>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useRouter } from "@nuxtjs/composition-api";
import { reactive, ref, toRefs } from "vue-demi";
import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
export default defineComponent({
layout: "admin",
setup() {
const { userForm } = useUserForm();
const { groups } = useGroups();
const router = useRouter();
// ==============================================
// New User Form
const refNewUserForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const state = reactive({
newUserData: {
username: "",
fullName: "",
email: "",
admin: false,
group: "",
advanced: false,
canInvite: false,
canManage: false,
canOrganize: false,
password: "",
},
});
async function handleSubmit() {
if (!refNewUserForm.value?.validate()) return;
const { response } = await adminApi.users.createOne(state.newUserData);
if (response?.status === 201) {
router.push("/admin/manage/users");
}
}
return {
...toRefs(state),
userForm,
refNewUserForm,
handleSubmit,
groups,
validators,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,108 @@
// TODO: Edit User
<template>
<v-container fluid>
<BaseCardSectionTitle title="User Management"> </BaseCardSectionTitle>
<section>
<v-toolbar color="background" flat class="justify-between">
<BaseButton to="/admin/manage/users/create">
{{ $t("general.create") }}
</BaseButton>
</v-toolbar>
<v-data-table
:headers="headers"
:items="users || []"
item-key="id"
class="elevation-0"
elevation="0"
hide-default-footer
disable-pagination
:search="search"
@click:row="handleRowClick"
>
<template #item.admin="{ item }">
<v-icon right :color="item.admin ? 'success' : null">
{{ item.admin ? $globals.icons.checkboxMarkedCircle : $globals.icons.windowClose }}
</v-icon>
</template>
<template #item.actions="{ item }">
<BaseDialog :title="$t('general.confirm')" color="error" @confirm="deleteUser(item.id)">
<template #activator="{ open }">
<v-btn icon :disabled="item.id == 1" color="error" @click.stop="open">
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</template>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
</template>
</v-data-table>
<v-divider></v-divider>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { useUser, useAllUsers } from "~/composables/use-user";
import { UserOut } from "~/types/api-types/user";
export default defineComponent({
layout: "admin",
setup() {
const api = useUserApi();
const refUserDialog = ref();
const { i18n } = useContext();
const router = useRouter();
const state = reactive({
search: "",
});
const { users, refreshAllUsers } = useAllUsers();
const { loading, deleteUser } = useUser(refreshAllUsers);
function handleRowClick(item: UserOut) {
router.push("/admin/manage/users/" + item.id);
}
// ==========================================================
// Constants / Non-reactive
const headers = [
{
text: 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("user.admin"), value: "admin" },
{ text: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" },
];
return {
...toRefs(state),
api,
headers,
deleteUser,
loading,
refUserDialog,
users,
handleRowClick,
};
},
head() {
return {
title: this.$t("sidebar.manage-users") as string,
};
},
});
</script>

View file

@ -0,0 +1,13 @@
type FormFieldType = "text" | "textarea" | "list" | "select" | "object" | "boolean" | "color" | "password";
export interface FormField {
section?: string;
sectionDetails?: string;
label?: string;
varName: string;
type: FormFieldType;
rules?: string[];
disableUpdate?: boolean;
}
export type AutoFormItems = FormField[];

View file

@ -1,49 +1,45 @@
// This Code is auto generated by gen_global_componenets.py
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import BaseColorPicker from "@/components/global/BaseColorPicker.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import BaseAutoForm from "@/components/global/BaseAutoForm.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppFloatingButton from "@/components/layout/AppFloatingButton.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import BaseColorPicker from "@/components/global/BaseColorPicker.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import BaseAutoForm from "@/components/global/BaseAutoForm.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
declare module "vue" {
export interface GlobalComponents {
// Global Components
BaseCardSectionTitle: typeof BaseCardSectionTitle;
AppLoader: typeof AppLoader;
BaseButton: typeof BaseButton;
BaseDialog: typeof BaseDialog;
BaseStatCard: typeof BaseStatCard;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
BaseColorPicker: typeof BaseColorPicker;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
BasePageTitle: typeof BasePageTitle;
BaseAutoForm: typeof BaseAutoForm;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppFloatingButton: typeof AppFloatingButton;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
BaseCardSectionTitle: typeof BaseCardSectionTitle;
AppLoader: typeof AppLoader;
BaseButton: typeof BaseButton;
BaseDialog: typeof BaseDialog;
BaseStatCard: typeof BaseStatCard;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
BaseColorPicker: typeof BaseColorPicker;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
BasePageTitle: typeof BasePageTitle;
BaseAutoForm: typeof BaseAutoForm;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
}
}
export {};
export {};

View file

@ -1,6 +1,8 @@
from fastapi import APIRouter
from mealie.routes.routers import AdminAPIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.admin.admin_user_service import AdminUserService
from . import admin_about, admin_email, admin_group, admin_log, admin_server_tasks
@ -9,5 +11,6 @@ router = AdminAPIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_log.router, tags=["Admin: Log"])
router.include_router(admin_group.router, tags=["Admin: Group"])
router.include_router(RouterFactory(AdminUserService, prefix="/users", tags=["Admin: Users"]))
router.include_router(admin_email.router, tags=["Admin: Email"])
router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"])

View file

@ -15,7 +15,6 @@ router = AdminAPIRouter(prefix="/groups")
async def get_all_groups(session: Session = Depends(generate_session)):
"""Returns a list of all groups in the database"""
db = get_database(session)
return db.groups.get_all()

View file

@ -6,8 +6,6 @@ from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, s
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.core.dependencies import get_current_user
from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token
@ -18,9 +16,11 @@ from mealie.schema.user.user import PrivateUser
from mealie.services.backups import imports
from mealie.services.backups.exports import backup_all
from mealie.services.events import create_backup_event
from mealie.utils.fs_stats import pretty_size
router = AdminAPIRouter(prefix="/api/backups", tags=["Backups"])
logger = get_logger()
app_dirs = get_app_dirs()
@router.get("/available", response_model=AllBackups)
@ -28,7 +28,7 @@ def available_imports():
"""Returns a list of avaiable .zip files for import into Mealie."""
imports = []
for archive in app_dirs.BACKUP_DIR.glob("*.zip"):
backup = BackupFile(name=archive.name, date=archive.stat().st_ctime)
backup = BackupFile(name=archive.name, date=archive.stat().st_ctime, size=pretty_size(archive.stat().st_size))
imports.append(backup)
templates = [template.name for template in app_dirs.TEMPLATE_DIR.glob("*.*")]
@ -118,3 +118,5 @@ def delete_backup(file_name: str):
file_path.unlink()
except Exception:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
return {"message": f"{file_name} has been deleted."}

View file

@ -60,6 +60,7 @@ class CreateBackup(BaseModel):
class BackupFile(BaseModel):
name: str
date: datetime
size: str
class AllBackups(BaseModel):

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.user.user import UserIn, UserOut
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import AdminHttpService
from mealie.services.events import create_recipe_event
class AdminUserService(
CrudHttpMixins[UserOut, UserIn, UserIn],
AdminHttpService[int, UserOut],
):
event_func = create_recipe_event
_schema = UserOut
@cached_property
def dal(self):
return self.db.users
def populate_item(self, id: int) -> UserOut:
self.item = self.dal.get_one(id)
return self.item
def get_all(self) -> list[UserOut]:
return self.dal.get_all()
def create_one(self, data: UserIn) -> UserOut:
return self._create_one(data)
def update_one(self, data: UserOut, item_id: int = None) -> UserOut:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> UserOut:
return self._delete_one(id)

15
mealie/utils/fs_stats.py Normal file
View file

@ -0,0 +1,15 @@
def pretty_size(size: int) -> str:
"""
Pretty size takes in a integer value of a file size and returns the most applicable
file unit and the size.
"""
if size < 1024:
return f"{size} bytes"
elif size < 1024 ** 2:
return f"{round(size / 1024, 2)} KB"
elif size < 1024 ** 2 * 1024:
return f"{round(size / 1024 / 1024, 2)} MB"
elif size < 1024 ** 2 * 1024 * 1024:
return f"{round(size / 1024 / 1024 / 1024, 2)} GB"
else:
return f"{round(size / 1024 / 1024 / 1024 / 1024, 2)} TB"