edit users
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"@nuxt/test-utils": "3.19.2",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@vee-validate/yup": "^4.15.1",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"byte-size": "^9.0.1",
|
||||
@@ -29,7 +30,7 @@
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-sonner": "^2.0.1",
|
||||
"zod": "^4.0.5",
|
||||
"yup": "^1.6.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.57",
|
||||
@@ -538,6 +539,8 @@
|
||||
|
||||
"@unhead/vue": ["@unhead/vue@2.0.12", "", { "dependencies": { "hookable": "^5.5.3", "unhead": "2.0.12" }, "peerDependencies": { "vue": ">=3.5.13" } }, "sha512-WFaiCVbBd39FK6Bx3GQskhgT9s45Vjx6dRQegYheVwU1AnF+FAfJVgWbrl21p6fRJcLAFp0xDz6wE18JYBM0eQ=="],
|
||||
|
||||
"@vee-validate/yup": ["@vee-validate/yup@4.15.1", "", { "dependencies": { "type-fest": "^4.8.3", "vee-validate": "4.15.1" }, "peerDependencies": { "yup": "^1.3.2" } }, "sha512-+u6lI1IZftjHphj+mTCPJRruwBBwv1IKKCI1EFm6ipQroAPibkS5M8UNX+yeVYG5++ix6m1rsv4/SJvJJQTWJg=="],
|
||||
|
||||
"@vee-validate/zod": ["@vee-validate/zod@4.15.1", "", { "dependencies": { "type-fest": "^4.8.3", "vee-validate": "4.15.1" }, "peerDependencies": { "zod": "^3.24.0" } }, "sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA=="],
|
||||
|
||||
"@vercel/nft": ["@vercel/nft@0.29.4", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^10.4.5", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-6lLqMNX3TuycBPABycx7A9F1bHQR7kiQln6abjFbPrf5C/05qHM9M5E4PeTE59c7z8g6vHnx1Ioihb2AQl7BTA=="],
|
||||
@@ -1548,6 +1551,8 @@
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
|
||||
"property-expr": ["property-expr@2.0.6", "", {}, "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="],
|
||||
|
||||
"protocols": ["protocols@2.0.2", "", {}, "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
@@ -1752,6 +1757,8 @@
|
||||
|
||||
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
|
||||
|
||||
"tiny-case": ["tiny-case@1.0.3", "", {}, "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
@@ -1770,6 +1777,8 @@
|
||||
|
||||
"toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="],
|
||||
|
||||
"toposort": ["toposort@2.0.2", "", {}, "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
@@ -1928,6 +1937,8 @@
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],
|
||||
|
||||
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
|
||||
|
||||
"zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="],
|
||||
@@ -2242,6 +2253,8 @@
|
||||
|
||||
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"yup/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
@@ -81,9 +81,14 @@ const warrenCrumbs = computed<WarrenBreadcrumbData[]>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<template v-if="store.current == null">
|
||||
<Breadcrumb class="flex-nowrap overflow-hidden">
|
||||
<BreadcrumbList class="flex-nowrap">
|
||||
<template
|
||||
v-if="
|
||||
store.current == null ||
|
||||
!route.path.startsWith('/warrens/files')
|
||||
"
|
||||
>
|
||||
<template v-for="(crumb, i) in routeCrumbs" :key="i">
|
||||
<BreadcrumbItem>
|
||||
<NuxtLink
|
||||
@@ -97,10 +102,7 @@ const warrenCrumbs = computed<WarrenBreadcrumbData[]>(() => {
|
||||
</NuxtLink>
|
||||
<BreadcrumbPage v-else>{{ crumb.name }}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator
|
||||
v-if="i < routeCrumbs.length - 1"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
<BreadcrumbSeparator v-if="i < routeCrumbs.length - 1" />
|
||||
</template>
|
||||
</template>
|
||||
<template
|
||||
@@ -118,10 +120,7 @@ const warrenCrumbs = computed<WarrenBreadcrumbData[]>(() => {
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbPage v-else>{{ crumb.name }}</BreadcrumbPage>
|
||||
<BreadcrumbSeparator
|
||||
v-if="i < warrenCrumbs.length - 1"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
<BreadcrumbSeparator v-if="i < warrenCrumbs.length - 1" />
|
||||
</template>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
@@ -2,50 +2,48 @@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useForm } from 'vee-validate';
|
||||
import { createUserSchema } from '~/lib/schemas/admin';
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import type z from 'zod';
|
||||
import { createUser } from '~/lib/api/admin/createUser';
|
||||
import { toTypedSchema } from '@vee-validate/yup';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const creating = ref(false);
|
||||
|
||||
function cancel() {
|
||||
adminStore.closeCreateUserDialog();
|
||||
form.resetForm();
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(createUserSchema),
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(
|
||||
async (values: z.output<typeof createUserSchema>) => {
|
||||
if (creating.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
creating.value = true;
|
||||
|
||||
const result = await createUser(values);
|
||||
|
||||
creating.value = false;
|
||||
|
||||
if (result.success) {
|
||||
adminStore.closeCreateUserDialog();
|
||||
}
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (creating.value) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
creating.value = true;
|
||||
|
||||
const result = await createUser(values);
|
||||
|
||||
creating.value = false;
|
||||
|
||||
if (result.success) {
|
||||
adminStore.closeCreateUserDialog();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="adminStore.createUserDialog != null">
|
||||
<DialogTrigger><slot /></DialogTrigger>
|
||||
<DialogContent @escape-key-down="cancel">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create user</DialogTitle>
|
||||
<DialogDescription>
|
||||
<AlertDialog :open="adminStore.createUserDialogOpen">
|
||||
<AlertDialogTrigger><slot /></AlertDialogTrigger>
|
||||
<AlertDialogContent @escape-key-down="cancel">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Create user</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Enter a username, email and password to create a new user
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<form
|
||||
id="create-user-form"
|
||||
@@ -67,8 +65,8 @@ const onSubmit = form.handleSubmit(
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<FormMessage />
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
@@ -104,8 +102,8 @@ const onSubmit = form.handleSubmit(
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<FormMessage />
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="admin">
|
||||
@@ -118,15 +116,19 @@ const onSubmit = form.handleSubmit(
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<FormMessage />
|
||||
</FormField>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="cancel">Cancel</Button>
|
||||
<Button type="submit" form="create-user-form">Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel variant="outline" @click="cancel"
|
||||
>Cancel</AlertDialogCancel
|
||||
>
|
||||
<AlertDialogAction type="submit" form="create-user-form"
|
||||
>Create</AlertDialogAction
|
||||
>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
@@ -35,7 +35,7 @@ adminStore.$subscribe(async (_mutation, state) => {
|
||||
|
||||
function close() {
|
||||
confirmEmail.value = '';
|
||||
adminStore.clearDeleteUserDialog();
|
||||
adminStore.closeDeleteUserDialog();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
|
||||
185
frontend/components/admin/EditUserDialog.vue
Normal file
185
frontend/components/admin/EditUserDialog.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useForm } from 'vee-validate';
|
||||
import { toTypedSchema } from '@vee-validate/yup';
|
||||
import { editUserSchema } from '~/lib/schemas/admin';
|
||||
import { editUser } from '~/lib/api/admin/editUser';
|
||||
import type { AuthUser } from '~/shared/types/auth';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
|
||||
const isValid = computed(() => Object.keys(form.errors.value).length < 1);
|
||||
|
||||
// We'll only update this value if there is a user to prevent layout shifts on close
|
||||
const user = ref<AuthUser>();
|
||||
const editing = ref(false);
|
||||
|
||||
const isChanged = computed(() => {
|
||||
if (user.value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = editUserSchema.validateSync(form.controlledValues.value);
|
||||
return (
|
||||
values.name !== user.value.name ||
|
||||
values.email !== user.value.email ||
|
||||
values.password != null ||
|
||||
values.admin !== user.value.admin
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
adminStore.closeEditUserDialog();
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(editUserSchema),
|
||||
});
|
||||
|
||||
adminStore.$subscribe((_mutation, state) => {
|
||||
if (state.editUserDialog != null && !editing.value) {
|
||||
user.value = state.editUserDialog.user;
|
||||
form.setValues(user.value);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (user.value == null || !isChanged.value || editing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editing.value = true;
|
||||
|
||||
const result = await editUser({
|
||||
id: user.value.id,
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
password: values.password ?? null,
|
||||
admin: values.admin,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
close();
|
||||
}
|
||||
|
||||
editing.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialog :open="adminStore.editUserDialog != null">
|
||||
<AlertDialogTrigger><slot /></AlertDialogTrigger>
|
||||
<AlertDialogContent @escape-key-down="close">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Edit user</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Edit the user's fields, manage permissions or assign warrens
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<form
|
||||
id="edit-user-form"
|
||||
class="flex flex-col gap-2"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="confused-cat"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
data-protonpass-ignore
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="confusedcat@example.com"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
data-protonpass-ignore
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
id="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
data-protonpass-ignore
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription
|
||||
>Leave empty to keep the current
|
||||
password</FormDescription
|
||||
>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="admin">
|
||||
<FormItem>
|
||||
<FormLabel>Admin</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
id="admin"
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</form>
|
||||
|
||||
<AlertDialogFooter class="gap-y-0">
|
||||
<AlertDialogCancel @click="close">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
type="submit"
|
||||
form="edit-user-form"
|
||||
:disabled="!isChanged || !isValid"
|
||||
>Save</AlertDialogAction
|
||||
>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
@@ -11,15 +11,21 @@ const AVATAR =
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group/user flex flex-row items-center justify-between gap-4">
|
||||
<div
|
||||
class="group/user bg-accent/30 flex cursor-pointer flex-row items-center justify-between gap-4 overflow-hidden rounded-lg p-2 pl-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage :src="AVATAR" />
|
||||
</Avatar>
|
||||
<div class="flex grow flex-col leading-4">
|
||||
<span class="text-sm font-medium">{{ user.name }}</span>
|
||||
<span class="text-muted-foreground text-xs">{{ user.email }}</span>
|
||||
<div class="flex min-w-0 shrink grow flex-col leading-4">
|
||||
<span class="truncate text-sm font-medium">{{ user.name }}</span>
|
||||
<span class="text-muted-foreground truncate text-xs">{{
|
||||
user.email
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="opacity-0 transition-all group-hover/user:opacity-100">
|
||||
<div
|
||||
class="flex justify-end transition-all not-pointer-coarse:opacity-0 not-pointer-coarse:group-hover/user:opacity-100"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { listUsers } from '~/lib/api/admin/listUsers';
|
||||
import { fetchAllAdminResources } from '~/lib/api/admin/fetchAll';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
|
||||
await useAsyncData('users', async () => {
|
||||
const response = await listUsers();
|
||||
await useAsyncData('admin-resources', async () => {
|
||||
const response = await fetchAllAdminResources();
|
||||
|
||||
if (response.success) {
|
||||
adminStore.users = response.users;
|
||||
adminStore.resources = response.data;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -15,6 +15,7 @@ await useAsyncData('users', async () => {
|
||||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<AdminCreateUserDialog />
|
||||
<AdminEditUserDialog />
|
||||
<AdminDeleteUserDialog />
|
||||
<slot />
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -14,27 +14,24 @@ store.warrens = await getWarrens();
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<main class="flex w-full grow flex-col overflow-hidden">
|
||||
<main
|
||||
class="flex w-full grow flex-col-reverse overflow-hidden md:flex-col"
|
||||
>
|
||||
<header
|
||||
class="flex h-16 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"
|
||||
class="flex h-16 items-center gap-2 border-t transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 md:border-t-0 md:border-b"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<div class="flex w-full items-center gap-4 px-4">
|
||||
<SidebarTrigger class="[&_svg]:size-4" />
|
||||
<Separator orientation="vertical" class="mr-2 !h-4" />
|
||||
<AppBreadcrumbs />
|
||||
<div class="hidden flex-row items-center gap-4 md:flex">
|
||||
<Separator orientation="vertical" class="mr-2 !h-4" />
|
||||
<AppBreadcrumbs />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="ml-auto flex flex-row-reverse items-center gap-2"
|
||||
>
|
||||
<CreateDirectoryDialog>
|
||||
<Button
|
||||
v-if="route.path.startsWith('/warrens/')"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Icon name="lucide:folder-plus" />
|
||||
</Button>
|
||||
</CreateDirectoryDialog>
|
||||
<div class="ml-auto flex flex-row items-center gap-2">
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="mr-2 hidden !h-4 md:block"
|
||||
/>
|
||||
<UploadDialog>
|
||||
<Button
|
||||
class="relative"
|
||||
@@ -48,11 +45,20 @@ store.warrens = await getWarrens();
|
||||
></div>
|
||||
</Button>
|
||||
</UploadDialog>
|
||||
<CreateDirectoryDialog>
|
||||
<Button
|
||||
v-if="route.path.startsWith('/warrens/')"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Icon name="lucide:folder-plus" />
|
||||
</Button>
|
||||
</CreateDirectoryDialog>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 flex-col p-4 pt-0">
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function createUser(
|
||||
};
|
||||
}
|
||||
|
||||
await refreshNuxtData('users');
|
||||
await refreshNuxtData('admin-resources');
|
||||
|
||||
toast.success('Create user', {
|
||||
description: 'Successfully created user',
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function deleteUser(
|
||||
};
|
||||
}
|
||||
|
||||
await refreshNuxtData('users');
|
||||
await refreshNuxtData('admin-resources');
|
||||
|
||||
toast.success('Delete user', {
|
||||
description: 'Successfully delete user',
|
||||
|
||||
46
frontend/lib/api/admin/editUser.ts
Normal file
46
frontend/lib/api/admin/editUser.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { toast } from 'vue-sonner';
|
||||
import type { ApiResponse } from '#shared/types/api';
|
||||
import type { AuthUser } from '#shared/types/auth';
|
||||
import { getApiHeaders } from '..';
|
||||
|
||||
/** Admin function to edit an existing user */
|
||||
export async function editUser(
|
||||
user: AuthUser & { password: string | null }
|
||||
): Promise<{ success: true; user: AuthUser } | { success: false }> {
|
||||
const { data, error } = await useFetch<ApiResponse<AuthUser>>(
|
||||
getApiUrl('admin/users'),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
admin: user.admin,
|
||||
}),
|
||||
responseType: 'json',
|
||||
}
|
||||
);
|
||||
|
||||
if (data.value == null) {
|
||||
toast.error('Edit user', {
|
||||
description: error.value?.data ?? 'Failed to edit user',
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
await refreshNuxtData('admin-resources');
|
||||
|
||||
toast.success('Edit user', {
|
||||
description: 'Successfully edited user',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: data.value.data,
|
||||
};
|
||||
}
|
||||
56
frontend/lib/api/admin/fetchAll.ts
Normal file
56
frontend/lib/api/admin/fetchAll.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ApiResponse } from '~/shared/types/api';
|
||||
import type { UserWarren, Warren } from '~/shared/types/warrens';
|
||||
import { getApiHeaders } from '..';
|
||||
import type { AdminResources, AuthUserWithWarrens } from '~/shared/types/admin';
|
||||
import type { AuthUser } from '~/shared/types/auth';
|
||||
|
||||
export async function fetchAllAdminResources(): Promise<
|
||||
| {
|
||||
success: true;
|
||||
data: AdminResources;
|
||||
}
|
||||
| { success: false }
|
||||
> {
|
||||
const { data } = await useFetch<
|
||||
ApiResponse<{
|
||||
users: AuthUser[];
|
||||
userWarrens: UserWarren[];
|
||||
warrens: Warren[];
|
||||
}>
|
||||
>(getApiUrl('admin/all'), {
|
||||
method: 'GET',
|
||||
headers: getApiHeaders(),
|
||||
responseType: 'json',
|
||||
deep: false,
|
||||
});
|
||||
|
||||
if (data.value == null) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const users: Record<string, AuthUserWithWarrens> = data.value.data.users
|
||||
.map((u) => ({
|
||||
...u,
|
||||
warrens: [],
|
||||
}))
|
||||
.reduce((acc, u) => ({ ...acc, [u.id]: u }), {});
|
||||
const warrens: Record<string, Warren> = {};
|
||||
|
||||
for (const warren of data.value.data.warrens) {
|
||||
warrens[warren.id] = warren;
|
||||
}
|
||||
|
||||
for (const userWarren of data.value.data.userWarrens) {
|
||||
users[userWarren.userId].warrens.push(userWarren);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: Object.values(users),
|
||||
warrens,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
export function getAuthHeader(): ['authorization', string] | null {
|
||||
const authSession = useAuthSession().value;
|
||||
if (authSession == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['authorization', `${authSession.type} ${authSession.id}`];
|
||||
}
|
||||
export function getApiHeaders(
|
||||
includeAuth: boolean = true
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (includeAuth) {
|
||||
const authSession = useAuthSession().value;
|
||||
|
||||
if (authSession != null) {
|
||||
headers['authorization'] = `${authSession.type} ${authSession.id}`;
|
||||
const header = getAuthHeader();
|
||||
if (header != null) {
|
||||
headers[header[0]] = header[1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { toast } from 'vue-sonner';
|
||||
import type { DirectoryEntry } from '#shared/types';
|
||||
import type { ApiResponse } from '#shared/types/api';
|
||||
import type { Warren } from '#shared/types/warrens';
|
||||
import { getApiHeaders } from '.';
|
||||
import { getApiHeaders, getAuthHeader } from '.';
|
||||
|
||||
export async function getWarrens(): Promise<Record<string, Warren>> {
|
||||
const { data, error } = await useFetch<ApiResponse<{ warrens: Warren[] }>>(
|
||||
@@ -201,9 +201,9 @@ export async function uploadToWarren(
|
||||
body.append('files', file);
|
||||
}
|
||||
|
||||
const headers = getApiHeaders();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
const header = getAuthHeader();
|
||||
if (header != null) {
|
||||
xhr.setRequestHeader(header[0], header[1]);
|
||||
}
|
||||
|
||||
xhr.send(body);
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import z from 'zod';
|
||||
import { boolean, object, string } from 'yup';
|
||||
import { registerSchema } from './auth';
|
||||
|
||||
export const createUserSchema = registerSchema.extend({
|
||||
admin: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.prefault(() => false),
|
||||
export const createUserSchema = registerSchema.concat(
|
||||
object({
|
||||
admin: boolean().default(false),
|
||||
})
|
||||
);
|
||||
export const editUserSchema = object({
|
||||
name: string().trim().min(1).required('required'),
|
||||
email: string().email().trim().required('required'),
|
||||
password: string()
|
||||
.trim()
|
||||
.min(12)
|
||||
.max(32)
|
||||
.transform((s: string) => (s.length > 0 ? s : undefined))
|
||||
.optional(),
|
||||
admin: boolean().required('required'),
|
||||
});
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import z from 'zod';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
export const registerSchema = z.object({
|
||||
name: z.string('This field is required').trim().min(1),
|
||||
email: z
|
||||
.email({
|
||||
error: 'This field is required',
|
||||
pattern: z.regexes.rfc5322Email,
|
||||
})
|
||||
.trim(),
|
||||
password: z.string('This field is required').trim().min(12).max(32),
|
||||
export const registerSchema = object({
|
||||
name: string().trim().min(1).required('required'),
|
||||
email: string().trim().email('Expected a valid email').required('required'),
|
||||
password: string().trim().min(12).max(32).required('required'),
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z
|
||||
.email({
|
||||
error: 'This field is required',
|
||||
pattern: z.regexes.rfc5322Email,
|
||||
})
|
||||
.trim(),
|
||||
export const loginSchema = object({
|
||||
email: string().trim().email('Expected a valid email').required('required'),
|
||||
// Don't include the min and max here to let bad actors waste their time
|
||||
password: z.string('This field is required').trim(),
|
||||
password: string().trim().required('required'),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-05-15',
|
||||
devtools: { enabled: true },
|
||||
devtools: { enabled: false },
|
||||
|
||||
modules: [
|
||||
'@nuxt/eslint',
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@nuxt/test-utils": "3.19.2",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@vee-validate/yup": "^4.15.1",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"byte-size": "^9.0.1",
|
||||
@@ -36,7 +37,7 @@
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-sonner": "^2.0.1",
|
||||
"zod": "^4.0.5"
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.57",
|
||||
|
||||
@@ -9,8 +9,8 @@ const adminStore = useAdminStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<Card class="overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
><NuxtLink to="/admin/users">Users</NuxtLink></CardTitle
|
||||
@@ -20,16 +20,26 @@ const adminStore = useAdminStore();
|
||||
warrens</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="max-h-64">
|
||||
<ScrollArea class="h-full">
|
||||
<div class="flex flex-col gap-4">
|
||||
<CardContent class="max-h-64 overflow-hidden">
|
||||
<ScrollArea class="h-full w-full overflow-hidden">
|
||||
<div class="flex w-full flex-col gap-2 overflow-hidden">
|
||||
<AdminUserListing
|
||||
v-for="user in adminStore.users"
|
||||
v-for="user in adminStore.resources.users"
|
||||
:key="user.id"
|
||||
:user
|
||||
class="group/user flex flex-row items-center justify-between gap-4"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
class="m-1"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@click="
|
||||
() =>
|
||||
adminStore.openEditUserDialog(user)
|
||||
"
|
||||
>
|
||||
<Icon name="lucide:pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
class="m-1"
|
||||
variant="destructive"
|
||||
@@ -50,9 +60,9 @@ const adminStore = useAdminStore();
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div class="mt-4 flex flex-row">
|
||||
<div class="mt-4 flex grow flex-row justify-end">
|
||||
<Button @click="adminStore.openCreateUserDialog"
|
||||
>Create user</Button
|
||||
>Create</Button
|
||||
>
|
||||
</div>
|
||||
</CardFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['is-admin'],
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['is-admin'],
|
||||
});
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p>/admin/users</p>
|
||||
<div class="flex h-full w-full">
|
||||
<ScrollArea class="h-full grow">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<AdminUserListing
|
||||
v-for="user in adminStore.resources.users"
|
||||
:key="user.id"
|
||||
:user
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['is-admin'],
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import { toTypedSchema } from '@vee-validate/yup';
|
||||
import { useForm } from 'vee-validate';
|
||||
import type z from 'zod';
|
||||
import { loginUser } from '~/lib/api/auth/login';
|
||||
import { loginSchema } from '~/lib/schemas/auth';
|
||||
|
||||
@@ -29,23 +28,21 @@ const form = useForm({
|
||||
validationSchema: toTypedSchema(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(
|
||||
async (values: z.output<typeof loginSchema>) => {
|
||||
if (loggingIn.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loggingIn.value = true;
|
||||
|
||||
const { success } = await loginUser(values.email, values.password);
|
||||
|
||||
if (success) {
|
||||
await navigateTo({ path: '/' });
|
||||
}
|
||||
|
||||
loggingIn.value = false;
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (loggingIn.value) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
loggingIn.value = true;
|
||||
|
||||
const { success } = await loginUser(values.email, values.password);
|
||||
|
||||
if (success) {
|
||||
await navigateTo({ path: '/' });
|
||||
}
|
||||
|
||||
loggingIn.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,8 +83,8 @@ const onSubmit = form.handleSubmit(
|
||||
autocomplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<FormMessage />
|
||||
</FormField>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import { toTypedSchema } from '@vee-validate/yup';
|
||||
import { useForm } from 'vee-validate';
|
||||
import type z from 'zod';
|
||||
import { registerUser } from '~/lib/api/auth/register';
|
||||
import { registerSchema } from '~/lib/schemas/auth';
|
||||
|
||||
@@ -27,24 +26,22 @@ const form = useForm({
|
||||
validationSchema: toTypedSchema(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(
|
||||
async (values: z.output<typeof registerSchema>) => {
|
||||
registering.value = true;
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
registering.value = true;
|
||||
|
||||
const { success } = await registerUser(
|
||||
values.name,
|
||||
values.email,
|
||||
values.password
|
||||
);
|
||||
const { success } = await registerUser(
|
||||
values.name,
|
||||
values.email,
|
||||
values.password
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await navigateTo({ path: '/login' });
|
||||
return;
|
||||
}
|
||||
|
||||
registering.value = false;
|
||||
if (success) {
|
||||
await navigateTo({ path: '/login' });
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
registering.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -71,8 +68,8 @@ const onSubmit = form.handleSubmit(
|
||||
autocomplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<FormMessage />
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
@@ -102,8 +99,8 @@ const onSubmit = form.handleSubmit(
|
||||
autocomplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<FormMessage />
|
||||
</FormField>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
11
frontend/shared/types/admin.ts
Normal file
11
frontend/shared/types/admin.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { AuthUser } from './auth';
|
||||
import type { UserWarren, Warren } from './warrens';
|
||||
|
||||
export type AdminResources = {
|
||||
users: AuthUserWithWarrens[];
|
||||
warrens: Record<string, Warren>;
|
||||
};
|
||||
|
||||
export type AuthUserWithWarrens = AuthUser & {
|
||||
warrens: UserWarren[];
|
||||
};
|
||||
@@ -2,3 +2,14 @@ export type Warren = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UserWarren = {
|
||||
userId: string;
|
||||
warrenId: string;
|
||||
canCreateChildren: boolean;
|
||||
canListFiles: boolean;
|
||||
canReadFiles: boolean;
|
||||
canModifyFiles: boolean;
|
||||
canDeleteFiles: boolean;
|
||||
canDeleteWarren: boolean;
|
||||
};
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import type { AuthUser, AuthUserFields } from '#shared/types/auth';
|
||||
import type { AuthUser } from '#shared/types/auth';
|
||||
import type { AdminResources } from '~/shared/types/admin';
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
users: [] as AuthUser[],
|
||||
createUserDialog: null as { user: AuthUserFields } | null,
|
||||
resources: {
|
||||
users: [],
|
||||
warrens: {},
|
||||
} as AdminResources,
|
||||
createUserDialogOpen: false,
|
||||
editUserDialog: null as { user: AuthUser } | null,
|
||||
deleteUserDialog: null as { user: AuthUser } | null,
|
||||
}),
|
||||
actions: {
|
||||
openCreateUserDialog() {
|
||||
this.createUserDialog = {
|
||||
user: {
|
||||
name: '',
|
||||
email: '',
|
||||
admin: false,
|
||||
},
|
||||
};
|
||||
this.createUserDialogOpen = true;
|
||||
},
|
||||
closeCreateUserDialog() {
|
||||
this.createUserDialog = null;
|
||||
this.createUserDialogOpen = false;
|
||||
},
|
||||
openEditUserDialog(user: AuthUser) {
|
||||
this.editUserDialog = { user };
|
||||
},
|
||||
closeEditUserDialog() {
|
||||
this.editUserDialog = null;
|
||||
},
|
||||
openDeleteUserDialog(user: AuthUser) {
|
||||
this.deleteUserDialog = {
|
||||
user: user,
|
||||
};
|
||||
},
|
||||
clearDeleteUserDialog() {
|
||||
closeDeleteUserDialog() {
|
||||
this.deleteUserDialog = null;
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user