user creation and other updates

This commit is contained in:
Henry Dollman
2024-07-13 19:43:14 -04:00
parent 357e3ad5d7
commit 054a56c316
10 changed files with 177 additions and 54 deletions

30
main.go
View File

@@ -41,6 +41,20 @@ 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 {
@@ -95,11 +109,12 @@ func main() {
return nil return nil
}) })
// ssh key setup
app.OnBeforeServe().Add(func(e *core.ServeEvent) error { app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// create ssh key if it doesn't exist // create ssh key if it doesn't exist
getSSHKey() getSSHKey()
// api route to return public key // api route to return public key
e.Router.GET("/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.Admin == nil {
return apis.NewForbiddenError("Forbidden", nil) return apis.NewForbiddenError("Forbidden", nil)
@@ -113,6 +128,19 @@ func main() {
return nil return nil
}) })
// other api routes
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// check if first time setup on login page
e.Router.GET("/api/qoma/first-run", func(c echo.Context) error {
adminNum, err := app.Dao().TotalAdmins()
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
})
return nil
})
// start ticker for server updates // start ticker for server updates
app.OnBeforeServe().Add(func(e *core.ServeEvent) error { app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
go serverUpdateTicker() go serverUpdateTicker()

View File

@@ -78,7 +78,7 @@ func init() {
{ {
"id": "2hz5ncl8tizk5nx", "id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z", "created": "2024-07-07 16:08:20.979Z",
"updated": "2024-07-13 01:18:43.529Z", "updated": "2024-07-13 23:20:50.678Z",
"name": "systems", "name": "systems",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -155,9 +155,7 @@ func init() {
} }
} }
], ],
"indexes": [ "indexes": [],
"CREATE UNIQUE INDEX ` + "`" + `idx_eggNgAn` + "`" + ` ON ` + "`" + `systems` + "`" + ` (` + "`" + `name` + "`" + `)"
],
"listRule": null, "listRule": null,
"viewRule": null, "viewRule": null,
"createRule": null, "createRule": null,

View File

@@ -43,7 +43,7 @@ export function AddServerButton() {
return return
} }
// get public key // get public key
pb.send('/getkey', {}).then(({ key }) => { pb.send('/api/qoma/getkey', {}).then(({ key }) => {
$publicKey.set(key) $publicKey.set(key)
}) })
}, [open]) }, [open])

View File

