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,
})
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
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
switch isGoRun {
@@ -95,11 +109,12 @@ func main() {
return nil
})
// ssh key setup
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// create ssh key if it doesn't exist
getSSHKey()
// 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)
if requestData.Admin == nil {
return apis.NewForbiddenError("Forbidden", nil)
@@ -113,6 +128,19 @@ func main() {
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
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
go serverUpdateTicker()

View File

@@ -78,7 +78,7 @@ func init() {
{
"id": "2hz5ncl8tizk5nx",
"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",
"type": "base",
"system": false,
@@ -155,9 +155,7 @@ func init() {
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_eggNgAn` + "`" + ` ON ` + "`" + `systems` + "`" + ` (` + "`" + `name` + "`" + `)"
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,

View File

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

View File

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

View File

@@ -1,11 +1,19 @@
import { UserAuthForm } from '@/components/user-auth-form'
import { Logo } from './logo'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { pb } from '@/lib/stores'
export default function () {
const [isFirstRun, setFirstRun] = useState(false)
useEffect(() => {
document.title = 'Login / Qoma'
pb.send('/api/qoma/first-run', {}).then(({ firstRun }) => {
setFirstRun(firstRun)
})
}, [])
return (
<div className="relative h-screen grid lg:max-w-none lg:px-0">
<div className="grid items-center py-12">
@@ -15,9 +23,11 @@ export default function () {
<Logo className="h-7 fill-foreground mx-auto" />
<span className="sr-only">Qoma</span>
</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>
<UserAuthForm />
<UserAuthForm isFirstRun={isFirstRun} />
<p className="text-center text-sm opacity-70 hover:opacity-100 transition-opacity">
{/* todo: add forgot password section to readme and link to section
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 { 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 () {
useEffect(() => {
@@ -24,7 +24,7 @@ export default function () {
</CardHeader>
<CardContent>
<Suspense>
<DataTable />
<SystemsTable />
</Suspense>
</CardContent>
</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 [deleteServer, setDeleteServer] = useState({} as SystemRecord)
const [sorting, setSorting] = useState<SortingState>([])
@@ -135,7 +135,7 @@ export default function () {
onClick={() => copyToClipboard(info.getValue() as string)}
>
{info.getValue() as string}
<CopyIcon className="h-3 w-3" />
<CopyIcon className="h-2.5 w-2.5" />
</Button>
</span>
)

View File

@@ -1,5 +1,5 @@
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 { Search } from 'lucide-react'
@@ -27,6 +27,9 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<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">
{children}
</Command>

View File

@@ -1,17 +1,8 @@
'use client'
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 { buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
// import { toast } from '@/components/ui/use-toast'
import { Github, LoaderCircle, LogInIcon } from 'lucide-react'
import { pb } from '@/lib/stores'
import * as v from 'valibot'
@@ -25,14 +16,34 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
const LoginSchema = v.object({
email: 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 honeypot = v.literal('')
const emailSchema = v.pipe(v.string(), v.email('Invalid email address.'))
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 [isGitHubLoading, setIsGitHubLoading] = React.useState<boolean>(false)
const [errors, setErrors] = React.useState<Record<string, string | undefined>>({})
@@ -45,8 +56,10 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
try {
const formData = new FormData(e.target as HTMLFormElement)
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) {
console.log(result)
let errors = {}
for (const issue of result.issues) {
// @ts-ignore
@@ -55,9 +68,14 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
setErrors(errors)
return
}
const { email, password } = result.output
let firstRun = true
if (firstRun) {
const { email, password, passwordConfirm } = result.output
if (isFirstRun) {
// check that passwords match
if (password !== passwordConfirm) {
let msg = 'Passwords do not match'
setErrors({ passwordConfirm: msg })
return
}
await pb.admins.create({
email,
password,
@@ -80,13 +98,19 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
return (
<div className={cn('grid gap-6', className)} {...props}>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
<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">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
autoFocus={true}
id="email"
name="email"
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>}
</div>
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
<Label className="sr-only" htmlFor="pass">
Password
</Label>
<Input
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>}
</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}>
{isLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogInIcon className="mr-2 h-4 w-4" />
)}
Sign In
{isFirstRun ? 'Create account' : 'Sign in'}
</button>
</div>
</form>
@@ -152,11 +195,20 @@ export function UserAuthForm({ className, ...props }: { className?: string }) {
Github
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent style={{ maxWidth: 440, width: '90%' }}>
<DialogHeader>
<DialogTitle>OAuth support coming soon</DialogTitle>
<DialogDescription>
OAuth / OpenID with all major providers should be available at 1.0.0.
<DialogTitle className="mb-2">OAuth 2 / OIDC support</DialogTitle>
<DialogDescription className="grid gap-3">
<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>
</DialogHeader>
</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 { cn, updateFavicon, updateServerList } from './lib/utils.ts'
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 { Toaster } from './components/ui/toaster.tsx'
import { Logo } from './components/logo.tsx'