oauth integration / reset password

This commit is contained in:
Henry Dollman
2024-07-17 18:52:29 -04:00
parent fe110b1175
commit 9f11c021ce
32 changed files with 440 additions and 157 deletions

View File

@@ -27,8 +27,8 @@ export function AddServerButton() {
function copyDockerCompose(port: string) {
copyToClipboard(`services:
agent:
image: 'henrygd/qoma-agent'
container_name: 'qoma-agent'
image: 'henrygd/beszel-agent'
container_name: 'beszel-agent'
restart: unless-stopped
ports:
- '${port}:45876'
@@ -43,7 +43,7 @@ export function AddServerButton() {
return
}
// get public key
pb.send('/api/qoma/getkey', {}).then(({ key }) => {
pb.send('/api/beszel/getkey', {}).then(({ key }) => {
$publicKey.set(key)
})
}, [open])

View File

@@ -21,8 +21,9 @@ import {
} from '@/components/ui/command'
import { useEffect, useState } from 'react'
import { useStore } from '@nanostores/react'
import { $systems, navigate } from '@/lib/stores'
import { $systems } from '@/lib/stores'
import { isAdmin } from '@/lib/utils'
import { navigate } from './router'
export default function CommandPalette() {
const [open, setOpen] = useState(false)

View File

@@ -1,51 +0,0 @@
import { UserAuthForm } from '@/components/user-auth-form'
import { Logo } from './logo'
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">
<div className="grid gap-5 w-full px-4 max-w-[22em] mx-auto">
<div className="text-center">
<h1 className="mb-3">
<Logo className="h-7 fill-foreground mx-auto" />
<span className="sr-only">Qoma</span>
</h1>
<p className="text-sm text-muted-foreground">
{isFirstRun ? 'Please create an admin account' : 'Please sign in to your account'}
</p>
</div>
<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 */}
<a
href="https://github.com/henrygd/qoma"
className="hover:text-brand underline underline-offset-4"
>
Forgot password?
</a>
</p>
</div>
</div>
{/* <div className="relative hidden h-full bg-primary lg:block">
<img
className="absolute inset-0 h-full w-full object-cover bg-primary"
src="/penguin-and-egg.avif"
></img>
</div> */}
</div>
)
}

View File

@@ -1,12 +1,11 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Github, LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react'
import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react'
import { $authenticated, pb } from '@/lib/stores'
import * as v from 'valibot'
import { toast } from './ui/use-toast'
import { toast } from '../ui/use-toast'
import {
Dialog,
DialogContent,
@@ -15,6 +14,9 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useEffect, useState } from 'react'
import { AuthProviderInfo } from 'pocketbase'
import { Link } from '../router'
const honeypot = v.literal('')
const emailSchema = v.pipe(v.string(), v.email('Invalid email address.'))
@@ -44,6 +46,14 @@ const RegisterSchema = v.looseObject({
passwordConfirm: passwordSchema,
})
const showLoginFaliedToast = () => {
toast({
title: 'Login attempt failed',
description: 'Please check your credentials and try again',
variant: 'destructive',
})
}
export function UserAuthForm({
className,
isFirstRun,
@@ -52,11 +62,21 @@ export function UserAuthForm({
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>>({})
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isGitHubLoading, setIsOauthLoading] = useState<boolean>(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([])
// const searchParams = useSearchParams()
useEffect(() => {
pb.collection('users')
.listAuthMethods()
.then((methods) => {
console.log('methods', methods)
console.log('password active', methods.emailPassword)
setAuthProviders(methods.authProviders)
console.log('auth providers', authProviders)
})
}, [])
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -104,11 +124,7 @@ export function UserAuthForm({
}
$authenticated.set(true)
} catch (e) {
return toast({
title: 'Login attempt failed',
description: 'Please check your credentials and try again',
variant: 'destructive',
})
showLoginFaliedToast()
} finally {
setIsLoading(false)
}
@@ -220,44 +236,79 @@ export function UserAuthForm({
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className={cn(buttonVariants({ variant: 'outline' }))}
// onClick={async () => {
// setIsGitHubLoading(true)
// do stuff
// setIsGitHubLoading(false)
// }}
disabled={isLoading || isGitHubLoading}
>
{isGitHubLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : (
<Github className="mr-2 h-4 w-4" />
)}{' '}
Github
</button>
</DialogTrigger>
<DialogContent style={{ maxWidth: 440, width: '90%' }}>
<DialogHeader>
<DialogTitle className="mb-2">OAuth 2 / OIDC support</DialogTitle>
<DialogDescription className="grid gap-3">
{authProviders.length > 0 && (
<div className="grid gap-2">
{authProviders.map((provider) => (
<button
key={provider.name}
type="button"
className={cn(buttonVariants({ variant: 'outline' }))}
onClick={async () => {
setIsOauthLoading(true)
try {
await pb.collection('users').authWithOAuth2({ provider: provider.name })
$authenticated.set(pb.authStore.isValid)
} catch (e) {
showLoginFaliedToast()
} finally {
setIsOauthLoading(false)
}
}}
disabled={isLoading || isGitHubLoading}
>
{isGitHubLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : (
<img
className="mr-2 h-4 w-4 dark:invert"
src={`/icons/${provider.name}.svg`}
alt=""
onError={(e) => {
e.currentTarget.src = '/icons/lock.svg'
}}
/>
)}
<span className="translate-y-[1px]">{provider.displayName}</span>
</button>
))}
</div>
)}
{!authProviders.length && (
<Dialog>
<DialogTrigger asChild>
<button type="button" className={cn(buttonVariants({ variant: 'outline' }))}>
<img className="mr-2 h-4 w-4 dark:invert" src="/icons/github.svg" alt="" />
<span className="translate-y-[1px]">GitHub</span>
</button>
</DialogTrigger>
<DialogContent style={{ maxWidth: 440, width: '90%' }}>
<DialogHeader>
<DialogTitle>OAuth 2 / OIDC support</DialogTitle>
</DialogHeader>
<div className="text-primary/70 text-[0.95em] contents">
<p>Beszel supports OpenID Connect and many OAuth2 authentication providers.</p>
<p>
Support for OAuth / OIDC (all major providers) will be available in the future. As
well as an option to disable password auth.
Please view the{' '}
<a
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
className={cn(buttonVariants({ variant: 'link' }), 'p-0 h-auto')}
>
GitHub README
</a>{' '}
for instructions.
</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>
</Dialog>
</div>
</DialogContent>
</Dialog>
)}
<Link
href="/forgot-password"
className="text-sm mx-auto mt-2 hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
>
Forgot password?
</Link>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { LoaderCircle, MailIcon, SendIcon } from 'lucide-react'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { useCallback, useState } from 'react'
import { toast } from '../ui/use-toast'
import { buttonVariants } from '../ui/button'
import { cn } from '@/lib/utils'
import { pb } from '@/lib/stores'
import { Dialog, DialogHeader } from '../ui/dialog'
import { DialogContent, DialogTrigger, DialogTitle } from '../ui/dialog'
const showLoginFaliedToast = () => {
toast({
title: 'Login attempt failed',
description: 'Please check your credentials and try again',
variant: 'destructive',
})
}
export default function ForgotPassword() {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [email, setEmail] = useState('')
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
try {
// console.log(email)
await pb.collection('users').requestPasswordReset(email)
toast({
title: 'Password reset request received',
description: `Check ${email} for a reset link.`,
})
} catch (e) {
showLoginFaliedToast()
} finally {
setIsLoading(false)
setEmail('')
}
},
[email]
)
return (
<>
<form onSubmit={handleSubmit}>
<div className="grid gap-3">
<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">
Email
</Label>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
id="email"
name="email"
required
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading}
className="pl-9"
/>
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : (
<SendIcon className="mr-2 h-4 w-4" />
)}
Reset password
</button>
</div>
</form>
<Dialog>
<DialogTrigger asChild>
<button className="text-sm mx-auto mt-2 hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity">
Command line instructions
</button>
</DialogTrigger>
<DialogContent className="max-w-[33em]">
<DialogHeader>
<DialogTitle>Command line instructions</DialogTitle>
</DialogHeader>
<p className="text-primary/70 text-[0.95em]">
If you don't have an SMTP server configured, you can use the following command to reset
your password:
</p>
<code className="bg-muted rounded-sm py-0.5 px-2.5 mr-auto text-sm">
beszel admin update youremail@example.com newpassword
</code>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,49 @@
import { UserAuthForm } from '@/components/login/auth-form'
import { Logo } from '../logo'
import { useEffect, useMemo, useState } from 'react'
import { pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import ForgotPassword from './forgot-pass-form'
import { $router } from '../router'
export default function () {
const page = useStore($router)
const [isFirstRun, setFirstRun] = useState(false)
useEffect(() => {
document.title = 'Login / Beszel'
pb.send('/api/beszel/first-run', {}).then(({ firstRun }) => {
setFirstRun(firstRun)
})
}, [])
const subtitle = useMemo(() => {
if (isFirstRun) {
return 'Please create an admin account'
} else if (page?.path === '/forgot-password') {
return 'Enter email address to reset password'
} else {
return 'Please sign in to your account'
}
}, [isFirstRun, page])
return (
<div className="min-h-screen grid items-center py-12">
<div className="grid gap-5 w-full px-4 max-w-[22em] mx-auto">
<div className="text-center">
<h1 className="mb-3">
<Logo className="h-7 fill-foreground mx-auto" />
<span className="sr-only">Beszel</span>
</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{page?.path === '/forgot-password' ? (
<ForgotPassword />
) : (
<UserAuthForm isFirstRun={isFirstRun} />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { createRouter } from '@nanostores/router'
export const $router = createRouter(
{
home: '/',
server: '/server/:name',
'forgot-password': '/forgot-password',
},
{ links: false }
)
/** Navigate to url using router */
export const navigate = (urlString: string) => {
$router.open(urlString)
}
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
e.preventDefault()
$router.open(new URL((e.target as HTMLAnchorElement).href).pathname)
}
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
return <a onClick={onClick} {...props}></a>
}

View File

@@ -5,7 +5,7 @@ const SystemsTable = lazy(() => import('../server-table/systems-table'))
export default function () {
useEffect(() => {
document.title = 'Dashboard / Qoma'
document.title = 'Dashboard / Beszel'
}, [])
return (

View File

@@ -57,11 +57,12 @@ import {
Trash2Icon,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { $systems, pb, navigate } from '@/lib/stores'
import { $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import { AddServerButton } from '../add-server'
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
import AlertsButton from '../table-alerts'
import { navigate } from '../router'
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = info.getValue() as number

View File

@@ -1,24 +1,10 @@
import PocketBase from 'pocketbase'
import { atom, WritableAtom } from 'nanostores'
import { AlertRecord, ChartTimes, SystemRecord } from '@/types'
import { createRouter } from '@nanostores/router'
/** PocketBase JS Client */
export const pb = new PocketBase('/')
export const $router = createRouter(
{
home: '/',
server: '/server/:name',
},
{ links: false }
)
/** Navigate to url using router */
export const navigate = (urlString: string) => {
$router.open(urlString)
}
/** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid)

View File

@@ -3,15 +3,7 @@ import React, { Suspense, lazy, useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import Home from './components/routes/home.tsx'
import { ThemeProvider } from './components/theme-provider.tsx'
import {
$alerts,
$authenticated,
$updatedSystem,
$router,
$systems,
navigate,
pb,
} from './lib/stores.ts'
import { $alerts, $authenticated, $updatedSystem, $systems, pb } from './lib/stores.ts'
import { ModeToggle } from './components/mode-toggle.tsx'
import {
cn,
@@ -22,7 +14,16 @@ import {
updateServerList,
} from './lib/utils.ts'
import { buttonVariants } from './components/ui/button.tsx'
import { DatabaseBackupIcon, Github, LogOutIcon, LogsIcon, UserIcon } from 'lucide-react'
import {
DatabaseBackupIcon,
GithubIcon,
LockKeyholeIcon,
LogOutIcon,
LogsIcon,
ServerIcon,
UserIcon,
UsersIcon,
} from 'lucide-react'
import { useStore } from '@nanostores/react'
import { Toaster } from './components/ui/toaster.tsx'
import { Logo } from './components/logo.tsx'
@@ -35,15 +36,18 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from './components/ui/dropdown-menu.tsx'
import { AlertRecord, SystemRecord } from './types'
import { $router, Link, navigate } from './components/router.tsx'
const ServerDetail = lazy(() => import('./components/routes/server.tsx'))
const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
const LoginPage = lazy(() => import('./components/login.tsx'))
const LoginPage = lazy(() => import('./components/login/login.tsx'))
const App = () => {
const page = useStore($router)
@@ -122,7 +126,7 @@ const Layout = () => {
<>
<div className="container">
<div className="flex items-center h-16 bg-card px-6 border bt-0 rounded-md my-5">
<a
<Link
href="/"
aria-label="Home"
className={'p-2 pl-0'}
@@ -132,7 +136,7 @@ const Layout = () => {
}}
>
<Logo className="h-[1.2em] fill-foreground" />
</a>
</Link>
<div className={'flex ml-auto'}>
<ModeToggle />
@@ -145,7 +149,7 @@ const Layout = () => {
href={'https://github.com/henrygd'}
className={cn('', buttonVariants({ variant: 'ghost', size: 'icon' }))}
>
<Github className="h-[1.2rem] w-[1.2rem]" />
<GithubIcon className="h-[1.2rem] w-[1.2rem]" />
</a>
</TooltipTrigger>
<TooltipContent>
@@ -163,28 +167,50 @@ const Layout = () => {
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
</a>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuLabel>{pb.authStore.model?.email}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{isAdmin() && (
<>
<DropdownMenuItem asChild>
<a href="/_/">
<UsersIcon className="mr-2.5 h-4 w-4" />
<span>Users</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/_/#/collections?collectionId=2hz5ncl8tizk5nx">
<ServerIcon className="mr-2.5 h-4 w-4" />
<span>Systems</span>
</a>
</DropdownMenuItem>
<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>
<DropdownMenuItem asChild>
<a href="/_/#/settings/auth-providers">
<LockKeyholeIcon className="mr-2.5 h-4 w-4" />
<span>Auth providers</span>
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
</DropdownMenuGroup>
<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>