mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2026-01-11 16:56:58 +01:00
refactor: remove pocketbase (#1138)
This commit is contained in:
171
ui/src/components/user/UserDisplay.vue
Normal file
171
ui/src/components/user/UserDisplay.vue
Normal 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>
|
||||
223
ui/src/components/user/UserForm.vue
Normal file
223
ui/src/components/user/UserForm.vue
Normal 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>
|
||||
132
ui/src/components/user/UserGroup.vue
Normal file
132
ui/src/components/user/UserGroup.vue
Normal 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>
|
||||
66
ui/src/components/user/UserList.vue
Normal file
66
ui/src/components/user/UserList.vue
Normal 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>
|
||||
51
ui/src/components/user/UserNew.vue
Normal file
51
ui/src/components/user/UserNew.vue
Normal 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>
|
||||
156
ui/src/components/user/UserPasswordForm.vue
Normal file
156
ui/src/components/user/UserPasswordForm.vue
Normal 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>
|
||||
115
ui/src/components/user/UserSelectDialog.vue
Normal file
115
ui/src/components/user/UserSelectDialog.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 { 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>
|
||||
Reference in New Issue
Block a user