1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

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

@ -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 {};