Compare commits

...

4 Commits

Author SHA1 Message Date
Jonas Plum
96b7a9604c fix: multi select state handling (#1094) 2024-08-05 15:22:01 +02:00
Jonas Plum
21f1c3d328 feat: reset password (#1092) 2024-08-03 16:26:09 +02:00
Jonas Plum
84ae933cfb perf: search (#1091) 2024-08-03 14:58:55 +02:00
Jonas Plum
b929100d30 feat: enum custom field (#1090) 2024-08-03 13:43:41 +02:00
18 changed files with 299 additions and 84 deletions

11
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
coverage:
status:
project:
default:
threshold: 5%
patch: off
comment:
layout: diff
parsers:
go:
partials_as_hits: true

View File

@@ -48,6 +48,15 @@ dev:
go run . fake-data go run . fake-data
go run . serve go run . serve
.PHONY: dev-10000
dev-10000:
@echo "Running..."
rm -rf catalyst_data
go run . admin create admin@catalyst-soar.com 1234567890
go run . set-feature-flags dev
go run . fake-data --users 100 --tickets 10000
go run . serve
.PHONY: dev-ui .PHONY: dev-ui
serve-ui: serve-ui:
cd ui && bun dev --port 3000 cd ui && bun dev --port 3000

View File

@@ -128,7 +128,7 @@ func ticketRecords(dao *daos.Dao, users, types []*models.Record, count int) []*m
record.Set("description", fakeTicketDescription()) record.Set("description", fakeTicketDescription())
record.Set("open", gofakeit.Bool()) record.Set("open", gofakeit.Bool())
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`) record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`)
record.Set("state", `{"tlp":"AMBER"}`) record.Set("state", `{"severity":"Medium"}`)
record.Set("owner", random(users).GetId()) record.Set("owner", random(users).GetId())
records = append(records, record) records = append(records, record)

View File

@@ -1,6 +1,8 @@
package migrations package migrations
import ( import (
"encoding/json"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
@@ -33,7 +35,16 @@ func typeRecords(dao *daos.Dao) []*models.Record {
record.Set("singular", "Incident") record.Set("singular", "Incident")
record.Set("plural", "Incidents") record.Set("plural", "Incidents")
record.Set("icon", "Flame") record.Set("icon", "Flame")
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`) record.Set("schema", s(map[string]any{
"type": "object",
"properties": map[string]any{
"severity": map[string]any{
"title": "Severity",
"enum": []string{"Low", "Medium", "High"},
},
},
"required": []string{"severity"},
}))
records = append(records, record) records = append(records, record)
@@ -42,9 +53,24 @@ func typeRecords(dao *daos.Dao) []*models.Record {
record.Set("singular", "Alert") record.Set("singular", "Alert")
record.Set("plural", "Alerts") record.Set("plural", "Alerts")
record.Set("icon", "AlertTriangle") record.Set("icon", "AlertTriangle")
record.Set("schema", `{"type":"object","properties":{"severity":{"title":"Severity","type":"string"}},"required": ["severity"]}`) record.Set("schema", s(map[string]any{
"type": "object",
"properties": map[string]any{
"severity": map[string]any{
"title": "Severity",
"enum": []string{"Low", "Medium", "High"},
},
},
"required": []string{"severity"},
}))
records = append(records, record) records = append(records, record)
return records return records
} }
func s(m map[string]any) string {
b, _ := json.Marshal(m) //nolint:errchkjson
return string(b)
}

View File

@@ -0,0 +1,49 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
)
const searchViewName = "ticket_search"
const searchViewQuery = `
SELECT
tickets.id,
tickets.name,
tickets.created,
tickets.description,
tickets.open,
tickets.type,
tickets.state,
users.name as owner_name,
group_concat(comments.message) as comment_messages,
group_concat(files.name) as file_names,
group_concat(links.name) as link_names,
group_concat(links.url) as link_urls,
group_concat(tasks.name) as task_names,
group_concat(timeline.message) as timeline_messages
FROM tickets
LEFT JOIN comments ON comments.ticket = tickets.id
LEFT JOIN files ON files.ticket = tickets.id
LEFT JOIN links ON links.ticket = tickets.id
LEFT JOIN tasks ON tasks.ticket = tickets.id
LEFT JOIN timeline ON timeline.ticket = tickets.id
LEFT JOIN users ON users.id = tickets.owner
GROUP BY tickets.id
`
func searchViewUp(db dbx.Builder) error {
return daos.New(db).SaveCollection(internalView(searchViewName, searchViewQuery))
}
func searchViewDown(db dbx.Builder) error {
dao := daos.New(db)
id, err := dao.FindCollectionByNameOrId(searchViewName)
if err != nil {
return err
}
return dao.DeleteCollection(id)
}

View File

@@ -11,4 +11,5 @@ func Register() {
migrations.Register(viewsUp, viewsDown, "1700000004_views.go") migrations.Register(viewsUp, viewsDown, "1700000004_views.go")
migrations.Register(reactionsUp, reactionsDown, "1700000005_reactions.go") migrations.Register(reactionsUp, reactionsDown, "1700000005_reactions.go")
migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go") migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go")
migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go")
} }

View File

@@ -9,7 +9,7 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build --force", "type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path ../.gitignore",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<img src="@/assets/flask.svg" alt="Catalyst Logo" :class="cn('dark:hidden', props.class)" />
<img
src="@/assets/flask_white.svg"
alt="Catalyst Logo"
:class="cn('hidden dark:flex', props.class)"
/>
</template>

View File

@@ -1,24 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Textarea } from '@/components/ui/textarea'
import { useVModel } from '@vueuse/core'
import { type HTMLAttributes, onMounted, ref } from 'vue' import { type HTMLAttributes, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes['class']
defaultValue?: string | number
modelValue?: string | number
}>() }>()
const emits = defineEmits<{ const modelValue = defineModel<string>({
(e: 'update:modelValue', payload: string | number): void default: ''
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
}) })
const textarea = ref<HTMLElement | null>(null) const textarea = ref<HTMLElement | null>(null)

View File

@@ -2,6 +2,14 @@
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
@@ -34,6 +42,26 @@ watch(
<template> <template>
<div v-for="(property, key) in schema.properties" :key="key"> <div v-for="(property, key) in schema.properties" :key="key">
<FormField v-if="property.enum" :name="key" v-slot="{ componentField }" v-model="formdata[key]">
<FormItem>
<FormLabel :for="key" class="text-right">
{{ property.title }}
</FormLabel>
<Select :id="key" class="col-span-3" v-bind="componentField">
<SelectTrigger class="font-medium">
<SelectValue :placeholder="'Select a ' + property.title" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="option in property.enum" :key="option" :value="option">
{{ option }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField <FormField
v-if="property.type === 'string'" v-if="property.type === 'string'"
:name="key" :name="key"

View File

@@ -32,7 +32,8 @@ const selectedItems = ref<string[]>(props.modelValue)
watch( watch(
() => selectedItems.value, () => selectedItems.value,
(value) => emit('update:modelValue', value) (value) => emit('update:modelValue', value),
{ deep: true }
) )
const filteredItems = computed(() => { const filteredItems = computed(() => {

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import IncidentNav from '@/components/sidebar/IncidentNav.vue' import IncidentNav from '@/components/sidebar/IncidentNav.vue'
import NavList from '@/components/sidebar/NavList.vue' import NavList from '@/components/sidebar/NavList.vue'
import UserDropDown from '@/components/sidebar/UserDropDown.vue' import UserDropDown from '@/components/sidebar/UserDropDown.vue'
@@ -14,16 +15,8 @@ const catalystStore = useCatalystStore()
<template> <template>
<div class="flex h-[57px] items-center border-b bg-background"> <div class="flex h-[57px] items-center border-b bg-background">
<img <CatalystLogo
src="@/assets/flask.svg" class="size-8"
alt="Catalyst"
class="h-8 w-8 dark:hidden"
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
/>
<img
src="@/assets/flask_white.svg"
alt="Catalyst"
class="hidden h-8 w-8 dark:flex"
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }" :class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
/> />
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1> <h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>

View File

@@ -17,9 +17,8 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Info, LoaderCircle, Search } from 'lucide-vue-next' import { LoaderCircle, Search } from 'lucide-vue-next'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
@@ -28,7 +27,7 @@ import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase' import { pb } from '@/lib/pocketbase'
import type { Ticket, Type } from '@/lib/types' import type { SearchTicket, Type } from '@/lib/types'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -48,15 +47,13 @@ const filter = computed(() => {
let raws: Array<string> = [ let raws: Array<string> = [
'name ~ {:search}', 'name ~ {:search}',
'description ~ {:search}', 'description ~ {:search}',
'owner.name ~ {:search}', 'owner_name ~ {:search}',
'owner.email ~ {:search}', 'comment_messages ~ {:search}',
'links_via_ticket.name ~ {:search}', 'file_names ~ {:search}',
'links_via_ticket.url ~ {:search}', 'link_names ~ {:search}',
'tasks_via_ticket.name ~ {:search}', 'link_urls ~ {:search}',
'comments_via_ticket.message ~ {:search}', 'task_names ~ {:search}',
'files_via_ticket.name ~ {:search}', 'timeline_messages ~ {:search}'
'timeline_via_ticket.message ~ {:search}',
'state.severity ~ {:search}'
] ]
Object.keys(props.selectedType.schema.properties).forEach((key) => { Object.keys(props.selectedType.schema.properties).forEach((key) => {
@@ -96,12 +93,10 @@ const {
refetch refetch
} = useQuery({ } = useQuery({
queryKey: ['tickets', filter.value], queryKey: ['tickets', filter.value],
queryFn: (): Promise<ListResult<Ticket>> => queryFn: (): Promise<ListResult<SearchTicket>> =>
pb.collection('tickets').getList(page.value, perPage.value, { pb.collection('ticket_search').getList(page.value, perPage.value, {
sort: '-created', sort: '-created',
filter: filter.value, filter: filter.value
expand:
'type,owner,comments_via_ticket.author,files_via_ticket,timeline_via_ticket,links_via_ticket,tasks_via_ticket.owner'
}) })
}) })
@@ -139,9 +134,9 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
<Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden"> <Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between px-4 pt-2"> <div class="flex items-center justify-between px-4 pt-2">
<TabsList> <TabsList>
<TabsTrigger value="all"> All</TabsTrigger> <TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="open"> Open</TabsTrigger> <TabsTrigger value="open">Open</TabsTrigger>
<TabsTrigger value="closed"> Closed</TabsTrigger> <TabsTrigger value="closed">Closed</TabsTrigger>
</TabsList> </TabsList>
<!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3"> <!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3">
<ListFilter class="h-3.5 w-3.5" /> <ListFilter class="h-3.5 w-3.5" />
@@ -155,23 +150,6 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-2"> <span class="absolute inset-y-0 start-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" /> <Search class="size-4 text-muted-foreground" />
</span> </span>
<div>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger as-child>
<Info class="ml-2 size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p class="w-64">
Search name, description, or owner. Links, tasks, comments, files, and
timeline messages are also searched, but cause unreliable results if there are
more than 1000 records.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -222,7 +200,6 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
</PaginationListItem> </PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" /> <PaginationEllipsis v-else :key="item.type" :index="index" />
</template> </template>
<PaginationNext /> <PaginationNext />
<PaginationLast /> <PaginationLast />
</PaginationList> </PaginationList>

View File

@@ -3,12 +3,12 @@ import ResourceListElement from '@/components/common/ResourceListElement.vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import type { Ticket } from '@/lib/types' import type { SearchTicket } from '@/lib/types'
const route = useRoute() const route = useRoute()
defineProps<{ defineProps<{
tickets: Array<Ticket> tickets: Array<SearchTicket>
}>() }>()
</script> </script>
@@ -19,10 +19,10 @@ defineProps<{
:key="item.id" :key="item.id"
:title="item.name" :title="item.name"
:created="item.created" :created="item.created"
:subtitle="item.expand.owner ? item.expand.owner.name : ''" :subtitle="item.owner_name"
:description="item.description ? item.description.substring(0, 300) : ''" :description="item.description ? item.description.substring(0, 300) : ''"
:active="route.params.id === item.id" :active="route.params.id === item.id"
:to="`/tickets/${item.expand.type.id}/${item.id}`" :to="`/tickets/${item.type}/${item.id}`"
:open="item.open" :open="item.open"
/> />
</div> </div>

View File

@@ -32,6 +32,16 @@ export interface Ticket {
} }
} }
export interface SearchTicket {
id: string
name: string
created: string
description: string
open: boolean
type: string
owner_name: string
}
export interface Task { export interface Task {
id: string id: string
@@ -126,6 +136,7 @@ export interface JSONSchema {
title: string title: string
type: string type: string
description?: string description?: string
enum?: Array<string>
} }
> >
required?: Array<string> required?: Array<string>

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '@/views/DashboardView.vue' import DashboardView from '@/views/DashboardView.vue'
import LoginView from '@/views/LoginView.vue' import LoginView from '@/views/LoginView.vue'
import PasswordResetView from '@/views/PasswordResetView.vue'
import ReactionView from '@/views/ReactionView.vue' import ReactionView from '@/views/ReactionView.vue'
import TicketView from '@/views/TicketView.vue' import TicketView from '@/views/TicketView.vue'
@@ -12,25 +13,30 @@ const router = createRouter({
path: '/', path: '/',
redirect: '/dashboard' redirect: '/dashboard'
}, },
{
path: '/reactions/:id?',
name: 'reactions',
component: ReactionView
},
{ {
path: '/dashboard', path: '/dashboard',
name: 'dashboard', name: 'dashboard',
component: DashboardView component: DashboardView
}, },
{
path: '/tickets/:type/:id?',
name: 'tickets',
component: TicketView
},
{
path: '/reactions/:id?',
name: 'reactions',
component: ReactionView
},
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: LoginView component: LoginView
}, },
{ {
path: '/tickets/:type/:id?', path: '/password-reset',
name: 'tickets', name: 'password-reset',
component: TicketView component: PasswordResetView
} }
] ]
}) })

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button, buttonVariants } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -8,9 +9,11 @@ import { useQuery } from '@tanstack/vue-query'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { pb } from '@/lib/pocketbase' import { pb } from '@/lib/pocketbase'
import { cn } from '@/lib/utils'
const mail = ref('') const mail = ref('')
const password = ref('') const password = ref('')
const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const login = () => { const login = () => {
@@ -20,6 +23,7 @@ const login = () => {
window.location.href = '/ui/' window.location.href = '/ui/'
}) })
.catch((error) => { .catch((error) => {
errorTitle.value = 'Login failed'
errorMessage.value = error.message errorMessage.value = error.message
}) })
} }
@@ -37,7 +41,8 @@ watch(
mail.value = 'user@catalyst-soar.com' mail.value = 'user@catalyst-soar.com'
password.value = '1234567890' password.value = '1234567890'
} }
} },
{ immediate: true }
) )
</script> </script>
@@ -45,12 +50,18 @@ watch(
<div class="flex h-full w-full flex-1 items-center justify-center"> <div class="flex h-full w-full flex-1 items-center justify-center">
<Card class="m-auto w-96"> <Card class="m-auto w-96">
<CardHeader class="flex flex-row justify-between"> <CardHeader class="flex flex-row justify-between">
<CardTitle>Catalyst</CardTitle> <CardTitle class="flex flex-row">
<CatalystLogo class="size-12" />
<div>
<h1 class="text-lg font-bold">Catalyst</h1>
<div class="text-muted-foreground">Login</div>
</div>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="flex flex-col gap-4"> <CardContent class="flex flex-col gap-4">
<Alert v-if="errorMessage" variant="destructive" class="border-4 p-4"> <Alert v-if="errorTitle || errorMessage" variant="destructive" class="border-4 p-4">
<AlertTitle>Error</AlertTitle> <AlertTitle v-if="errorTitle">{{ errorTitle }}</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription> <AlertDescription v-if="errorMessage">{{ errorMessage }}</AlertDescription>
</Alert> </Alert>
<Input <Input
v-model="mail" v-model="mail"
@@ -66,7 +77,14 @@ watch(
class="w-full" class="w-full"
@keydown.enter="login" @keydown.enter="login"
/> />
<Button variant="outline" class="w-full" @click="login"> Login</Button> <Button variant="outline" class="w-full" @click="login">Login</Button>
<RouterLink
:to="{ name: 'password-reset' }"
:class="
cn(buttonVariants({ variant: 'link', size: 'default' }), 'w-full text-foreground')
"
>Reset Password
</RouterLink>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button, buttonVariants } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ref } from 'vue'
import { pb } from '@/lib/pocketbase'
import { cn } from '@/lib/utils'
interface AlertData {
variant: 'default' | 'destructive'
title: string
message: string
}
const mail = ref('')
const alert = ref<AlertData | null>(null)
const resetPassword = () => {
pb.collection('users')
.requestPasswordReset(mail.value)
.then(() => {
alert.value = {
variant: 'default',
title: 'Password reset',
message: 'Password reset email sent'
}
})
.catch((error) => {
alert.value = {
variant: 'destructive',
title: 'Password reset failed',
message: error.message
}
})
}
</script>
<template>
<div class="flex h-full w-full flex-1 items-center justify-center">
<Card class="m-auto w-96">
<CardHeader class="flex flex-row justify-between">
<CardTitle class="flex flex-row">
<CatalystLogo class="size-12" />
<div>
<h1 class="text-lg font-bold">Catalyst</h1>
<div class="text-muted-foreground">Password Reset</div>
</div>
</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<Alert v-if="alert" :variant="alert.variant" class="border-4 p-4">
<AlertTitle>{{ alert.title }}</AlertTitle>
<AlertDescription>{{ alert.message }}</AlertDescription>
</Alert>
<div v-else class="flex flex-col gap-4">
<Input
v-model="mail"
type="text"
placeholder="Email"
class="w-full"
@keydown.enter="resetPassword"
/>
<Button variant="outline" class="w-full" @click="resetPassword">Reset Password</Button>
</div>
<RouterLink
:to="{ name: 'login' }"
:class="
cn(buttonVariants({ variant: 'link', size: 'default' }), 'w-full text-foreground')
"
>Back to Login
</RouterLink>
</CardContent>
</Card>
</div>
</template>