create users through admin page

This commit is contained in:
2025-07-19 22:18:49 +02:00
parent deff81d2ff
commit 7f2aac12e6
65 changed files with 1394 additions and 139 deletions

View File

@@ -61,6 +61,9 @@ async function selectWarren(id: string) {
<SidebarMenuSubButton
:tooltip="warren.name"
:is-active="
route.path.startsWith(
'/warrens/files'
) &&
store.current != null &&
store.current.warrenId === id
"
@@ -78,6 +81,7 @@ async function selectWarren(id: string) {
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarAdminMenu v-if="session != null && session.user.admin" />
<SidebarUser v-if="session != null" :user="session.user" />
</SidebarFooter>
</Sidebar>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import {
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from '@/components/ui/sidebar';
const route = useRoute();
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<NuxtLink to="/admin" as-child>
<SidebarMenuButton
:is-active="route.path === '/admin'"
tooltip="Admin"
>
<Icon name="lucide:settings" />
<span>Administration</span>
</SidebarMenuButton>
</NuxtLink>
<SidebarMenuSub>
<SidebarMenuSubItem>
<NuxtLink to="/admin/users" as-child>
<SidebarMenuSubButton
:is-active="route.path === '/admin/users'"
>
<Icon name="lucide:users" />
<span>Users</span>
</SidebarMenuSubButton>
</NuxtLink>
<NuxtLink to="/admin/warrens" as-child>
<SidebarMenuSubButton
:is-active="route.path === '/admin/warrens'"
>
<Icon name="lucide:folder-tree" />
<span>Warrens</span>
</SidebarMenuSubButton>
</NuxtLink>
<!-- <NuxtLink to="/admin/stats" as-child>
<SidebarMenuSubButton
:is-active="route.path === '/admin/stats'"
>
<Icon name="lucide:bar-chart-3" />
<span>Stats</span>
</SidebarMenuSubButton>
</NuxtLink> -->
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuItem>
</SidebarMenu>
</template>

View File

@@ -32,7 +32,7 @@ const AVATAR =
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
tooltip="Settings"
tooltip="User"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="AVATAR" />

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
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';
const adminStore = useAdminStore();
const creating = ref(false);
function cancel() {
adminStore.closeCreateUserDialog();
}
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();
}
}
);
</script>
<template>
<Dialog :open="adminStore.createUserDialog != null">
<DialogTrigger><slot /></DialogTrigger>
<DialogContent @escape-key-down="cancel">
<DialogHeader>
<DialogTitle>Create user</DialogTitle>
<DialogDescription>
Enter a username, email and password to create a new user
</DialogDescription>
</DialogHeader>
<form
id="create-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>
</FormItem>
<FormMessage />
</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>
</FormItem>
<FormMessage />
</FormField>
<FormField v-slot="{ value, handleChange }" name="admin">
<FormItem>
<FormLabel>Admin</FormLabel>
<FormControl>
<Switch
id="admin"
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
</FormItem>
<FormMessage />
</FormField>
</form>
<DialogFooter>
<Button variant="outline" @click="cancel">Cancel</Button>
<Button type="submit" form="create-user-form">Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,90 @@
<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 type { AuthUser } from '~/types/auth';
const adminStore = useAdminStore();
// We'll only update this value if there is a user to prevent layout shifts on close
const user = ref<AuthUser>();
const confirmEmailInput = ref<InstanceType<typeof Input>>();
const confirmEmail = ref<string>('');
const emailMatches = computed(
() => user.value != null && user.value.email === confirmEmail.value
);
adminStore.$subscribe(async (_mutation, state) => {
if (state.deleteUserDialog != null) {
user.value = state.deleteUserDialog.user;
setTimeout(() => confirmEmailInput.value?.domRef?.focus(), 25);
}
});
function cancel() {
adminStore.clearDeleteUserDialog();
}
function submit() {}
</script>
<template>
<AlertDialog :open="adminStore.deleteUserDialog != null">
<AlertDialogTrigger as-child>
<slot />
</AlertDialogTrigger>
<AlertDialogContent @escape-key-down="cancel">
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription class="space-y-1">
<p ref="test">
This action cannot be undone. This will permanently
delete the user and remove their data from the database
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlterDialogContent v-if="user != null">
<div class="flex flex-col gap-4">
<AdminUserListing :user />
<div class="flex flex-col gap-1">
<p
:class="[
'tight text-sm',
emailMatches
? 'text-muted-foreground'
: 'text-destructive-foreground',
]"
>
Enter their email address to continue
</p>
<Input
ref="confirmEmailInput"
v-model="confirmEmail"
type="text"
:placeholder="user.email"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</div>
</div>
</AlterDialogContent>
<AlertDialogFooter>
<AlertDialogCancel @click="cancel">Cancel</AlertDialogCancel>
<AlertDialogAction :disabled="!emailMatches" @click="submit"
>Delete</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { AuthUser } from '~/types/auth';
const { user } = defineProps<{
user: AuthUser;
}>();
// TODO: Remove once this is a field on the users
const AVATAR =
'https://cdn.discordapp.com/avatars/285424924049276939/0368b00056c416cae689ab1434c0aac0.webp';
</script>
<template>
<div class="group/user flex flex-row items-center justify-between gap-4">
<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>
<div class="opacity-0 transition-all group-hover/user:opacity-100">
<slot name="actions" />
</div>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogContent,
type AlertDialogContentEmits,
type AlertDialogContentProps,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { AlertDialogTrigger, type AlertDialogTriggerProps } from 'reka-ui'
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View File

