warren creation / edit / deletion

This commit is contained in:
2025-07-22 22:01:43 +02:00
parent 2ed69ae498
commit b3e68deb38
27 changed files with 1345 additions and 25 deletions

View File

@@ -45,10 +45,16 @@ function onKeyDown(e: KeyboardEvent) {
submit();
}
}
function onOpenChange(state: boolean) {
if (!state) {
dialog.reset();
}
}
</script>
<template>
<Dialog v-model:open="dialog.open">
<Dialog v-model:open="dialog.open" @update:open="onOpenChange">
<DialogTrigger as-child>
<slot />
</DialogTrigger>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/yup';
import { useForm } from 'vee-validate';
import { createWarren } from '~/lib/api/admin/createWarren';
import { createWarrenSchema } from '~/lib/schemas/admin';
const adminStore = useAdminStore();
const creating = ref(false);
function close() {
adminStore.closeCreateWarrenDialog();
form.resetForm();
}
const form = useForm({
validationSchema: toTypedSchema(createWarrenSchema),
});
const onSubmit = form.handleSubmit(async (values) => {
if (creating.value) {
return;
}
creating.value = true;
const result = await createWarren(values.name, values.path);
creating.value = false;
if (result.success) {
close();
}
});
</script>
<template>
<AlertDialog :open="adminStore.createWarrenDialogOpen">
<AlertDialogTrigger><slot /></AlertDialogTrigger>
<AlertDialogContent @escape-key-down="close">
<AlertDialogHeader>
<AlertDialogTitle>Create warren</AlertDialogTitle>
<AlertDialogDescription>
Enter a name and an absolute file path to create a new
warren
</AlertDialogDescription>
</AlertDialogHeader>
<form
id="create-warren-form"
class="flex flex-col gap-2"
@submit.prevent="onSubmit"
>
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
v-bind="componentField"
id="name"
type="text"
placeholder="my-warren"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="path">
<FormItem>
<FormLabel>File path</FormLabel>
<FormControl>
<Input
v-bind="componentField"
id="path"
type="text"
placeholder="/mywarren"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
<AlertDialogFooter class="gap-y-0">
<AlertDialogCancel variant="outline" @click="close">
Cancel
</AlertDialogCancel>
<AlertDialogAction
type="submit"
form="create-warren-form"
:disabled="creating"
>Create</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -64,7 +64,7 @@ async function submit() {
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription class="space-y-1">
<p ref="test">
<p>
This action cannot be undone. This will permanently
delete the user and remove their data from the database
</p>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { deleteWarren } from '~/lib/api/admin/deleteWarren';
import type { AdminWarrenData } from '~/shared/types/warrens';
const adminStore = useAdminStore();
// We'll only update this value if there is a warren to prevent layout shifts on close
const warren = ref<AdminWarrenData>();
const deleting = ref(false);
const confirmPath = ref<string>('');
const confirmPathInput = ref<InstanceType<typeof Input>>();
const pathMatches = computed(
() => warren.value != null && warren.value.path === confirmPath.value
);
adminStore.$subscribe(async (_mutation, state) => {
if (state.deleteWarrenDialog != null) {
warren.value = state.deleteWarrenDialog?.warren;
setTimeout(() => confirmPathInput.value?.domRef?.focus(), 25);
}
});
function close() {
confirmPath.value = '';
adminStore.closeDeleteWarrenDialog();
}
async function submit() {
if (deleting.value || adminStore.deleteWarrenDialog == null) {
return;
}
deleting.value = true;
const { success } = await deleteWarren(
adminStore.deleteWarrenDialog.warren.id
);
if (success) {
close();
}
deleting.value = false;
}
</script>
<template>
<AlertDialog :open="adminStore.deleteWarrenDialog != null">
<AlertDialogTrigger as-child>
<slot />
</AlertDialogTrigger>
<AlertDialogContent @escape-key-down="close">
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription class="space-y-1">
<p>
This action cannot be undone. This will permanently
delete the warren. The contained files will be left
unchanged.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlterDialogContent v-if="warren != null">
<div class="flex flex-col gap-4">
<AdminWarrenListing :warren />
<div class="flex flex-col gap-1">
<p
:class="[
'tight text-sm',
pathMatches
? 'text-muted-foreground'
: 'text-destructive-foreground',
]"
>
Enter the warren's path to continue
</p>
<Input
ref="confirmPathInput"
v-model="confirmPath"
type="text"
:placeholder="warren.path"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</div>
</div>
</AlterDialogContent>
<AlertDialogFooter class="gap-y-0">
<AlertDialogCancel @click="close">Cancel</AlertDialogCancel>
<AlertDialogAction
:disabled="!pathMatches || deleting"
@click="submit"
>Delete</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -0,0 +1,158 @@
<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 { editWarrenSchema } from '~/lib/schemas/admin';
import { editWarren } from '~/lib/api/admin/editWarren';
import type { AdminWarrenData } from '~/shared/types/warrens';
const adminStore = useAdminStore();
const isValid = computed(() => Object.keys(form.errors.value).length < 1);
// We'll only update this value if there is a warren to prevent layout shifts on close
const warren = ref<AdminWarrenData>();
const editing = ref(false);
const isChanged = computed(() => {
if (warren.value == null) {
return false;
}
try {
const values = editWarrenSchema.validateSync(
form.controlledValues.value
);
return (
values.name !== warren.value.name ||
values.path !== warren.value.path
);
} catch {
return true;
}
});
function close() {
adminStore.closeEditWarrenDialog();
}
const form = useForm({
validationSchema: toTypedSchema(editWarrenSchema),
});
adminStore.$subscribe((_mutation, state) => {
if (state.editWarrenDialog != null && !editing.value) {
warren.value = Object.values(state.resources.warrens).find(
(w) => w.id === state.editWarrenDialog?.warren.id
);
if (warren.value != null) {
form.setValues(warren.value);
}
}
});
const onSubmit = form.handleSubmit(async (values) => {
if (warren.value == null || !isChanged.value || editing.value) {
return;
}
editing.value = true;
const result = await editWarren(warren.value.id, values.name, values.path);
if (result.success) {
const newWarren: AdminWarrenData = {
id: result.warren.id,
name: result.warren.name,
path: result.warren.path,
};
adminStore.openEditWarrenDialog(newWarren);
}
editing.value = false;
});
</script>
<template>
<AlertDialog :open="adminStore.editWarrenDialog != null">
<AlertDialogTrigger><slot /></AlertDialogTrigger>
<AlertDialogContent
v-if="warren != null"
class="flex max-h-[95vh] flex-col"
@escape-key-down="close"
>
<AlertDialogHeader class="shrink">
<AlertDialogTitle>Edit warren</AlertDialogTitle>
<AlertDialogDescription>
Modify the warren's name and path
</AlertDialogDescription>
</AlertDialogHeader>
<form
id="edit-warren-form"
class="flex flex-col gap-2"
@submit.prevent="onSubmit"
>
<input type="hidden" name="id" :value="warren.id" />
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
v-bind="componentField"
id="name"
type="text"
placeholder="my-warren"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="path">
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input
v-bind="componentField"
id="path"
type="text"
placeholder="/mywarren"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
<AlertDialogFooter class="gap-y-0">
<AlertDialogCancel @click="close">Close</AlertDialogCancel>
<AlertDialogAction
type="submit"
form="edit-warren-form"
:disabled="!isChanged || !isValid"
>Save</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -17,6 +17,11 @@ await useAsyncData('admin-resources', async () => {
<AdminCreateUserDialog />
<AdminEditUserDialog />
<AdminDeleteUserDialog />
<AdminCreateWarrenDialog />
<AdminEditWarrenDialog />
<AdminDeleteWarrenDialog />
<slot />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,43 @@
import type { ApiResponse } from '~/shared/types/api';
import type { AdminWarrenData } from '~/shared/types/warrens';
import { getApiHeaders } from '..';
import { toast } from 'vue-sonner';
export async function createWarren(
name: string,
path: string
): Promise<{ success: true; warren: AdminWarrenData } | { success: false }> {
const { data, error } = await useFetch<ApiResponse<AdminWarrenData>>(
getApiUrl('admin/warrens'),
{
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
name: name,
path: path,
}),
responseType: 'json',
}
);
if (data.value == null) {
toast.error('Create warren', {
description: error.value?.data ?? 'Failed to create warren',
});
return {
success: false,
};
}
await refreshNuxtData('admin-resources');
toast.success('Create warren', {
description: 'Successfully created warren',
});
return {
success: true,
warren: data.value.data,
};
}