@@ -1,6 +1,13 @@
'use client' 'use client'
import { Database, Github, Home, Server } from 'lucide-react' import {
Database,
DatabaseBackupIcon,
Github,
LayoutDashboard,
MailIcon,
Server,
} from 'lucide-react'
import { import {
CommandDialog, CommandDialog,
@@ -16,7 +23,7 @@ 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'
export default function () { export default function CommandPalette() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const servers = useStore($servers) const servers = useStore($servers)
@@ -39,24 +46,16 @@ export default function () {
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions"> <CommandGroup heading="Suggestions">
<CommandItem <CommandItem
keywords={['home']}
onSelect={() => { onSelect={() => {
navigate('/') navigate('/')
setOpen((open) => !open) setOpen((open) => !open)
}} }}
> >
<Home className="mr-2 h-4 w-4" /> <LayoutDashboard className="mr-2 h-4 w-4" />
<span>Home</span> <span>Dashboard</span>
<CommandShortcut>Page</CommandShortcut> <CommandShortcut>Page</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem
onSelect={() => {
window.location.href = '/_/#/collections?collectionId=2hz5ncl8tizk5nx'
}}
>
<Database className="mr-2 h-4 w-4" />
<span>Admin UI</span>
<CommandShortcut>PocketBase</CommandShortcut>
</CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
window.location.href = 'https://github.com/henrygd' window.location.href = 'https://github.com/henrygd'
@@ -79,9 +78,42 @@ export default function () {
> >
<Server className="mr-2 h-4 w-4" /> <Server className="mr-2 h-4 w-4" />
<span>{server.name}</span> <span>{server.name}</span>
<CommandShortcut>{server.host}</CommandShortcut>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
<CommandSeparator />
<CommandGroup heading="Admin">
<CommandItem
onSelect={() => {
window.location.href = '/_/#/collections?collectionId=2hz5ncl8tizk5nx'
}}
>
<Database className="mr-2 h-4 w-4" />
<span>PocketBase</span>
<CommandShortcut>Admin</CommandShortcut>
</CommandItem>
<CommandItem
keywords={['email']}
onSelect={() => {
window.location.href = '/_/#/settings/backups'
}}
>
<DatabaseBackupIcon className="mr-2 h-4 w-4" />
<span>Database backups</span>
<CommandShortcut>Admin</CommandShortcut>
</CommandItem>
<CommandItem
keywords={['email']}
onSelect={() => {
window.location.href = '/_/#/settings/mail'
}}
>
<MailIcon className="mr-2 h-4 w-4" />
<span>SMTP settings</span>
<CommandShortcut>Admin</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
) )

View File

@@ -1,11 +1,19 @@
import { UserAuthForm } from '@/components/user-auth-form' import { UserAuthForm } from '@/components/user-auth-form'
import { Logo } from './logo' import { Logo } from './logo'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { pb } from '@/lib/stores'
export default function () { export default function () {
const [isFirstRun, setFirstRun] = useState(false)
useEffect(() => { useEffect(() => {
document.title = 'Login / Qoma' document.title = 'Login / Qoma'
pb.send('/api/qoma/first-run', {}).then(({ firstRun }) => {
setFirstRun(firstRun)
})
}, []) }, [])
return ( return (
<div className="relative h-screen grid lg:max-w-none lg:px-0"> <div className="relative h-screen grid lg:max-w-none lg:px-0">
<div className="grid items-center py-12"> <div className="grid items-center py-12">
@@ -15,9 +23,11 @@ export default function () {
<Logo className="h-7 fill-foreground mx-auto" /> <Logo className="h-7 fill-foreground mx-auto" />
<span className="sr-only">Qoma</span> <span className="sr-only">Qoma</span>
</h1> </h1>
<p className="text-sm text-muted-foreground">Please sign in to your account</p> <p className="text-sm text-muted-foreground">
{isFirstRun ? 'Please create your admin account' : 'Please sign in to your account'}
</p>
</div> </div>
<UserAuthForm /> <UserAuthForm isFirstRun={isFirstRun} />
<p className="text-center text-sm opacity-70 hover:opacity-100 transition-opacity"> <p className="text-center text-sm opacity-70 hover:opacity-100 transition-opacity">
{/* todo: add forgot password section to readme and link to section {/* todo: add forgot password section to readme and link to section
reset w/ command or link to pb reset */} reset w/ command or link to pb reset */}

View File

@@ -2,7 +2,7 @@ import { Suspense, lazy, useEffect } from 'react'
// import { DataTable } from '../server-table/data-table' // 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 DataTable = lazy(() => import('../server-table/data-table')) const SystemsTable = lazy(() => import('../server-table/systems-table'))
export default function () { export default function () {
useEffect(() => { useEffect(() => {
@@ -24,7 +24,7 @@ export default function () {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Suspense> <Suspense>
<DataTable /> <SystemsTable />
</Suspense> </Suspense>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -103,7 +103,7 @@ function sortableHeader(column: Column<SystemRecord, unknown>, name: string, Ico
) )
} }
export default function () { export default function SystemsTable() {
const data = useStore($servers) const data = useStore($servers)
// const [deleteServer, setDeleteServer] = useState({} as SystemRecord) // const [deleteServer, setDeleteServer] = useState({} as SystemRecord)
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
@@ -135,7 +135,7 @@ export default function () {
onClick={() => copyToClipboard(info.getValue() as string)} onClick={() => copyToClipboard(info.getValue() as string)}
> >
{info.getValue() as string} {info.getValue() as string}
<CopyIcon className="h-3 w-3" /> <CopyIcon className="h-2.5 w-2.5" />
</Button> </Button>
</span> </span>
) )

View File

@@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog' import { DialogTitle, type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk' import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
@@ -27,6 +27,9 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg"> <DialogContent className="overflow-hidden p-0 shadow-lg">
<div className="sr-only">
<DialogTitle>Command</DialogTitle>
</div>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children} {children}
</Command> </Command>

View File

@@ -1,17 +1,8 @@
'use client'
import * as React from 'react' import * as React from 'react'
// import { useSearchParams } from 'next/navigation'
// import { zodResolver } from '@hookform/resolvers/zod'
// import { signIn } from 'next-auth/react'
// import { useForm } from 'react-hook-form'
// import * as z from 'zod'
import { cn } from '@/lib/utils' 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 { toast } from '@/components/ui/use-toast'
import { Github, LoaderCircle, LogInIcon } from 'lucide-react' import { Github, LoaderCircle, LogInIcon } from 'lucide-react'
import { pb } from '@/lib/stores' import { pb } from '@/lib/stores'
import * as v from 'valibot' import * as v from 'valibot'
@@ -25,14 +16,34 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
const LoginSchema = v.object({ const honeypot = v.literal('')
email: v.pipe(v.string(), v.email('Invalid email address.')), const emailSchema = v.pipe(v.string(), v.email('Invalid email address.'))
password: v.pipe(v.string(), v.minLength(10, 'Password must be at least 10 characters long.')), const passwordSchema = v.pipe(
v.string(),
v.minLength(10, 'Password must be at least 10 characters.')
)
const LoginSchema = v.looseObject({
username: honeypot,
email: emailSchema,
password: passwordSchema,
}) })
// type LoginData = v.InferOutput<typeof LoginSchema> // { email: string; password: string } const RegisterSchema = v.looseObject({
username: honeypot,
email: emailSchema,
password: passwordSchema,
passwordConfirm: passwordSchema,
})
export function UserAuthForm({ className, ...props }: { className?: string }) { export function UserAuthForm({
className,
isFirstRun,
...props
}: {
className?: string
isFirstRun: boolean
}) {
const [isLoading, setIsLoading] = React.useState<boolean>(false) const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [isGitHubLoading, setIsGitHubLoading] = React.useState<boolean>(false) const [isGitHubLoading, setIsGitHubLoading] = React.useState<boolean>(false)
const [errors, setErrors] = React.useState<Record<string, string | undefined>>({}) const [errors, setErrors] = React.useState<Record<string, string | undefined>>({})
@@ -45,8 +56,10 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
try { try {
const formData = new FormData(e.target as HTMLFormElement) const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any> const data = Object.fromEntries(formData) as Record<string, any>
const result = v.safeParse(LoginSchema, data) const Schema = isFirstRun ? RegisterSchema : LoginSchema
const result = v.safeParse(Schema, data)
if (!result.success) { if (!result.success) {
console.log(result)
let errors = {} let errors = {}
for (const issue of result.issues) { for (const issue of result.issues) {
// @ts-ignore // @ts-ignore
@@ -55,9 +68,14 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
setErrors(errors) setErrors(errors)
return return
} }
const { email, password } = result.output const { email, password, passwordConfirm } = result.output
let firstRun = true if (isFirstRun) {
if (firstRun) { // check that passwords match
if (password !== passwordConfirm) {
let msg = 'Passwords do not match'
setErrors({ passwordConfirm: msg })
return
}
await pb.admins.create({ await pb.admins.create({
email, email,
password, password,
@@ -80,13 +98,19 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
return ( return (
<div className={cn('grid gap-6', className)} {...props}> <div className={cn('grid gap-6', className)} {...props}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} onChange={() => setErrors({})}>
<div className="grid gap-2.5"> <div className="grid gap-2.5">
<div className="sr-only">
{/* honeypot */}
<label htmlFor="username"></label>
<input id="username" type="text" name="username" tabIndex={-1} />
</div>
<div className="grid gap-1"> <div className="grid gap-1">
<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
@@ -100,8 +124,8 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
{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">
<Label className="sr-only" htmlFor="email"> <Label className="sr-only" htmlFor="pass">
Email Password
</Label> </Label>
<Input <Input
id="pass" id="pass"
@@ -114,13 +138,32 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
/> />
{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 && (
<div className="grid gap-1">
<Label className="sr-only" htmlFor="pass2">
Confirm password
</Label>
<Input
id="pass2"
name="passwordConfirm"
placeholder="confirm password"
required
type="password"
autoComplete="current-password"
disabled={isLoading || isGitHubLoading}
/>
{errors?.passwordConfirm && (
<p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>
)}
</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" />
) : ( ) : (
<LogInIcon className="mr-2 h-4 w-4" /> <LogInIcon className="mr-2 h-4 w-4" />
)} )}
Sign In {isFirstRun ? 'Create account' : 'Sign in'}
</button> </button>
</div> </div>
</form> </form>
@@ -152,11 +195,20 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
Github Github
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px]"> <DialogContent style={{ maxWidth: 440, width: '90%' }}>
<DialogHeader> <DialogHeader>
<DialogTitle>OAuth support coming soon</DialogTitle> <DialogTitle className="mb-2">OAuth 2 / OIDC support</DialogTitle>
<DialogDescription> <DialogDescription className="grid gap-3">
OAuth / OpenID with all major providers should be available at 1.0.0. <p>
Support for OAuth / OIDC (all major providers) will be available in the future. As
well as an option to disable password auth.
</p>
<p>First I need to decide what to do with additional users.</p>
<p>
Should systems be shared across all accounts? Or should they be private by default
with team-based sharing?
</p>
<p>Let me know if you have strong opinions either way.</p>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
</DialogContent> </DialogContent>

View File

@@ -7,7 +7,7 @@ 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, 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, UserIcon } from 'lucide-react' import { DatabaseBackupIcon, Github, LogOutIcon, LogsIcon, MailIcon, UserIcon } from 'lucide-react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { Toaster } from './components/ui/toaster.tsx' import { Toaster } from './components/ui/toaster.tsx'
import { Logo } from './components/logo.tsx' import { Logo } from './components/logo.tsx'