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,98 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import GroupForm from '@/components/group/GroupForm.vue'
import GroupGroup from '@/components/group/GroupGroup.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 { useToast } from '@/components/ui/toast/use-toast'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useRouter } from 'vue-router'
import { useAPI } from '@/api'
import type { Group, GroupUpdate } from '@/client/models'
import { handleError } from '@/lib/utils'
const api = useAPI()
const router = useRouter()
const queryClient = useQueryClient()
const { toast } = useToast()
const props = defineProps<{
id: string
}>()
const {
isPending,
isError,
data: group,
error
} = useQuery({
queryKey: ['groups', props.id],
queryFn: (): Promise<Group> => api.getGroup({ id: props.id })
})
const updateGroupMutation = useMutation({
mutationFn: (update: GroupUpdate) => api.updateGroup({ id: props.id, groupUpdate: update }),
onSuccess: () => {
toast({
title: 'Group updated',
description: 'The group has been updated successfully'
})
queryClient.invalidateQueries({ queryKey: ['groups'] })
},
onError: handleError('Failed to update group')
})
const deleteMutation = useMutation({
mutationFn: () => api.deleteGroup({ id: props.id }),
onSuccess: () => {
queryClient.removeQueries({ queryKey: ['groups', props.id] })
queryClient.invalidateQueries({ queryKey: ['groups'] })
toast({
title: 'Group deleted',
description: 'The group has been deleted successfully'
})
router.push({ name: 'groups' })
},
onError: handleError('Failed to delete group')
})
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader>
<Button @click="router.push({ name: 'groups' })" variant="outline" class="md:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
<div class="ml-auto">
<DeleteDialog
v-if="group && group.id !== 'admin'"
:name="group.name"
singular="Group"
@delete="deleteMutation.mutate"
/>
</div>
</ColumnHeader>
<ColumnBody v-if="group">
<ColumnBodyContainer>
<div class="flex flex-col gap-4 xl:flex-row">
<div class="flex flex-col gap-4 xl:flex-1">
<GroupForm :group="group" @submit="updateGroupMutation.mutate" />
</div>
<div class="flex w-full flex-col gap-4 xl:w-96 xl:shrink-0">
<GroupGroup :id="group.id" />
</div>
</div>
</ColumnBodyContainer>
</ColumnBody>
</TanView>
</template>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import MultiSelect from '@/components/form/MultiSelect.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { FormControl, 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 { computed, ref, watch } from 'vue'
import { useAPI } from '@/api'
import type { NewGroup } from '@/client'
const api = useAPI()
const submitDisabledReason = ref<string>('')
const props = defineProps<{
group?: NewGroup
}>()
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
})
const { handleSubmit, validate, values } = useForm({
initialValues: () => ({
name: props.group?.name || '',
permissions: props.group?.permissions || []
}),
validationSchema: {
name: 'required'
}
})
const equalGroup = (values: NewGroup, group?: NewGroup): boolean => {
if (!group) return false
return group.name === values.name && equalElements(group.permissions, values.permissions)
}
const equalElements = (a: string[], b: string[]): boolean => {
if (a.length !== b.length) return false
const sortedA = [...a].sort()
const sortedB = [...b].sort()
return sortedA.every((value, index) => value === sortedB[index])
}
const updateSubmitDisabledReason = () => {
if (props.group && props.group.name === 'Admin') {
submitDisabledReason.value = 'The admin group cannot be edited'
return
}
if (isDemo.value) {
submitDisabledReason.value = 'Groups cannot be created or edited in demo mode'
return
}
if (equalGroup(values, props.group)) {
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.group,
() => updateSubmitDisabledReason(),
{ immediate: true }
)
watch(
() => values,
() => updateSubmitDisabledReason(),
{ deep: true, immediate: true }
)
const onSubmit = handleSubmit((values) => {
emit('submit', {
name: values.name,
permissions: values.permissions
})
})
const permissionItems = computed(() => config.value?.permissions || [])
</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"
:disabled="group && group.name === 'Admin'"
/>
<FormMessage />
</FormItem>
</FormField>
<FormField
key="permission.name"
name="permissions"
v-slot="{ componentField }"
validate-on-input
>
<FormItem id="permissions" class="w-full">
<div class="space-y-0.5">
<FormLabel for="permissions" class="text-right">Permissions</FormLabel>
</div>
<FormControl>
<MultiSelect
v-bind="componentField"
:items="permissionItems"
placeholder="Select permissions..."
:disabled="values.name === 'Admin'"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Alert v-if="isDemo" variant="destructive">
<AlertTitle>Cannot save</AlertTitle>
<AlertDescription>{{ submitDisabledReason }}</AlertDescription>
</Alert>
<Alert v-if="values.name === 'Admin'">
<AlertTitle>Cannot save</AlertTitle>
<AlertDescription>{{ submitDisabledReason }}</AlertDescription>
</Alert>
<div v-if="values.name !== 'Admin'" class="flex gap-4">
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger class="cursor-default">
<Button
role="submit"
:variant="submitDisabledReason !== '' ? 'secondary' : 'default'"
:disabled="submitDisabledReason !== ''"
:title="submitDisabledReason"
>
Save
</Button>
</TooltipTrigger>
<TooltipContent>
<span v-if="submitDisabledReason !== ''">
{{ submitDisabledReason }}
</span>
<span v-else> Save the group. </span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<slot name="cancel"></slot>
</div>
</form>
</template>

View File

@@ -0,0 +1,323 @@
<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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/components/ui/toast/use-toast'
import UserSelectDialog from '@/components/user/UserSelectDialog.vue'
import { Trash2 } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { useAPI } from '@/api'
import type { GroupUser, UserGroup } from '@/client'
import { handleError } from '@/lib/utils'
const api = useAPI()
const queryClient = useQueryClient()
const { toast } = useToast()
const props = defineProps<{
id: string
}>()
const { data: parentGroups } = useQuery({
queryKey: ['parent_groups', props.id],
queryFn: (): Promise<Array<UserGroup>> => api.listParentGroups({ id: props.id })
})
const { data: parentPermissions } = useQuery({
queryKey: ['parent_permissions', props.id],
queryFn: (): Promise<Array<string>> => api.listParentPermissions({ id: props.id })
})
const { data: childGroups } = useQuery({
queryKey: ['child_groups', props.id],
queryFn: (): Promise<Array<UserGroup>> => api.listChildGroups({ id: props.id })
})
const { data: groupUsers } = useQuery({
queryKey: ['group_users', props.id],
queryFn: (): Promise<Array<GroupUser>> => api.listGroupUsers({ id: props.id })
})
const addGroupUserMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.addUserGroup({
id: id,
groupRelation: {
groupId: props.id
}
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group_users'] })
toast({
title: 'User added',
description: 'The user has been added to the group'
})
},
onError: handleError('Failed to add user to group')
})
const addGroupParentMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.addGroupParent({
id: id,
groupRelation: {
groupId: props.id
}
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['parent_groups'] })
queryClient.invalidateQueries({ queryKey: ['parent_permissions'] })
toast({
title: 'Parent group added',
description: 'The parent group has been added successfully'
})
},
onError: handleError('Failed to add parent group')
})
const addGroupChildMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.addGroupParent({
id: props.id,
groupRelation: {
groupId: id
}
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['child_groups'] })
toast({
title: 'Child group added',
description: 'The child group has been added successfully'
})
},
onError: handleError('Failed to add child group')
})
const removeGroupUserMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.removeUserGroup({
id: id,
groupId: props.id
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group_users'] })
toast({
title: 'User removed',
description: 'The user has been removed from the group'
})
},
onError: handleError('Failed to remove user from group')
})
const removeGroupParentMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.removeGroupParent({
id: id,
parentGroupId: props.id
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['parent_groups'] })
queryClient.invalidateQueries({ queryKey: ['parent_permissions'] })
toast({
title: 'Parent group removed',
description: 'The parent group has been removed successfully'
})
},
onError: handleError('Failed to remove parent group')
})
const removeGroupChildMutation = useMutation({
mutationFn: (id: string): Promise<void> =>
api.removeGroupParent({
id: props.id,
parentGroupId: id
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['child_groups'] })
toast({
title: 'Child group removed',
description: 'The child group has been removed successfully'
})
},
onError: handleError('Failed to remove child group')
})
const dialogOpenParent = ref(false)
const dialogOpenChild = ref(false)
const dialogOpenUser = ref(false)
const selectParent = (group: { group: string }) => {
addGroupParentMutation.mutate(group.group)
dialogOpenParent.value = false
}
const selectChild = (group: { group: string }) => {
addGroupChildMutation.mutate(group.group)
dialogOpenChild.value = false
}
const selectUser = (user: { user: string }) => {
addGroupUserMutation.mutate(user.user)
dialogOpenUser.value = false
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4">
<TicketPanel title="Child Groups" @add="dialogOpenChild = true">
<GroupSelectDialog
v-model="dialogOpenChild"
@select="selectChild"
:exclude="childGroups?.map((group) => group.id).concat([id]) ?? [id]"
/>
<PanelListElement
v-for="groupGroup in childGroups"
:key="groupGroup.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: groupGroup.id } }"
class="hover:underline"
>
{{ groupGroup.name }}
</RouterLink>
<span class="ml-1 text-sm text-muted-foreground">({{ groupGroup.type }})</span>
</div>
<DeleteDialog
v-if="groupGroup.type === 'direct'"
:name="groupGroup.name"
singular="Membership"
@delete="removeGroupChildMutation.mutate(groupGroup.id)"
>
<Button variant="ghost" size="icon" class="h-8 w-8">
<Trash2 class="size-4" />
</Button>
</DeleteDialog>
</PanelListElement>
<div
v-if="!childGroups || childGroups.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">
<TicketPanel title="Users" @add="dialogOpenUser = true">
<UserSelectDialog
v-model="dialogOpenUser"
@select="selectUser"
:exclude="groupUsers?.map((user) => user.id) ?? []"
/>
<PanelListElement
v-for="groupUser in groupUsers"
:key="groupUser.id"
class="flex h-10 flex-row items-center pr-1"
>
<div class="flex flex-1 items-center overflow-hidden">
<RouterLink
:to="{ name: 'users', params: { id: groupUser.id } }"
class="hover:underline"
>
{{ groupUser.name }}
</RouterLink>
<span class="ml-1 text-sm text-muted-foreground">({{ groupUser.type }})</span>
</div>
<DeleteDialog
v-if="groupUser.type === 'direct'"
:name="groupUser.name"
singular="Membership"
@delete="removeGroupUserMutation.mutate(groupUser.id)"
>
<Button variant="ghost" size="icon" class="h-8 w-8">
<Trash2 class="size-4" />
</Button>
</DeleteDialog>
</PanelListElement>
<div
v-if="!groupUsers || groupUsers.length === 0"
class="flex h-10 items-center p-4 text-muted-foreground"
>
No users assigned yet.
</div>
</TicketPanel>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Inheritance</CardTitle>
</CardHeader>
<CardContent>
<div class="mt-4 flex flex-col gap-4">
<TicketPanel title="Parent Groups" @add="dialogOpenParent = true">
<GroupSelectDialog
v-model="dialogOpenParent"
@select="selectParent"
:exclude="parentGroups?.map((group) => group.id).concat([id]) ?? [id]"
/>
<PanelListElement
v-for="groupGroup in parentGroups"
:key="groupGroup.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: groupGroup.id } }"
class="hover:underline"
>
{{ groupGroup.name }}
</RouterLink>
<span class="ml-1 text-sm text-muted-foreground">({{ groupGroup.type }})</span>
</div>
<DeleteDialog
v-if="groupGroup.type === 'direct'"
:name="groupGroup.name"
singular="Inheritance"
@delete="removeGroupParentMutation.mutate(groupGroup.id)"
>
<Button variant="ghost" size="icon" class="h-8 w-8">
<Trash2 class="size-4" />
</Button>
</DeleteDialog>
</PanelListElement>
<div
v-if="!parentGroups || parentGroups.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 in addition to the permissions selected to the left
by the parent groups.
</p>
<div class="flex flex-wrap gap-2">
<Badge v-for="(permission, index) in parentPermissions" :key="index">{{
permission
}}</Badge>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,60 @@
<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 { Group } from '@/client/models'
const api = useAPI()
const route = useRoute()
const router = useRouter()
const {
isPending,
isError,
data: groups,
error
} = useQuery({
queryKey: ['groups'],
queryFn: (): Promise<Array<Group>> => api.listGroups()
})
const description = (group: Group): string => {
return group.permissions.join(', ')
}
const openNew = () => {
router.push({ name: 'groups', params: { id: 'new' } })
}
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader title="Groups" show-sidebar-trigger>
<div class="ml-auto">
<Button variant="ghost" @click="openNew">New Group</Button>
</div>
</ColumnHeader>
<div class="mt-2 flex flex-1 flex-col gap-2 overflow-auto p-2 pt-0">
<ResourceListElement
v-for="group in groups"
:key="group.id"
:title="group.name"
:created="group.created"
subtitle=""
:description="description(group)"
:active="route.params.id === group.id"
:to="{ name: 'groups', params: { id: group.id } }"
:open="false"
>
{{ group.name }}
</ResourceListElement>
</div>
</TanView>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import GroupForm from '@/components/group/GroupForm.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 { useToast } from '@/components/ui/toast/use-toast'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useRouter } from 'vue-router'
import { useAPI } from '@/api'
import type { Group, NewGroup } from '@/client'
import { handleError } from '@/lib/utils'
const api = useAPI()
const queryClient = useQueryClient()
const router = useRouter()
const { toast } = useToast()
const addGroupMutation = useMutation({
mutationFn: (values: NewGroup): Promise<Group> => api.createGroup({ newGroup: values }),
onSuccess: (data: Group) => {
router.push({ name: 'groups', params: { id: data.id } })
toast({
title: 'Group created',
description: 'The group has been created successfully'
})
queryClient.invalidateQueries({ queryKey: ['groups'] })
},
onError: handleError('Failed to create group')
})
</script>
<template>
<ColumnHeader>
<Button @click="router.push({ name: 'groups' })" variant="outline" class="md:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
</ColumnHeader>
<ColumnBody>
<ColumnBodyContainer small>
<GroupForm @submit="addGroupMutation.mutate" />
</ColumnBodyContainer>
</ColumnBody>
</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 { Group } from '@/client'
const api = useAPI()
const isOpen = defineModel<boolean>()
const props = defineProps<{
exclude: Array<string>
}>()
const emit = defineEmits(['select'])
const { data: groups } = useQuery({
queryKey: ['groups'],
queryFn: (): Promise<Array<Group>> => api.listGroups()
})
const filteredGroups = computed(() => {
return groups.value?.filter((group) => !props.exclude.includes(group.id)) ?? []
})
defineRule('required', (value: string) => {
if (!value || !value.length) {
return 'This field is required'
}
return true
})
const { handleSubmit, validate, values } = useForm({
validationSchema: {
group: '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 Group</DialogTitle>
<DialogDescription> Add a new group to this user</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" @change="change">
<FormField name="group" v-slot="{ componentField }">
<FormItem>
<FormLabel for="group" class="text-right"> Group</FormLabel>
<Select id="group" v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select a group" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="group in filteredGroups" :key="group.id" :value="group.id"
>{{ group.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>