View File

@@ -0,0 +1,41 @@
import type { ApiResponse } from '~/shared/types/api';
import type { AdminWarrenData } from '~/shared/types/warrens';
import { getApiHeaders } from '..';
import { toast } from 'vue-sonner';
export async function deleteWarren(
warrenId: string
): Promise<{ success: true; warren: AdminWarrenData } | { success: false }> {
const { data, error } = await useFetch<ApiResponse<AdminWarrenData>>(
getApiUrl('admin/warrens'),
{
method: 'DELETE',
headers: getApiHeaders(),
body: JSON.stringify({
id: warrenId,
}),
responseType: 'json',
}
);
if (data.value == null) {
toast.error('Delete warren', {
description: error.value?.data ?? 'Failed to delete warren',
});
return {
success: false,
};
}
await refreshNuxtData(['warrens', 'admin-resources']);
toast.success('Delete warren', {
description: 'Successfully deleted warren',
});
return {
success: true,
warren: data.value.data,
};
}

View File

@@ -0,0 +1,45 @@
import type { ApiResponse } from '~/shared/types/api';
import type { AdminWarrenData } from '~/shared/types/warrens';
import { getApiHeaders } from '..';
import { toast } from 'vue-sonner';
export async function editWarren(
warrenId: string,
name: string,
path: string
): Promise<{ success: true; warren: AdminWarrenData } | { success: false }> {
const { data, error } = await useFetch<ApiResponse<AdminWarrenData>>(
getApiUrl('admin/warrens'),
{
method: 'PATCH',
headers: getApiHeaders(),
body: JSON.stringify({
id: warrenId,
name: name,
path: path,
}),
responseType: 'json',
}
);
if (data.value == null) {
toast.error('Edit warren', {
description: error.value?.data ?? 'Failed to edit warren',
});
return {
success: false,
};
}
await refreshNuxtData('admin-resources');
toast.success('Edit warren', {
description: 'Successfully editd warren',
});
return {
success: true,
warren: data.value.data,
};
}

