mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-20 14:03:07 +01:00
refactor: remove pocketbase (#1138)
This commit is contained in:
98
ui/src/components/group/GroupDisplay.vue
Normal file
98
ui/src/components/group/GroupDisplay.vue
Normal 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>
|
||||
201
ui/src/components/group/GroupForm.vue
Normal file
201
ui/src/components/group/GroupForm.vue
Normal 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>
|
||||
323
ui/src/components/group/GroupGroup.vue
Normal file
323
ui/src/components/group/GroupGroup.vue
Normal 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>
|
||||
60
ui/src/components/group/GroupList.vue
Normal file
60
ui/src/components/group/GroupList.vue
Normal 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>
|
||||
51
ui/src/components/group/GroupNew.vue
Normal file
51
ui/src/components/group/GroupNew.vue
Normal 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>
|
||||
115
ui/src/components/group/GroupSelectDialog.vue
Normal file
115
ui/src/components/group/GroupSelectDialog.vue
Normal 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>
|
||||
Reference in New Issue
Block a user