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:
parent
7afdd5b577
commit
dce84c3937
32 changed files with 657 additions and 563 deletions
42
docs/docs/documentation/getting-started/faq.md
Normal file
42
docs/docs/documentation/getting-started/faq.md
Normal 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>
|
||||
```
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
39
frontend/api/admin/admin-users.ts
Normal file
39
frontend/api/admin/admin-users.ts
Normal 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;
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
|
|
25
frontend/components/global/AppToolbar.vue
Normal file
25
frontend/components/global/AppToolbar.vue
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -6,6 +6,5 @@ export const fieldTypes = {
|
|||
OBJECT: "object",
|
||||
BOOLEAN: "boolean",
|
||||
COLOR: "color",
|
||||
};
|
||||
|
||||
|
||||
PASSWORD: "password",
|
||||
} as const;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
1
frontend/composables/use-users/index.ts
Normal file
1
frontend/composables/use-users/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { useUserForm } from "./user-form";
|
68
frontend/composables/use-users/user-form.ts
Normal file
68
frontend/composables/use-users/user-form.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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" },
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
117
frontend/pages/admin/manage/users/_id.vue
Normal file
117
frontend/pages/admin/manage/users/_id.vue
Normal 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>
|
96
frontend/pages/admin/manage/users/create.vue
Normal file
96
frontend/pages/admin/manage/users/create.vue
Normal 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>
|
108
frontend/pages/admin/manage/users/index.vue
Normal file
108
frontend/pages/admin/manage/users/index.vue
Normal 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>
|
13
frontend/types/auto-forms.ts
Normal file
13
frontend/types/auto-forms.ts
Normal 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[];
|
76
frontend/types/components.d.ts
vendored
76
frontend/types/components.d.ts
vendored
|
@ -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 {};
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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."}
|
||||
|
|
|
@ -60,6 +60,7 @@ class CreateBackup(BaseModel):
|
|||
class BackupFile(BaseModel):
|
||||
name: str
|
||||
date: datetime
|
||||
size: str
|
||||
|
||||
|
||||
class AllBackups(BaseModel):
|
||||
|
|
36
mealie/services/admin/admin_user_service.py
Normal file
36
mealie/services/admin/admin_user_service.py
Normal 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
15
mealie/utils/fs_stats.py
Normal 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"
|
Loading…
Reference in a new issue