View File

@@ -1,5 +1,6 @@
export async function logout() {
useAuthSession().value = null;
useAdminStore().$reset();
await navigateTo({
path: '/login',
});

View File

@@ -26,3 +26,13 @@ export const userWarrenSchema = object({
canModifyFiles: boolean().required(),
canDeleteFiles: boolean().required(),
});
export const createWarrenSchema = object({
name: string().min(1).required('required'),
path: string().min(1).required('required'),
});
export const editWarrenSchema = object({
name: string().min(1).required('required'),
path: string().min(1).required('required'),
});

View File

@@ -15,7 +15,7 @@ const adminStore = useAdminStore();
<CardTitle>Users</CardTitle>
<CardDescription>Create or manage users</CardDescription>
</CardHeader>
<CardContent class="max-h-64 overflow-hidden">
<CardContent class="max-h-96 overflow-hidden">
<ScrollArea class="h-full w-full overflow-hidden">
<div class="flex w-full flex-col gap-2 overflow-hidden">
<AdminUserListing
@@ -66,7 +66,7 @@ const adminStore = useAdminStore();
<CardTitle>Warrens</CardTitle>
<CardDescription>Create or manage warrens</CardDescription>
</CardHeader>
<CardContent class="max-h-64 grow overflow-hidden">
<CardContent class="max-h-96 grow overflow-hidden">
<ScrollArea class="h-full w-full overflow-hidden">
<div class="flex w-full flex-col gap-2 overflow-hidden">
<AdminWarrenListing
@@ -79,12 +79,24 @@ const adminStore = useAdminStore();
class="m-1"
variant="outline"
size="icon"
@click="
() =>
adminStore.openEditWarrenDialog(
warren
)
"
><Icon name="lucide:pencil"
/></Button>
<Button
class="m-1"
variant="destructive"
size="icon"
@click="
() =>
adminStore.openDeleteWarrenDialog(
warren
)
"
><Icon name="lucide:trash-2"
/></Button>
</template>
@@ -94,7 +106,7 @@ const adminStore = useAdminStore();
</CardContent>
<CardFooter>
<div class="mt-4 flex grow flex-row justify-end">
<Button @click="adminStore.openCreateUserDialog"
<Button @click="adminStore.openCreateWarrenDialog"
>Create</Button
>
</div>

View File

@@ -1,5 +1,6 @@
import type { AuthUser } from '#shared/types/auth';
import type { AdminResources, AuthUserWithWarrens } from '~/shared/types/admin';
import type { AdminWarrenData } from '~/shared/types/warrens';
export const useAdminStore = defineStore('admin', {
state: () => ({
@@ -7,11 +8,37 @@ export const useAdminStore = defineStore('admin', {
users: [],
warrens: {},
} as AdminResources,
createWarrenDialogOpen: false,
editWarrenDialog: null as { warren: AdminWarrenData } | null,
deleteWarrenDialog: null as { warren: AdminWarrenData } | null,
createUserDialogOpen: false,
editUserDialog: null as { user: AuthUserWithWarrens } | null,
deleteUserDialog: null as { user: AuthUser } | null,
}),
actions: {
openCreateWarrenDialog() {
this.createWarrenDialogOpen = true;
},
closeCreateWarrenDialog() {
this.createWarrenDialogOpen = false;
},
openEditWarrenDialog(warren: AdminWarrenData) {
this.editWarrenDialog = {
warren,
};
},
closeEditWarrenDialog() {
this.editWarrenDialog = null;
},
openDeleteWarrenDialog(warren: AdminWarrenData) {
this.deleteWarrenDialog = {
warren,
};
},
closeDeleteWarrenDialog() {
this.deleteWarrenDialog = null;
},
openCreateUserDialog() {
this.createUserDialogOpen = true;
},