This commit is contained in:
Henry Dollman
2024-07-13 23:51:22 -04:00
parent 054a56c316
commit 0af3138ef7
17 changed files with 267 additions and 201 deletions

16
main.go
View File

@@ -41,20 +41,6 @@ func main() {
Automigrate: isGoRun, Automigrate: isGoRun,
}) })
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
// update app settings on first run
settings := app.Settings()
if app.Settings().Meta.AppName == "Acme" {
app.Settings().Meta.AppName = "Qoma"
app.Settings().Meta.HideControls = true
err := app.Dao().SaveSettings(settings)
if err != nil {
return err
}
}
return nil
})
// serve site // serve site
app.OnBeforeServe().Add(func(e *core.ServeEvent) error { app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
switch isGoRun { switch isGoRun {
@@ -116,7 +102,7 @@ func main() {
// api route to return public key // api route to return public key
e.Router.GET("/api/qoma/getkey", func(c echo.Context) error { e.Router.GET("/api/qoma/getkey", func(c echo.Context) error {
requestData := apis.RequestInfo(c) requestData := apis.RequestInfo(c)
if requestData.Admin == nil { if requestData.AuthRecord == nil {
return apis.NewForbiddenError("Forbidden", nil) return apis.NewForbiddenError("Forbidden", nil)
} }
key, err := os.ReadFile("./pb_data/id_ed25519.pub") key, err := os.ReadFile("./pb_data/id_ed25519.pub")

View File

@@ -12,73 +12,10 @@ import (
func init() { func init() {
m.Register(func(db dbx.Builder) error { m.Register(func(db dbx.Builder) error {
jsonData := `[ jsonData := `[
{
"id": "_pb_users_auth_",
"created": "2024-07-07 15:59:04.262Z",
"updated": "2024-07-09 23:42:40.542Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_name",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": "",
"updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id",
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
},
{ {
"id": "2hz5ncl8tizk5nx", "id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z", "created": "2024-07-07 16:08:20.979Z",
"updated": "2024-07-13 23:20:50.678Z", "updated": "2024-07-14 03:36:23.090Z",
"name": "systems", "name": "systems",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -156,17 +93,17 @@ func init() {
} }
], ],
"indexes": [], "indexes": [],
"listRule": null, "listRule": "",
"viewRule": null, "viewRule": "@request.auth.id != \"\"",
"createRule": null, "createRule": "@request.auth.id != \"\" && @request.auth.admin = true",
"updateRule": null, "updateRule": "",
"deleteRule": null, "deleteRule": "@request.auth.id != \"\" && @request.auth.admin = true",
"options": {} "options": {}
}, },
{ {
"id": "ej9oowivz8b2mht", "id": "ej9oowivz8b2mht",
"created": "2024-07-07 16:09:09.179Z", "created": "2024-07-07 16:09:09.179Z",
"updated": "2024-07-09 23:42:40.542Z", "updated": "2024-07-14 03:36:23.089Z",
"name": "system_stats", "name": "system_stats",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -203,7 +140,7 @@ func init() {
"indexes": [ "indexes": [
"CREATE INDEX ` + "`" + `idx_GxIee0j` + "`" + ` ON ` + "`" + `system_stats` + "`" + ` (` + "`" + `system` + "`" + `)" "CREATE INDEX ` + "`" + `idx_GxIee0j` + "`" + ` ON ` + "`" + `system_stats` + "`" + ` (` + "`" + `system` + "`" + `)"
], ],
"listRule": null, "listRule": "@request.auth.id != \"\"",
"viewRule": null, "viewRule": null,
"createRule": null, "createRule": null,
"updateRule": null, "updateRule": null,
@@ -213,7 +150,7 @@ func init() {
{ {
"id": "juohu4jipgc13v7", "id": "juohu4jipgc13v7",
"created": "2024-07-07 16:09:57.976Z", "created": "2024-07-07 16:09:57.976Z",
"updated": "2024-07-09 23:42:40.542Z", "updated": "2024-07-14 03:36:23.090Z",
"name": "container_stats", "name": "container_stats",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -248,12 +185,71 @@ func init() {
} }
], ],
"indexes": [], "indexes": [],
"listRule": null, "listRule": "@request.auth.id != \"\"",
"viewRule": null, "viewRule": null,
"createRule": null, "createRule": null,
"updateRule": null, "updateRule": null,
"deleteRule": null, "deleteRule": null,
"options": {} "options": {}
},
{
"id": "_pb_users_auth_",
"created": "2024-07-14 03:36:23.076Z",
"updated": "2024-07-14 03:36:23.087Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
},
{
"system": false,
"id": "ebyl7gfs",
"name": "admin",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
} }
]` ]`

View File

@@ -0,0 +1,19 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db)
settings, _ := dao.FindSettings()
settings.Meta.AppName = "Qoma"
settings.Meta.HideControls = true
return dao.SaveSettings(settings)
}, nil)
}

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.7 81.8"><path fill="#16a34a" d="M76 29v14a33 33 0 0 1-1 7 30 30 0 0 1-1 2 29 29 0 0 1-3 7 27 27 0 0 1 0 1 27 27 0 0 1-6 6 27 27 0 0 1-7 4l14 12H54L42 72H29a32 32 0 0 1-8-1 29 29 0 0 1-3-1q-6-2-10-6a28 28 0 0 1-6-10 30 30 0 0 1-2-9 35 35 0 0 1 0-2V29a32 32 0 0 1 1-8 28 28 0 0 1 1-3 28 28 0 0 1 5-9 27 27 0 0 1 1-1 28 28 0 0 1 10-6 32 32 0 0 1 0 0 30 30 0 0 1 9-2 35 35 0 0 1 2 0h17a32 32 0 0 1 9 1 28 28 0 0 1 3 1 28 28 0 0 1 9 6 27 27 0 0 1 6 9 31 31 0 0 1 0 1 30 30 0 0 1 3 9 35 35 0 0 1 0 2ZM63 43V29a20 20 0 0 0 0-4 17 17 0 0 0-1-3 15 15 0 0 0-3-4 14 14 0 0 0-1-1 15 15 0 0 0-4-3 17 17 0 0 0-1 0 17 17 0 0 0-5-1 21 21 0 0 0-2 0H29a20 20 0 0 0-4 0 17 17 0 0 0-2 1l-6 3a15 15 0 0 0-3 5 17 17 0 0 0 0 0 17 17 0 0 0-1 5 22 22 0 0 0 0 2v14a20 20 0 0 0 0 4 17 17 0 0 0 1 2 15 15 0 0 0 3 5 14 14 0 0 0 0 1 15 15 0 0 0 5 3 17 17 0 0 0 1 0 17 17 0 0 0 4 1 21 21 0 0 0 2 0h17a20 20 0 0 0 4 0 17 17 0 0 0 3-1l5-3a15 15 0 0 0 4-5 17 17 0 0 0 0-1 17 17 0 0 0 1-4 22 22 0 0 0 0-2Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.7 81.8"><path fill="#22c55e" d="M76 29v14a33 33 0 0 1-1 7 30 30 0 0 1-1 2 29 29 0 0 1-3 7 27 27 0 0 1 0 1 27 27 0 0 1-6 6 27 27 0 0 1-7 4l14 12H54L42 72H29a32 32 0 0 1-8-1 29 29 0 0 1-3-1q-6-2-10-6a28 28 0 0 1-6-10 30 30 0 0 1-2-9 35 35 0 0 1 0-2V29a32 32 0 0 1 1-8 28 28 0 0 1 1-3 28 28 0 0 1 5-9 27 27 0 0 1 1-1 28 28 0 0 1 10-6 32 32 0 0 1 0 0 30 30 0 0 1 9-2 35 35 0 0 1 2 0h17a32 32 0 0 1 9 1 28 28 0 0 1 3 1 28 28 0 0 1 9 6 27 27 0 0 1 6 9 31 31 0 0 1 0 1 30 30 0 0 1 3 9 35 35 0 0 1 0 2ZM63 43V29a20 20 0 0 0 0-4 17 17 0 0 0-1-3 15 15 0 0 0-3-4 14 14 0 0 0-1-1 15 15 0 0 0-4-3 17 17 0 0 0-1 0 17 17 0 0 0-5-1 21 21 0 0 0-2 0H29a20 20 0 0 0-4 0 17 17 0 0 0-2 1l-6 3a15 15 0 0 0-3 5 17 17 0 0 0 0 0 17 17 0 0 0-1 5 22 22 0 0 0 0 2v14a20 20 0 0 0 0 4 17 17 0 0 0 1 2 15 15 0 0 0 3 5 14 14 0 0 0 0 1 15 15 0 0 0 5 3 17 17 0 0 0 1 0 17 17 0 0 0 4 1 21 21 0 0 0 2 0h17a20 20 0 0 0 4 0 17 17 0 0 0 3-1l5-3a15 15 0 0 0 4-5 17 17 0 0 0 0-1 17 17 0 0 0 1-4 22 22 0 0 0 0-2Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -79,17 +79,17 @@ export function AddServerButton() {
Add <span className="hidden sm:inline">System</span> Add <span className="hidden sm:inline">System</span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New System</DialogTitle> <DialogTitle className="mb-2">Add New System</DialogTitle>
<DialogDescription> <DialogDescription>
The agent must be running on the server to connect. Copy the{' '} The agent must be running on the server to connect. Copy the{' '}
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent <code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent
below. below.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form name="testing" action="/" onSubmit={handleSubmit as any}> <form onSubmit={handleSubmit as any}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 mt-1 mb-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
Name Name
@@ -116,7 +116,7 @@ export function AddServerButton() {
/> />
</div> </div>
<div className="grid grid-cols-4 items-center gap-4 relative"> <div className="grid grid-cols-4 items-center gap-4 relative">
<Label htmlFor="pkey" className="text-right"> <Label htmlFor="pkey" className="text-right whitespace-pre">
Public Key Public Key
</Label> </Label>
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input> <Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input>
@@ -144,7 +144,7 @@ export function AddServerButton() {
</TooltipProvider> </TooltipProvider>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className="flex justify-end gap-2">
<Button <Button
type="button" type="button"
variant={'ghost'} variant={'ghost'}

View File

@@ -19,7 +19,7 @@ const chartConfig = {
}, },
} satisfies ChartConfig } satisfies ChartConfig
export default function ({ chartData }: { chartData: { time: string; cpu: number }[] }) { export default function CpuChart({ chartData }: { chartData: { time: string; cpu: number }[] }) {
if (!chartData?.length) { if (!chartData?.length) {
return <Spinner /> return <Spinner />
} }

View File

@@ -20,7 +20,7 @@ const chartConfig = {
}, },
} satisfies ChartConfig } satisfies ChartConfig
export default function ({ export default function DiskChart({
chartData, chartData,
}: { }: {
chartData: { time: string; disk: number; diskUsed: number }[] chartData: { time: string; disk: number; diskUsed: number }[]

View File

@@ -22,6 +22,7 @@ import {
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $servers, navigate } from '@/lib/stores' import { $servers, navigate } from '@/lib/stores'
import { isAdmin } from '@/lib/utils'
export default function CommandPalette() { export default function CommandPalette() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -82,38 +83,42 @@ export default function CommandPalette() {
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
<CommandSeparator /> {isAdmin() && (
<CommandGroup heading="Admin"> <>
<CommandItem <CommandSeparator />
onSelect={() => { <CommandGroup heading="Admin">
window.location.href = '/_/#/collections?collectionId=2hz5ncl8tizk5nx' <CommandItem
}} onSelect={() => {
> window.location.href = '/_/'
<Database className="mr-2 h-4 w-4" /> }}
<span>PocketBase</span> >
<CommandShortcut>Admin</CommandShortcut> <Database className="mr-2 h-4 w-4" />
</CommandItem> <span>PocketBase</span>
<CommandItem <CommandShortcut>Admin</CommandShortcut>
keywords={['email']} </CommandItem>
onSelect={() => { <CommandItem
window.location.href = '/_/#/settings/backups' keywords={['email']}
}} onSelect={() => {
> window.location.href = '/_/#/settings/backups'
<DatabaseBackupIcon className="mr-2 h-4 w-4" /> }}
<span>Database backups</span> >
<CommandShortcut>Admin</CommandShortcut> <DatabaseBackupIcon className="mr-2 h-4 w-4" />
</CommandItem> <span>Database backups</span>
<CommandItem <CommandShortcut>Admin</CommandShortcut>
keywords={['email']} </CommandItem>
onSelect={() => { <CommandItem
window.location.href = '/_/#/settings/mail' keywords={['email']}
}} onSelect={() => {
> window.location.href = '/_/#/settings/mail'
<MailIcon className="mr-2 h-4 w-4" /> }}
<span>SMTP settings</span> >
<CommandShortcut>Admin</CommandShortcut> <MailIcon className="mr-2 h-4 w-4" />
</CommandItem> <span>SMTP settings</span>
</CommandGroup> <CommandShortcut>Admin</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
) )

View File

@@ -24,7 +24,7 @@ export default function () {
<span className="sr-only">Qoma</span> <span className="sr-only">Qoma</span>
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isFirstRun ? 'Please create your admin account' : 'Please sign in to your account'} {isFirstRun ? 'Please create an admin account' : 'Please sign in to your account'}
</p> </p>
</div> </div>
<UserAuthForm isFirstRun={isFirstRun} /> <UserAuthForm isFirstRun={isFirstRun} />

View File

@@ -21,7 +21,7 @@ export function ModeToggle() {
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent>
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>

View File

@@ -1,5 +1,4 @@
import { Suspense, lazy, useEffect } from 'react' import { Suspense, lazy, useEffect } from 'react'
// import { DataTable } from '../server-table/data-table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
const SystemsTable = lazy(() => import('../server-table/systems-table')) const SystemsTable = lazy(() => import('../server-table/systems-table'))

View File

@@ -4,14 +4,18 @@ import { Suspense, lazy, useEffect, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import Spinner from '../spinner' import Spinner from '../spinner'
import CpuChart from '../charts/cpu-chart' // import CpuChart from '../charts/cpu-chart'
import MemChart from '../charts/mem-chart' // import MemChart from '../charts/mem-chart'
import DiskChart from '../charts/disk-chart' // import DiskChart from '../charts/disk-chart'
import ContainerCpuChart from '../charts/container-cpu-chart' // import ContainerCpuChart from '../charts/container-cpu-chart'
// import ContainerMemChart from '../charts/container-mem-chart'
import { CpuIcon, MemoryStickIcon } from 'lucide-react' import { CpuIcon, MemoryStickIcon } from 'lucide-react'
import ContainerMemChart from '../charts/container-mem-chart'
// const CpuChart = lazy(() => import('../cpu-chart')) const CpuChart = lazy(() => import('../charts/cpu-chart'))
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
const MemChart = lazy(() => import('../charts/mem-chart'))
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
const DiskChart = lazy(() => import('../charts/disk-chart'))
function timestampToBrowserTime(timestamp: string) { function timestampToBrowserTime(timestamp: string) {
const date = new Date(timestamp) const date = new Date(timestamp)
@@ -138,6 +142,9 @@ export default function ServerDetail({ name }: { name: string }) {
return ( return (
<> <>
<div className="grid gap-6 mb-10"> <div className="grid gap-6 mb-10">
<div className="p-6">
<h1 className="text-3xl font-semibold">{name}</h1>
</div>
<Card className="pb-2"> <Card className="pb-2">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2 justify-between"> <CardTitle className="flex gap-2 justify-between">
@@ -203,9 +210,9 @@ export default function ServerDetail({ name }: { name: string }) {
<CardDescription>Precise usage at the recorded time</CardDescription> <CardDescription>Precise usage at the recorded time</CardDescription>
</CardHeader> </CardHeader>
<CardContent className={'pl-1 w-[calc(100%-2em)] h-52 relative'}> <CardContent className={'pl-1 w-[calc(100%-2em)] h-52 relative'}>
{/* <Suspense fallback={<Spinner />}> */} <Suspense fallback={<Spinner />}>
<DiskChart chartData={diskChartData} /> <DiskChart chartData={diskChartData} />
{/* </Suspense> */} </Suspense>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -61,7 +61,7 @@ import { useMemo, useState } from 'react'
import { $servers, pb, navigate } from '@/lib/stores' import { $servers, pb, navigate } from '@/lib/stores'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { AddServerButton } from '../add-server' import { AddServerButton } from '../add-server'
import { cn, copyToClipboard } from '@/lib/utils' import { cn, copyToClipboard, isAdmin } from '@/lib/utils'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -75,7 +75,7 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = info.getValue() as number const val = info.getValue() as number
return ( return (
<div className="flex gap-2 items-center tabular-nums tracking-tight"> <div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="grow min-w-10 block bg-muted h-4 relative rounded-sm overflow-hidden"> <span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span <span
className={cn( className={cn(
'absolute inset-0 w-full h-full origin-left', 'absolute inset-0 w-full h-full origin-left',
@@ -288,9 +288,11 @@ export default function SystemsTable() {
onChange={(event) => table.getColumn('name')?.setFilterValue(event.target.value)} onChange={(event) => table.getColumn('name')?.setFilterValue(event.target.value)}
className="max-w-sm" className="max-w-sm"
/> />
<div className="ml-auto flex gap-2"> {isAdmin() && (
<AddServerButton /> <div className="ml-auto flex gap-2">
</div> <AddServerButton />
</div>
)}
</div> </div>
<div className="rounded-md border overflow-hidden"> <div className="rounded-md border overflow-hidden">
<Table> <Table>

View File

@@ -3,8 +3,8 @@ import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Github, LoaderCircle, LogInIcon } from 'lucide-react' import { Github, LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react'
import { pb } from '@/lib/stores' import { $authenticated, pb } from '@/lib/stores'
import * as v from 'valibot' import * as v from 'valibot'
import { toast } from './ui/use-toast' import { toast } from './ui/use-toast'
import { import {
@@ -24,13 +24,21 @@ const passwordSchema = v.pipe(
) )
const LoginSchema = v.looseObject({ const LoginSchema = v.looseObject({
username: honeypot, name: honeypot,
email: emailSchema, email: emailSchema,
password: passwordSchema, password: passwordSchema,
}) })
const RegisterSchema = v.looseObject({ const RegisterSchema = v.looseObject({
username: honeypot, name: honeypot,
username: v.pipe(
v.string(),
v.regex(
/^(?=.*[a-zA-Z])[a-zA-Z0-9_-]+$/,
'Invalid username. You may use alphanumeric characters, underscores, and hyphens.'
),
v.minLength(3, 'Username must be at least 3 characters long.')
),
email: emailSchema, email: emailSchema,
password: passwordSchema, password: passwordSchema,
passwordConfirm: passwordSchema, passwordConfirm: passwordSchema,
@@ -68,7 +76,7 @@ export function UserAuthForm({
setErrors(errors) setErrors(errors)
return return
} }
const { email, password, passwordConfirm } = result.output const { email, password, passwordConfirm, username } = result.output
if (isFirstRun) { if (isFirstRun) {
// check that passwords match // check that passwords match
if (password !== passwordConfirm) { if (password !== passwordConfirm) {
@@ -82,9 +90,19 @@ export function UserAuthForm({
passwordConfirm: password, passwordConfirm: password,
}) })
await pb.admins.authWithPassword(email, password) await pb.admins.authWithPassword(email, password)
await pb.collection('users').create({
username,
email,
password,
passwordConfirm: password,
admin: true,
verified: true,
})
await pb.collection('users').authWithPassword(email, password)
} else { } else {
await pb.admins.authWithPassword(email, password) await pb.collection('users').authWithPassword(email, password)
} }
$authenticated.set(true)
} catch (e) { } catch (e) {
return toast({ return toast({
title: 'Login attempt failed', title: 'Login attempt failed',
@@ -100,30 +118,49 @@ export function UserAuthForm({
<div className={cn('grid gap-6', className)} {...props}> <div className={cn('grid gap-6', className)} {...props}>
<form onSubmit={handleSubmit} onChange={() => setErrors({})}> <form onSubmit={handleSubmit} onChange={() => setErrors({})}>
<div className="grid gap-2.5"> <div className="grid gap-2.5">
<div className="sr-only"> {isFirstRun && (
{/* honeypot */} <div className="grid gap-1 relative">
<label htmlFor="username"></label> <UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<input id="username" type="text" name="username" tabIndex={-1} /> <Label className="sr-only" htmlFor="username">
</div> Username
<div className="grid gap-1"> </Label>
<Input
autoFocus={true}
id="username"
name="username"
required
placeholder="username"
type="username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
disabled={isLoading || isGitHubLoading}
className="pl-9"
/>
{errors?.username && <p className="px-1 text-xs text-red-600">{errors.username}</p>}
</div>
)}
<div className="grid gap-1 relative">
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="email"> <Label className="sr-only" htmlFor="email">
Email Email
</Label> </Label>
<Input <Input
autoFocus={true}
id="email" id="email"
name="email" name="email"
required required
placeholder="name@example.com" placeholder={isFirstRun ? 'email' : 'name@example.com'}
type="email" type="email"
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
autoCorrect="off" autoCorrect="off"
disabled={isLoading || isGitHubLoading} disabled={isLoading || isGitHubLoading}
className="pl-9"
/> />
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>} {errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1 relative">
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="pass"> <Label className="sr-only" htmlFor="pass">
Password Password
</Label> </Label>
@@ -135,11 +172,13 @@ export function UserAuthForm({
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
disabled={isLoading || isGitHubLoading} disabled={isLoading || isGitHubLoading}
className="pl-9"
/> />
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>} {errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
</div> </div>
{isFirstRun && ( {isFirstRun && (
<div className="grid gap-1"> <div className="grid gap-1 relative">
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="pass2"> <Label className="sr-only" htmlFor="pass2">
Confirm password Confirm password
</Label> </Label>
@@ -151,12 +190,18 @@ export function UserAuthForm({
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
disabled={isLoading || isGitHubLoading} disabled={isLoading || isGitHubLoading}
className="pl-9"
/> />
{errors?.passwordConfirm && ( {errors?.passwordConfirm && (
<p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p> <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>
)} )}
</div> </div>
)} )}
<div className="sr-only">
{/* honeypot */}
<label htmlFor="name"></label>
<input id="name" type="text" name="name" tabIndex={-1} />
</div>
<button className={cn(buttonVariants())} disabled={isLoading}> <button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />

View File

@@ -4,8 +4,6 @@ import { SystemRecord } from '@/types'
import { createRouter } from '@nanostores/router' import { createRouter } from '@nanostores/router'
export const pb = new PocketBase('/') export const pb = new PocketBase('/')
// @ts-ignore
pb.authStore.storageKey = 'pb_admin_auth'
export const $router = createRouter( export const $router = createRouter(
{ {
@@ -20,9 +18,6 @@ export const navigate = (urlString: string) => {
} }
export const $authenticated = atom(pb.authStore.isValid) export const $authenticated = atom(pb.authStore.isValid)
pb.authStore.onChange(() => {
$authenticated.set(pb.authStore.isValid)
})
export const $servers = atom([] as SystemRecord[]) export const $servers = atom([] as SystemRecord[])

View File

@@ -54,3 +54,5 @@ export const formatShortDate = (timestamp: string) => shortDateFormatter.format(
export const updateFavicon = (newIconUrl: string) => export const updateFavicon = (newIconUrl: string) =>
((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = newIconUrl) ((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = newIconUrl)
export const isAdmin = () => pb.authStore.model?.admin

View File

@@ -5,7 +5,7 @@ import Home from './components/routes/home.tsx'
import { ThemeProvider } from './components/theme-provider.tsx' import { ThemeProvider } from './components/theme-provider.tsx'
import { $authenticated, $router, $servers, navigate, pb } from './lib/stores.ts' import { $authenticated, $router, $servers, navigate, pb } from './lib/stores.ts'
import { ModeToggle } from './components/mode-toggle.tsx' import { ModeToggle } from './components/mode-toggle.tsx'
import { cn, updateFavicon, updateServerList } from './lib/utils.ts' import { cn, isAdmin, updateFavicon, updateServerList } from './lib/utils.ts'
import { buttonVariants } from './components/ui/button.tsx' import { buttonVariants } from './components/ui/button.tsx'
import { DatabaseBackupIcon, Github, LogOutIcon, LogsIcon, MailIcon, UserIcon } from 'lucide-react' import { DatabaseBackupIcon, Github, LogOutIcon, LogsIcon, MailIcon, UserIcon } from 'lucide-react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@@ -35,8 +35,14 @@ const App = () => {
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
const servers = useStore($servers) const servers = useStore($servers)
// get servers useEffect(() => {
useEffect(updateServerList, []) // get servers
updateServerList()
// change auth store on auth change
pb.authStore.onChange(() => {
$authenticated.set(pb.authStore.isValid)
})
}, [])
useEffect(() => { useEffect(() => {
pb.collection<SystemRecord>('systems').subscribe('*', (e) => { pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
@@ -128,36 +134,7 @@ const Layout = () => {
</a> </a>
<div className={'flex ml-auto'}> <div className={'flex ml-auto'}>
<DropdownMenu> <ModeToggle />
<DropdownMenuTrigger asChild>
<a
aria-label="User Actions"
href={'https://github.com/henrygd'}
className={cn('', buttonVariants({ variant: 'ghost', size: 'icon' }))}
>
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
</a>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => pb.authStore.clear()}>
<LogOutIcon className="mr-2.5 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="/_/#/logs">
<LogsIcon className="mr-2.5 h-4 w-4" />
<span>Logs</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/_/#/settings/backups">
<DatabaseBackupIcon className="mr-2.5 h-4 w-4" />
<span>Backups</span>
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -175,7 +152,40 @@ const Layout = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<ModeToggle /> <DropdownMenu>
<DropdownMenuTrigger asChild>
<a
aria-label="User Actions"
href={'https://github.com/henrygd'}
className={cn('', buttonVariants({ variant: 'ghost', size: 'icon' }))}
>
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
</a>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => pb.authStore.clear()}>
<LogOutIcon className="mr-2.5 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
{isAdmin() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="/_/#/logs">
<LogsIcon className="mr-2.5 h-4 w-4" />
<span>Logs</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/_/#/settings/backups">
<DatabaseBackupIcon className="mr-2.5 h-4 w-4" />
<span>Backups</span>
</a>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
</div> </div>