refactor: remove pocketbase (#1138)

This commit is contained in:
Jonas Plum
2025-09-02 21:58:08 +02:00
committed by GitHub
parent f28c238135
commit eba2615ec0
435 changed files with 42677 additions and 4730 deletions

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/toast/use-toast'
import UserForm from '@/components/user/UserForm.vue'
import UserGroup from '@/components/user/UserGroup.vue'
import UserPasswordForm from '@/components/user/UserPasswordForm.vue'
import { ChevronLeft } from 'lucide-vue-next'
import CardContent from '../ui/card/CardContent.vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAPI } from '@/api'
import type { User, UserUpdate } from '@/client/models'
import { handleError } from '@/lib/utils'
// Prevent unused var warnings for components used in the template
const _tabsComponents = { Tabs, TabsContent, TabsList, TabsTrigger }
void _tabsComponents
const api = useAPI()
const router = useRouter()
const queryClient = useQueryClient()
const { toast } = useToast()
const passwordForm = ref<InstanceType<typeof UserPasswordForm> | null>(null)
const props = defineProps<{
id: string
}>()
const {
isPending,
isError,
data: user,
error
} = useQuery({
queryKey: ['users', props.id],
queryFn: (): Promise<User> => api.getUser({ id: props.id })
})
const updateUserMutation = useMutation({
mutationFn: (update: UserUpdate) => api.updateUser({ id: props.id, userUpdate: update }),
onSuccess: () => {
toast({
title: 'User updated',
description: 'The user has been updated successfully'
})
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: handleError('Failed to update user')
})
const passwordSubmit = (values: UserUpdate) => {
updateUserMutation.mutate(values, {
onSuccess: () => {
passwordForm.value?.reset()
}
})
}
const deleteMutation = useMutation({
mutationFn: () => api.deleteUser({ id: props.id }),
onSuccess: () => {
queryClient.removeQueries({ queryKey: ['users', props.id] })
queryClient.invalidateQueries({ queryKey: ['users'] })
toast({
title: 'User deleted',
description: 'The user has been deleted successfully'
})
router.push({ name: 'users' })
},
onError: handleError('Failed to delete user')
})
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader>
<Button @click="router.push({ name: 'users' })" variant="outline" class="md:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
<div class="ml-auto">
<DeleteDialog
v-if="user"
:name="user.name ? user.name : user.username"
singular="User"
@delete="deleteMutation.mutate"
/>
</div>
</ColumnHeader>
<ColumnBody v-if="user">
<ColumnBodyContainer>
<div class="flex flex-col gap-4 xl:flex-row">
<div class="flex flex-col gap-4 xl:flex-1">
<Card>
<CardHeader>
<CardTitle>User</CardTitle>
</CardHeader>
<CardContent>
<UserForm :user="user" @submit="updateUserMutation.mutate" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Set Password</CardTitle>
</CardHeader>
<CardContent>
<UserPasswordForm ref="passwordForm" @submit="passwordSubmit" />
</CardContent>
</Card>
</div>
<div class="flex w-full flex-col gap-4 xl:w-96 xl:shrink-0">
<Card>
<CardHeader>
<CardTitle>Access</CardTitle>
</CardHeader>
<CardContent>
<UserGroup :id="user.id" />
</CardContent>
</Card>
</div>
</div>
<!--Tabs default-value="groups" class="w-full">
<TabsList class="grid w-full grid-cols-3">
<TabsTrigger value="user"> User </TabsTrigger>
<TabsTrigger value="password"> Password </TabsTrigger>
<TabsTrigger value="groups"> Groups </TabsTrigger>
</TabsList>
<TabsContent value="user" class="mt-2">
<Card>
<CardHeader>
<CardTitle>User</CardTitle>
</CardHeader>
<CardContent>
<UserForm :user="user" @submit="updateUserMutation.mutate" />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="password" class="mt-2">
<Card>
<CardHeader>
<CardTitle>Set Password</CardTitle>
</CardHeader>
<CardContent>
<UserPasswordForm ref="passwordForm" @submit="passwordSubmit" />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="groups" class="mt-2">
<UserGroup :id="user.id" />
</TabsContent>
</Tabs-->
</ColumnBodyContainer>
</ColumnBody>
</TanView>
</template>

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useQuery } from '@tanstack/vue-query'
import { defineRule, useForm } from 'vee-validate'
import { ref, watch } from 'vue'
import { useAPI } from '@/api'
import type { UserUpdate } from '@/client/models'
const api = useAPI()
const submitDisabledReason = ref<string>('')
const props = defineProps<{
user?: UserUpdate
}>()
const emit = defineEmits(['submit'])
const isDemo = ref(false)
const { data: config } = useQuery({
queryKey: ['config'],
queryFn: () => api.getConfig()
})
watch(
() => config.value,
() => {
if (!config.value) return
if (config.value.flags.includes('demo')) {
isDemo.value = true
}
},
{ immediate: true }
)
defineRule('required', (value: string) => {
if (!value || !value.length) {
return 'This field is required'
}
return true
})
defineRule('username', (value: string) => {
const usernamePattern = /^[a-z0-9_-]{3,72}$/
if (!usernamePattern.test(value)) {
return 'Username must be 3-72 characters long and can only contain lowercase letters, numbers, underscores, and hyphens'
}
return true
})
defineRule('email', (value: string) => {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailPattern.test(value)) {
return 'Please enter a valid email address'
}
return true
})
const { handleSubmit, validate, values } = useForm({
initialValues: props.user || {
username: '',
avatar: '',
email: '',
name: '',
active: false
},
validationSchema: {
username: 'username',
email: 'email',
name: 'required'
}
})
const equalUser = (values: UserUpdate, user?: UserUpdate): boolean => {
if (!user) return false
return (
user.username === values.username &&
user.avatar === values.avatar &&
user.email === values.email &&
user.name === values.name &&
user.active === values.active
)
}
const updateSubmitDisabledReason = () => {
if (props.user?.username === 'system') {
submitDisabledReason.value = 'The system user cannot be modified'
return
}
if (isDemo.value) {
submitDisabledReason.value = 'Users cannot be created or edited in demo mode'
return
}
if (equalUser(values, props.user)) {
submitDisabledReason.value = 'Make changes to save'
return
}
validate({ mode: 'silent' }).then((res) => {
if (res.valid) {
submitDisabledReason.value = ''
} else {
submitDisabledReason.value = 'Please fix the errors'
}
})
}
watch(
() => isDemo.value,
() => updateSubmitDisabledReason()
)
watch(
() => props.user,
() => updateSubmitDisabledReason(),
{ immediate: true }
)
watch(
() => values,
() => updateSubmitDisabledReason(),
{ deep: true, immediate: true }
)
const onSubmit = handleSubmit((values) => emit('submit', values))
</script>
<template>
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
<FormField name="name" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="name" class="text-right">Name</FormLabel>
<Input id="name" class="col-span-3" v-bind="componentField" />
<FormMessage />
</FormItem>
</FormField>
<FormField name="username" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="username" class="text-right">Username</FormLabel>
<Input id="username" class="col-span-3" v-bind="componentField" />
<FormMessage />
</FormItem>
</FormField>
<FormField name="email" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="email" class="text-right">Email</FormLabel>
<Input id="email" type="email" class="col-span-3" v-bind="componentField" />
<FormMessage />
</FormItem>
</FormField>
<FormField name="active" v-slot="{ value, handleChange }">
<FormItem class="w-full items-center gap-2">
<FormLabel>Active</FormLabel>
<div class="flex flex-row items-center gap-2">
<FormControl>
<Switch :checked="value" @update:checked="handleChange" />
</FormControl>
<FormDescription> Check to allow the user to log in. </FormDescription>
</div>
<FormMessage />
</FormItem>
</FormField>
<Alert v-if="props.user?.username === 'system'" variant="destructive">
<AlertTitle>Cannot save</AlertTitle>
<AlertDescription>The system user cannot be modified.</AlertDescription>
</Alert>
<Alert v-else-if="isDemo" variant="destructive">
<AlertTitle>Cannot save</AlertTitle>
<AlertDescription>{{ submitDisabledReason }}</AlertDescription>
</Alert>
<div class="flex gap-4">
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger class="cursor-default">
<Button
type="submit"
:variant="submitDisabledReason !== '' ? 'secondary' : 'default'"
:disabled="submitDisabledReason !== ''"
:title="submitDisabledReason"
>
Save
</Button>
</TooltipTrigger>
<TooltipContent>
<span v-if="submitDisabledReason !== ''">
{{ submitDisabledReason }}
</span>
<span v-else> Save the user. </span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<slot name="cancel"></slot>
</div>
</form>
</template>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import GroupSelectDialog from '@/components/group/GroupSelectDialog.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import TicketPanel from '@/components/ticket/TicketPanel.vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/toast/use-toast'
import { Trash2 } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { useAPI } from '@/api'
import type { UserGroup } from '@/client'
import { handleError } from '@/lib/utils'
const api = useAPI()
const queryClient = useQueryClient()
const { toast } = useToast()
const props = defineProps<{
id: string
}>()
const { data: userGroups } = useQuery({
queryKey: ['user_groups', props.id],
queryFn: (): Promise<Array<UserGroup>> => api.listUserGroups({ id: props.id })
})
const { data: userPermissions } = useQuery({
queryKey: ['user_permissions', props.id],
queryFn: (): Promise<Array<string>> => api.listUserPermissions({ id: props.id })
})
const addGroupMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.addUserGroup({
id: props.id,
groupRelation: {
groupId: id
}
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user_groups'] })
queryClient.invalidateQueries({ queryKey: ['user_permissions'] })
toast({
title: 'Group added',
description: 'The group has been added successfully'
})
},
onError: handleError('Failed to add group')
})
const removeGroupMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.removeUserGroup({
id: props.id,
groupId: id
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user_groups'] })
queryClient.invalidateQueries({ queryKey: ['user_permissions'] })
toast({
title: 'Group removed',
description: 'The group has been removed successfully'
})
},
onError: handleError('Failed to remove group')
})
const dialogOpen = ref(false)
const select = (group: { group: string }) => {
addGroupMutation.mutate(group.group)
dialogOpen.value = false
}
</script>
<template>
<div class="flex flex-col gap-4">
<TicketPanel title="Groups" @add="dialogOpen = true">
<GroupSelectDialog
v-model="dialogOpen"
@select="select"
:exclude="userGroups?.map((group) => group.id) ?? []"
/>
<PanelListElement
v-for="userGroup in userGroups"
:key="userGroup.id"
class="flex h-10 flex-row items-center pr-1"
>
<div class="flex flex-1 items-center overflow-hidden">
<RouterLink
:to="{ name: 'groups', params: { id: userGroup.id } }"
class="hover:underline"
>
{{ userGroup.name }}
</RouterLink>
<span class="ml-1 text-sm text-muted-foreground">({{ userGroup.type }})</span>
</div>
<DeleteDialog
v-if="userGroup.type === 'direct'"
:name="userGroup.name"
singular="Group Membership"
@delete="removeGroupMutation.mutate(userGroup.id)"
>
<Button variant="ghost" size="icon" class="h-8 w-8">
<Trash2 class="size-4" />
</Button>
</DeleteDialog>
</PanelListElement>
<div
v-if="!userGroups || userGroups.length === 0"
class="flex h-10 items-center p-4 text-muted-foreground"
>
No groups assigned yet.
</div>
</TicketPanel>
</div>
<div class="mt-4 flex flex-col gap-4">
<h2 class="text-sm font-medium">Permissions</h2>
<p class="text-sm text-muted-foreground">
The following permissions are granted to the user by their groups.
</p>
<div class="flex flex-wrap gap-2">
<Badge v-for="(permission, index) in userPermissions" :key="index">{{ permission }}</Badge>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ResourceListElement from '@/components/layout/ResourceListElement.vue'
import { Button } from '@/components/ui/button'
import { useQuery } from '@tanstack/vue-query'
import { useRoute, useRouter } from 'vue-router'
import { useAPI } from '@/api'
import type { User } from '@/client/models'
const api = useAPI()
const route = useRoute()
const router = useRouter()
const {
isPending,
isError,
data: users,
error
} = useQuery({
queryKey: ['users'],
queryFn: (): Promise<Array<User>> => api.listUsers()
})
const description = (user: User): string => {
var desc = user.email
if (!user.active) {
desc += ' (inactive)'
}
return desc
}
const openNew = () => {
router.push({ name: 'users', params: { id: 'new' } })
}
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader title="Users" show-sidebar-trigger>
<div class="ml-auto">
<Button variant="ghost" @click="openNew">New User</Button>
</div>
</ColumnHeader>
<div class="mt-2 flex flex-1 flex-col gap-2 overflow-auto p-2 pt-0">
<ResourceListElement
v-for="user in users"
:key="user.id"
:title="user.name ? user.name : user.username"
:created="user.created"
:subtitle="user.username"
:description="description(user)"
:active="route.params.id === user.id"
:to="{ name: 'users', params: { id: user.id } }"
:open="false"
>
{{ user.name }}
</ResourceListElement>
</div>
</TanView>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/toast/use-toast'
import UserForm from '@/components/user/UserForm.vue'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useRouter } from 'vue-router'
import { useAPI } from '@/api'
import type { NewUser, User } from '@/client'
import { handleError } from '@/lib/utils'
const api = useAPI()
const queryClient = useQueryClient()
const router = useRouter()
const { toast } = useToast()
const addUserMutation = useMutation({
mutationFn: (values: NewUser): Promise<User> => api.createUser({ newUser: values }),
onSuccess: (data: User) => {
router.push({ name: 'users', params: { id: data.id } })
toast({
title: 'User created',
description: 'The user has been created successfully'
})
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: handleError('Failed to create user')
})
</script>
<template>
<ColumnHeader>
<Button @click="router.push({ name: 'users' })" variant="outline" class="md:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
</ColumnHeader>
<ColumnBody>
<ColumnBodyContainer small>
<UserForm @submit="addUserMutation.mutate" />
</ColumnBodyContainer>
</ColumnBody>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useQuery } from '@tanstack/vue-query'
import { defineRule, useForm } from 'vee-validate'
import { ref, watch } from 'vue'
import { useAPI } from '@/api'
const api = useAPI()
const submitDisabledReason = ref<string>('')
const emit = defineEmits(['submit'])
const isDemo = ref(false)
const { data: config } = useQuery({
queryKey: ['config'],
queryFn: () => api.getConfig()
})
watch(
() => config.value,
() => {
if (!config.value) return
if (config.value.flags.includes('demo')) {
isDemo.value = true
}
},
{ immediate: true }
)
defineRule('password', (value: string) => {
if (!value) {
return 'Password is required'
}
if (value.length < 8) {
return 'Password must be at least 8 characters long'
}
return true
})
defineRule('passwordConfirm', (value: string) => {
if (!value) {
return 'Password confirmation is required'
}
if (value !== values.password) {
return 'Passwords do not match'
}
return true
})
const { handleSubmit, validate, values, resetForm } = useForm({
initialValues: {
password: '',
passwordConfirm: ''
},
validationSchema: {
password: 'password',
passwordConfirm: 'passwordConfirm'
}
})
const reset = () =>
resetForm({
values: { password: '', passwordConfirm: '' }
})
defineExpose({ reset })
const updateSubmitDisabledReason = () => {
if (isDemo.value) {
submitDisabledReason.value = 'Users cannot be created or edited in demo mode'
return
}
validate({ mode: 'silent' }).then((res) => {
if (res.valid) {
submitDisabledReason.value = ''
} else {
submitDisabledReason.value = 'Please fix the errors'
}
})
}
watch(
() => isDemo.value,
() => updateSubmitDisabledReason()
)
watch(
() => values,
() => updateSubmitDisabledReason(),
{ deep: true, immediate: true }
)
const onSubmit = handleSubmit((values) => emit('submit', values))
</script>
<template>
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
<FormField name="password" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="password" class="text-right">Password</FormLabel>
<Input id="password" type="password" class="col-span-3" v-bind="componentField" />
<FormMessage />
</FormItem>
</FormField>
<FormField name="passwordConfirm" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="passwordConfirm" class="text-right">Confirm Password</FormLabel>
<Input id="passwordConfirm" type="password" class="col-span-3" v-bind="componentField" />
<FormMessage />
</FormItem>
</FormField>
<Alert v-if="isDemo" variant="destructive">
<AlertTitle>Cannot save</AlertTitle>
<AlertDescription>{{ submitDisabledReason }}</AlertDescription>
</Alert>
<div class="flex gap-4">
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger class="cursor-default">
<Button
type="submit"
:variant="submitDisabledReason !== '' ? 'secondary' : 'default'"
:disabled="submitDisabledReason !== ''"
:title="submitDisabledReason"
>
Set Password
</Button>
</TooltipTrigger>
<TooltipContent>
<span v-if="submitDisabledReason !== ''">
{{ submitDisabledReason }}
</span>
<span v-else> Save the user. </span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<slot name="cancel"></slot>
</div>
</form>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { useQuery } from '@tanstack/vue-query'
import { defineRule, useForm } from 'vee-validate'
import { computed, ref, watch } from 'vue'
import { useAPI } from '@/api'
import type { User } from '@/client'
const api = useAPI()
const isOpen = defineModel<boolean>()
const props = defineProps<{
exclude: Array<string>
}>()
const emit = defineEmits(['select'])
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: (): Promise<Array<User>> => api.listUsers()
})
const filteredUsers = computed(() => {
return users.value?.filter((user) => !props.exclude.includes(user.id)) ?? []
})
defineRule('required', (value: string) => {
if (!value || !value.length) {
return 'This field is required'
}
return true
})
const { handleSubmit, validate, values } = useForm({
validationSchema: {
user: 'required'
}
})
const onSubmit = handleSubmit((values) => emit('select', values))
const submitDisabled = ref(true)
const change = () => validate({ mode: 'silent' }).then((res) => (submitDisabled.value = !res.valid))
watch(
() => values,
() => change(),
{ deep: true, immediate: true }
)
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>New User</DialogTitle>
<DialogDescription> Add a new user to this group</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" @change="change">
<FormField name="user" v-slot="{ componentField }">
<FormItem>
<FormLabel for="user" class="text-right"> User</FormLabel>
<Select id="user" v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="user in filteredUsers" :key="user.id" :value="user.id">
{{ user.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter class="mt-2 sm:justify-start">
<Button
:title="submitDisabled ? 'Please fill out all required fields' : undefined"
:disabled="submitDisabled"
type="submit"
>
Save
</Button>
<DialogClose as-child>
<Button type="button" variant="secondary"> Cancel</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>