mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-06 23:32:47 +01:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b7a9604c | ||
|
|
21f1c3d328 | ||
|
|
84ae933cfb | ||
|
|
b929100d30 |
11
.github/codecov.yml
vendored
Normal file
11
.github/codecov.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
threshold: 5%
|
||||
patch: off
|
||||
comment:
|
||||
layout: diff
|
||||
parsers:
|
||||
go:
|
||||
partials_as_hits: true
|
||||
9
Makefile
9
Makefile
@@ -48,6 +48,15 @@ dev:
|
||||
go run . fake-data
|
||||
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
|
||||
serve-ui:
|
||||
cd ui && bun dev --port 3000
|
||||
|
||||
@@ -128,7 +128,7 @@ func ticketRecords(dao *daos.Dao, users, types []*models.Record, count int) []*m
|
||||
record.Set("description", fakeTicketDescription())
|
||||
record.Set("open", gofakeit.Bool())
|
||||
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())
|
||||
|
||||
records = append(records, record)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -33,7 +35,16 @@ func typeRecords(dao *daos.Dao) []*models.Record {
|
||||
record.Set("singular", "Incident")
|
||||
record.Set("plural", "Incidents")
|
||||
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)
|
||||
|
||||
@@ -42,9 +53,24 @@ func typeRecords(dao *daos.Dao) []*models.Record {
|
||||
record.Set("singular", "Alert")
|
||||
record.Set("plural", "Alerts")
|
||||
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)
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
func s(m map[string]any) string {
|
||||
b, _ := json.Marshal(m) //nolint:errchkjson
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
49
migrations/7_search_view.go
Normal file
49
migrations/7_search_view.go
Normal 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)
|
||||
}
|
||||
@@ -11,4 +11,5 @@ func Register() {
|
||||
migrations.Register(viewsUp, viewsDown, "1700000004_views.go")
|
||||
migrations.Register(reactionsUp, reactionsDown, "1700000005_reactions.go")
|
||||
migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go")
|
||||
migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"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/"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
16
ui/src/components/common/CatalystLogo.vue
Normal file
16
ui/src/components/common/CatalystLogo.vue
Normal 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>
|
||||
@@ -1,24 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { type HTMLAttributes, onMounted, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue
|
||||
const modelValue = defineModel<string>({
|
||||
default: ''
|
||||
})
|
||||
|
||||
const textarea = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -34,6 +42,26 @@ watch(
|
||||
|
||||
<template>
|
||||
<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
|
||||
v-if="property.type === 'string'"
|
||||
:name="key"
|
||||
|
||||
@@ -32,7 +32,8 @@ const selectedItems = ref<string[]>(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => selectedItems.value,
|
||||
(value) => emit('update:modelValue', value)
|
||||
(value) => emit('update:modelValue', value),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import CatalystLogo from '@/components/common/CatalystLogo.vue'
|
||||
import IncidentNav from '@/components/sidebar/IncidentNav.vue'
|
||||
import NavList from '@/components/sidebar/NavList.vue'
|
||||
import UserDropDown from '@/components/sidebar/UserDropDown.vue'
|
||||
@@ -14,16 +15,8 @@ const catalystStore = useCatalystStore()
|
||||
|
||||
<template>
|
||||
<div class="flex h-[57px] items-center border-b bg-background">
|
||||
<img
|
||||
src="@/assets/flask.svg"
|
||||
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"
|
||||
<CatalystLogo
|
||||
class="size-8"
|
||||
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
|
||||
/>
|
||||
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
|
||||
|
||||
@@ -17,9 +17,8 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
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 debounce from 'lodash.debounce'
|
||||
@@ -28,7 +27,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket, Type } from '@/lib/types'
|
||||
import type { SearchTicket, Type } from '@/lib/types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -48,15 +47,13 @@ const filter = computed(() => {
|
||||
let raws: Array<string> = [
|
||||
'name ~ {:search}',
|
||||
'description ~ {:search}',
|
||||
'owner.name ~ {:search}',
|
||||
'owner.email ~ {:search}',
|
||||
'links_via_ticket.name ~ {:search}',
|
||||
'links_via_ticket.url ~ {:search}',
|
||||
'tasks_via_ticket.name ~ {:search}',
|
||||
'comments_via_ticket.message ~ {:search}',
|
||||
'files_via_ticket.name ~ {:search}',
|
||||
'timeline_via_ticket.message ~ {:search}',
|
||||
'state.severity ~ {:search}'
|
||||
'owner_name ~ {:search}',
|
||||
'comment_messages ~ {:search}',
|
||||
'file_names ~ {:search}',
|
||||
'link_names ~ {:search}',
|
||||
'link_urls ~ {:search}',
|
||||
'task_names ~ {:search}',
|
||||
'timeline_messages ~ {:search}'
|
||||
]
|
||||
|
||||
Object.keys(props.selectedType.schema.properties).forEach((key) => {
|
||||
@@ -96,12 +93,10 @@ const {
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: ['tickets', filter.value],
|
||||
queryFn: (): Promise<ListResult<Ticket>> =>
|
||||
pb.collection('tickets').getList(page.value, perPage.value, {
|
||||
queryFn: (): Promise<ListResult<SearchTicket>> =>
|
||||
pb.collection('ticket_search').getList(page.value, perPage.value, {
|
||||
sort: '-created',
|
||||
filter: filter.value,
|
||||
expand:
|
||||
'type,owner,comments_via_ticket.author,files_via_ticket,timeline_via_ticket,links_via_ticket,tasks_via_ticket.owner'
|
||||
filter: filter.value
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,9 +134,9 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
|
||||
<Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 pt-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all"> All</TabsTrigger>
|
||||
<TabsTrigger value="open"> Open</TabsTrigger>
|
||||
<TabsTrigger value="closed"> Closed</TabsTrigger>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="open">Open</TabsTrigger>
|
||||
<TabsTrigger value="closed">Closed</TabsTrigger>
|
||||
</TabsList>
|
||||
<!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3">
|
||||
<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">
|
||||
<Search class="size-4 text-muted-foreground" />
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -222,7 +200,6 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
|
||||
<PaginationNext />
|
||||
<PaginationLast />
|
||||
</PaginationList>
|
||||
|
||||
@@ -3,12 +3,12 @@ import ResourceListElement from '@/components/common/ResourceListElement.vue'
|
||||
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import type { Ticket } from '@/lib/types'
|
||||
import type { SearchTicket } from '@/lib/types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
defineProps<{
|
||||
tickets: Array<Ticket>
|
||||
tickets: Array<SearchTicket>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -19,10 +19,10 @@ defineProps<{
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:created="item.created"
|
||||
:subtitle="item.expand.owner ? item.expand.owner.name : ''"
|
||||
:subtitle="item.owner_name"
|
||||
:description="item.description ? item.description.substring(0, 300) : ''"
|
||||
:active="route.params.id === item.id"
|
||||
:to="`/tickets/${item.expand.type.id}/${item.id}`"
|
||||
:to="`/tickets/${item.type}/${item.id}`"
|
||||
:open="item.open"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
id: string
|
||||
|
||||
@@ -126,6 +136,7 @@ export interface JSONSchema {
|
||||
title: string
|
||||
type: string
|
||||
description?: string
|
||||
enum?: Array<string>
|
||||
}
|
||||
>
|
||||
required?: Array<string>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import PasswordResetView from '@/views/PasswordResetView.vue'
|
||||
import ReactionView from '@/views/ReactionView.vue'
|
||||
import TicketView from '@/views/TicketView.vue'
|
||||
|
||||
@@ -12,25 +13,30 @@ const router = createRouter({
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/reactions/:id?',
|
||||
name: 'reactions',
|
||||
component: ReactionView
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: DashboardView
|
||||
},
|
||||
{
|
||||
path: '/tickets/:type/:id?',
|
||||
name: 'tickets',
|
||||
component: TicketView
|
||||
},
|
||||
{
|
||||
path: '/reactions/:id?',
|
||||
name: 'reactions',
|
||||
component: ReactionView
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView
|
||||
},
|
||||
{
|
||||
path: '/tickets/:type/:id?',
|
||||
name: 'tickets',
|
||||
component: TicketView
|
||||
path: '/password-reset',
|
||||
name: 'password-reset',
|
||||
component: PasswordResetView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CatalystLogo from '@/components/common/CatalystLogo.vue'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
|
||||
@@ -8,9 +9,11 @@ import { useQuery } from '@tanstack/vue-query'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const mail = ref('')
|
||||
const password = ref('')
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const login = () => {
|
||||
@@ -20,6 +23,7 @@ const login = () => {
|
||||
window.location.href = '/ui/'
|
||||
})
|
||||
.catch((error) => {
|
||||
errorTitle.value = 'Login failed'
|
||||
errorMessage.value = error.message
|
||||
})
|
||||
}
|
||||
@@ -37,7 +41,8 @@ watch(
|
||||
mail.value = 'user@catalyst-soar.com'
|
||||
password.value = '1234567890'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -45,12 +50,18 @@ watch(
|
||||
<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>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>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<Alert v-if="errorMessage" variant="destructive" class="border-4 p-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
<Alert v-if="errorTitle || errorMessage" variant="destructive" class="border-4 p-4">
|
||||
<AlertTitle v-if="errorTitle">{{ errorTitle }}</AlertTitle>
|
||||
<AlertDescription v-if="errorMessage">{{ errorMessage }}</AlertDescription>
|
||||
</Alert>
|
||||
<Input
|
||||
v-model="mail"
|
||||
@@ -66,7 +77,14 @@ watch(
|
||||
class="w-full"
|
||||
@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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
79
ui/src/views/PasswordResetView.vue
Normal file
79
ui/src/views/PasswordResetView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user