@@ -0,0 +1,9 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
data-slot="checkbox"
v-bind="forwarded"
:class="
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="flex items-center justify-center text-current transition-none"
>
<slot>
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { Slot } from 'reka-ui'
import { useFormField } from './useFormField'
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
</script>
<template>
<Slot
:id="formItemId"
data-slot="form-control"
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useFormField } from './useFormField'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { formDescriptionId } = useFormField()
</script>
<template>
<p
:id="formDescriptionId"
data-slot="form-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { useId } from 'reka-ui'
import { type HTMLAttributes, provide } from 'vue'
import { cn } from '@/lib/utils'
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const id = useId()
provide(FORM_ITEM_INJECTION_KEY, id)
</script>
<template>
<div
data-slot="form-item"
:class="cn('grid gap-2', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { useFormField } from './useFormField'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const { error, formItemId } = useFormField()
</script>
<template>
<Label
data-slot="form-label"
:data-error="!!error"
:class="cn(
'data-[error=true]:text-destructive-foreground',
props.class,
)"
:for="formItemId"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { ErrorMessage } from 'vee-validate'
import { type HTMLAttributes, toValue } from 'vue'
import { cn } from '@/lib/utils'
import { useFormField } from './useFormField'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { name, formMessageId } = useFormField()
</script>
<template>
<ErrorMessage
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive-foreground text-sm', props.class)"
/>
</template>

View File

@@ -0,0 +1,7 @@
export { default as FormControl } from './FormControl.vue'
export { default as FormDescription } from './FormDescription.vue'
export { default as FormItem } from './FormItem.vue'
export { default as FormLabel } from './FormLabel.vue'
export { default as FormMessage } from './FormMessage.vue'
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate'

View File

@@ -0,0 +1,4 @@
import type { InjectionKey } from 'vue'
export const FORM_ITEM_INJECTION_KEY
= Symbol() as InjectionKey<string>

View File

@@ -0,0 +1,30 @@
import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from 'vee-validate'
import { inject } from 'vue'
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
export function useFormField() {
const fieldContext = inject(FieldContextKey)
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
if (!fieldContext)
throw new Error('useFormField should be used within <FormField>')
const { name } = fieldContext
const id = fieldItemContext
const fieldState = {
valid: useIsFieldValid(name),
isDirty: useIsFieldDirty(name),
isTouched: useIsFieldTouched(name),
error: useFieldError(name),
}
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}

View File

@@ -3,6 +3,12 @@ import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const domRef = ref<HTMLInputElement>();
defineExpose({
domRef,
});
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
@@ -21,6 +27,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
<template>
<input
ref="domRef"
v-model="modelValue"
data-slot="input"
:class="cn(

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label, type LabelProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
SwitchRoot,
type SwitchRootEmits,
type SwitchRootProps,
SwitchThumb,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
data-slot="switch"
v-bind="forwarded"
:class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
>
<slot name="thumb" />
</SwitchThumb>
</SwitchRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Switch } from './Switch.vue'