edit users

This commit is contained in:
2025-07-21 09:37:53 +02:00
parent 6e0880eb3d
commit 50e066f794
46 changed files with 1284 additions and 232 deletions

View File

@@ -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=="],

View File

@@ -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>

View File

@@ -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>

View File

@@ -35,7 +35,7 @@ adminStore.$subscribe(async (_mutation, state) => {
function close() {
confirmEmail.value = '';
adminStore.clearDeleteUserDialog();
adminStore.closeDeleteUserDialog();
}
async function submit() {

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -32,7 +32,7 @@ export async function createUser(
};
}
await refreshNuxtData('users');
await refreshNuxtData('admin-resources');
toast.success('Create user', {
description: 'Successfully created user',

View File

@@ -29,7 +29,7 @@ export async function deleteUser(
};
}
await refreshNuxtData('users');
await refreshNuxtData('admin-resources');
toast.success('Delete user', {
description: 'Successfully delete user',

View 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,
};
}

View 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,
},
};
}

View File

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

View File

@@ -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);

View File

@@ -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'),
});

View File

@@ -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'),
});

View File

@@ -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',

View File

@@ -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",

View File

@@ -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>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: ['is-admin'],
});
</script>

View File

@@ -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>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: ['is-admin'],
});
</script>

View File

@@ -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>

View File

@@ -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>

View 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[];
};

View File

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

View File

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