mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 01:39:34 +08:00
user creation and other updates
This commit is contained in:
30
main.go
30
main.go
@@ -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()
|
||||
|
@@ -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,
|
||||
|
@@ -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])
|
||||
|
@@ -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>
|
||||
)
|
||||
|
@@ -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 */}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
)
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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'
|
||||
|
Reference in New Issue
Block a user