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

10
main.go
View File

@@ -52,10 +52,12 @@ func main() {
Scheme: "http", Scheme: "http",
Host: "localhost:5173", Host: "localhost:5173",
}) })
e.Router.GET("/icons/*", apis.StaticDirectoryHandler(os.DirFS("./site/public/icons"), false))
e.Router.Any("/*", echo.WrapHandler(proxy)) e.Router.Any("/*", echo.WrapHandler(proxy))
e.Router.Any("/", echo.WrapHandler(proxy)) e.Router.Any("/", echo.WrapHandler(proxy))
default: default:
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS("./site/dist"), true)) e.Router.GET("/icons/*", apis.StaticDirectoryHandler(os.DirFS("./site/dist/icons"), false))
e.Router.Any("/*", apis.StaticDirectoryHandler(os.DirFS("./site/dist"), true))
} }
return nil return nil
}) })
@@ -103,7 +105,7 @@ func main() {
// 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("/api/qoma/getkey", func(c echo.Context) error { e.Router.GET("/api/beszel/getkey", func(c echo.Context) error {
requestData := apis.RequestInfo(c) requestData := apis.RequestInfo(c)
if requestData.AuthRecord == nil { if requestData.AuthRecord == nil {
return apis.NewForbiddenError("Forbidden", nil) return apis.NewForbiddenError("Forbidden", nil)
@@ -120,7 +122,7 @@ func main() {
// other api routes // other api routes
app.OnBeforeServe().Add(func(e *core.ServeEvent) error { app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// check if first time setup on login page // check if first time setup on login page
e.Router.GET("/api/qoma/first-run", func(c echo.Context) error { e.Router.GET("/api/beszel/first-run", func(c echo.Context) error {
adminNum, err := app.Dao().TotalAdmins() adminNum, err := app.Dao().TotalAdmins()
if err != nil { if err != nil {
return err return err
@@ -405,7 +407,7 @@ func handleStatusAlerts(newStatus string, oldRecord *models.Record) error {
sendAlert(EmailData{ sendAlert(EmailData{
to: user.Get("email").(string), to: user.Get("email").(string),
subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
body: fmt.Sprintf("Connection to %s is %s %v\n\n- Qoma", systemName, alertStatus, emoji), body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus),
}) })
} }
return nil return nil

View File

@@ -11,7 +11,7 @@ func init() {
dao := daos.New(db) dao := daos.New(db)
settings, _ := dao.FindSettings() settings, _ := dao.FindSettings()
settings.Meta.AppName = "Qoma" settings.Meta.AppName = "Beszel"
settings.Meta.HideControls = true settings.Meta.HideControls = true
return dao.SaveSettings(settings) return dao.SaveSettings(settings)

77
readme.md Normal file
View File

@@ -0,0 +1,77 @@
# Beszel
A lightweight resource monitoring hub with historical data, docker stats, and alerts.
<table width="100%">
<tbody>
<tr>
<td width="50%"><img src="https://henrygd-assets.b-cdn.net/social-image-server/before-capture.png" alt="example of turso.tech/pricing link which is missing an og:image as of may 11 2024"/></td>
<td width="50%"><img src="https://henrygd-assets.b-cdn.net/social-image-server/after-capture.webp" alt="example of turso.tech/pricing link using an image generated by the server as it's og:image"/></td>
</tr>
</tbody>
</table>
## Introduction
Beszel has two components: the hub and the agent.
The hub is a web application built on top of [PocketBase](https://pocketbase.io/) that provides a dashboard to view and manage your connected systems.
The agent runs on each system you want to monitor. It provides a minimal SSH server through which it communicates system information to the hub.
## Installation
The hub and agent are distributed as single binary files, as well as docker images.
> **Note**: The docker version does not support disk I/O stats, so use the binary version if that's important to you.
### Docker
### Binary
## OAuth / OIDC integration
Beszel supports OpenID Connect and many OAuth2 authentication providers (see list below). To enable this, you will need to:
1. Create an OAuth2 application using your provider of choice. The redirect / callback URL should be `<your-beszel-url>/api/oauth2-redirect`.
2. When you have the client ID and secret, go to the "Auth providers" page and enable your provider.
<details>
<summary>Supported provider list</summary>
- Apple
- Bitbucket
- Discord
- Facebook
- Gitea
- Gitee
- GitHub
- GitLab
- Google
- Instagram
- Kakao
- LiveChat
- mailcow
- Microsoft
- OpenID Connect
- Patreon (v2)
- Planning Center
- Spotify
- Strava
- Twitch
- Twitter
- VK
- Yandex
</details>
## API
Because Beszel is built on top of PocketBase, you can use the normal PocketBase API to read or update your data in your own applications.
## Security
The hub and agent communicate over SSH, so they do not need to be exposed to the internet.
When the hub is started for the first time, it generates an ED25519 key pair.
The agent's SSH server is configured to only accept connections using this key. It also does not provide a pty or accept any input, so it is not possible to execute commands on the agent.

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qoma</title> <title>Beszel</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.2 6.9c-1 0-2.5-1-4-1-2 0-4 1.1-5 3-2 3.6-.5 9 1.5 12 1 1.5 2.3 3.2 3.8 3.1 1.6 0 2.1-1 4-1 1.8 0 2.3 1 4 1 1.6 0 2.6-1.5 3.6-3a13 13 0 0 0 1.7-3.4 5.3 5.3 0 0 1-.6-9.4 5.6 5.6 0 0 0-4.4-2.4C14.8 5.6 13 7 12.2 7zm3.3-3c.9-1 1.4-2.5 1.3-3.9-1.2 0-2.7.8-3.6 1.8A5 5 0 0 0 12 5.5c1.3.1 2.7-.7 3.5-1.7"/></svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M.8 1.2a.8.8 0 0 0-.8 1l3.3 19.7c0 .5.5.9 1 .9h15.6a.8.8 0 0 0 .8-.7l3.3-20a.8.8 0 0 0-.8-.9zm13.7 14.3h-5l-1.3-7h7.5z"/></svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.3 4.4a19.8 19.8 0 0 0-4.9-1.5L14.7 4C13 4 11.1 4 9.3 4.1L8.6 3a19.7 19.7 0 0 0-5 1.5C.6 9-.4 13.6.1 18.1c2 1.5 4 2.4 6 3h.1c.5-.6.9-1.3 1.2-2l-1.9-1V18l.4-.3c4 1.8 8.2 1.8 12.1 0h.1l.4.3v.1a12.3 12.3 0 0 1-2 1l1.3 2c2-.6 4-1.5 6-3h.1c.5-5.2-.8-9.7-3.6-13.7zM8 15.4c-1.2 0-2.1-1.2-2.1-2.5s1-2.4 2.1-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4zm8 0c-1.2 0-2.2-1.2-2.2-2.5s1-2.4 2.2-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4Z"/></svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.1 23.7v-8H6.6V12h2.5v-1.5c0-4.1 1.8-6 5.9-6h1.4a8.7 8.7 0 0 1 1.2.3V8a8.6 8.6 0 0 0-.7 0 26.8 26.8 0 0 0-.7 0c-.7 0-1.3 0-1.7.3a1.7 1.7 0 0 0-.7.6c-.2.4-.3 1-.3 1.7V12h3.9l-.4 2.1-.3 1.6h-3.2V24a12 12 0 1 0-4.4-.3Z"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.2 4.6a4.2 4.2 0 0 0-2.9 1.1C-.4 7.3 0 9.7.1 10.1c0 .4.3 1.6 1.2 2.7C3 15 6.8 15 6.8 15S7.3 16 8 17c1 1.3 2 2.3 2.9 2.4H18s.4 0 1-.4c.6-.3 1-.9 1-.9s.6-.5 1.3-1.7l.5-1s2.1-4.6 2.1-9c0-1.2-.4-1.5-.4-1.5l-.4-.2s-4.5.3-6.8.3h-1.5v4.5l-.6-.3V5h-3.5l-6-.4h-.6zm.4 1.8s.3 2.3.7 3.6c.2 1.1 1 3 1 3l-1.7-.3c-1-.4-1.4-.8-1.4-.8s-.8-.5-1.1-1.5c-.7-1.7 0-2.7 0-2.7s.2-.9 1.4-1.1c.4-.2.9-.2 1-.2zM12.9 9l.5.1.9.4-.6 1.1a.7.7 0 0 0-.6.4.7.7 0 0 0 .1.7l-1 2a.7.7 0 0 0-.6.5.7.7 0 0 0 .3.7.7.7 0 0 0 1-.2.7.7 0 0 0-.2-.8l1-2a.7.7 0 0 0 .2 0 .7.7 0 0 0 .3 0 8.8 8.8 0 0 1 1 .4.8.8 0 0 1 .3.3l-.1.6c0 .3-.7 1.5-.7 1.5a.7.7 0 0 0-.7.5.7.7 0 1 0 1.2-.2l.2-.5.5-1.1c0-.1.2-.4.1-.8a1 1 0 0 0-.5-.7l-1-.6-.1-.2a.7.7 0 0 0-.2-.3l.5-1 3 1.4s.4.2.5.6v.6L16 16.8s-.2.5-.7.5a1 1 0 0 1-.4 0h-.2L10.4 15s-.4-.2-.5-.6l.1-.7 2-4.2s.3-.4.5-.5A.9.9 0 0 1 13 9z"/></svg>

After

Width:  |  Height:  |  Size: 907 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm6 5.3c.4 0 .7.3.7.6v1.5a.6.6 0 0 1-.6.6H9.8C8.8 8 8 8.8 8 9.8v5.6c0 .3.3.6.6.6h5.6c1 0 1.8-.8 1.8-1.8V14a.6.6 0 0 0-.6-.6h-4.1a.6.6 0 0 1-.6-.6v-1.4a.6.6 0 0 1 .6-.6H18c.3 0 .6.2.6.6v3.4a4 4 0 0 1-4 4H5.9a.6.6 0 0 1-.6-.6V9.8a4.4 4.4 0 0 1 4.5-4.5H18Z"/></svg>

After

Width:  |  Height:  |  Size: 406 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2 0 1.9 1.2 1.9 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3"/></svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.6 9.6 20.3 1a.9.9 0 0 0-.3-.4.9.9 0 0 0-1 0 .9.9 0 0 0-.3.5l-2.2 6.7h-9L5.3 1.1A.9.9 0 0 0 5 .6a.9.9 0 0 0-1 0 .9.9 0 0 0-.3.4L.4 9.5a6 6 0 0 0 2 7.1l5 3.8 2.5 1.8 1.5 1.1a1 1 0 0 0 1.2 0l1.5-1 2.5-2 5-3.7a6 6 0 0 0 2-7z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.5 11v3.2h7.8a7 7 0 0 1-1.8 4.1 8 8 0 0 1-6 2.4c-4.8 0-8.6-3.9-8.6-8.7a8.6 8.6 0 0 1 14.5-6.4l2.3-2.3C18.7 1.4 16 0 12.5 0 5.9 0 .3 5.4.3 12S6 24 12.5 24a11 11 0 0 0 8.4-3.4c2.1-2.1 2.8-5.2 2.8-7.6 0-.8 0-1.5-.2-2h-11z"/></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 0C5.8.2 5 .4 4.1.7 3.3 1 2.7 1.4 2 2c-.7.7-1 1.4-1.4 2.2C.3 4.9.1 5.8.1 7a84.6 84.6 0 0 0 .5 12.8c.4.8.8 1.4 1.4 2.1.7.7 1.4 1 2.2 1.4.7.3 1.6.5 2.9.5a85 85 0 0 0 12.8-.5c.8-.4 1.4-.8 2.1-1.4.7-.7 1-1.4 1.4-2.2.3-.7.5-1.6.5-2.9a85 85 0 0 0-.5-12.8C23 3.3 22.6 2.7 22 2c-.7-.7-1.4-1-2.2-1.4-.7-.3-1.6-.5-2.9-.5A85.5 85.5 0 0 0 7 0m.2 21.7c-1.2 0-1.8-.3-2.3-.4-.5-.2-1-.5-1.3-1-.5-.3-.7-.7-1-1.3-.1-.4-.3-1-.4-2.2a84.8 84.8 0 0 1 .4-12c.2-.5.5-1 1-1.3.3-.5.7-.7 1.3-1 .4-.1 1-.3 2.2-.4a84.4 84.4 0 0 1 12 .4c.5.3 1 .5 1.3 1 .5.3.7.7 1 1.3.1.4.3 1 .4 2.2a82.7 82.7 0 0 1-.4 12c-.2.5-.5 1-1 1.3-.3.5-.7.7-1.3 1-.4.1-1 .3-2.2.4a84.9 84.9 0 0 1-9.7 0M17 5.6A1.4 1.4 0 1 0 18.4 4 1.4 1.4 0 0 0 17 5.6M5.8 12a6.2 6.2 0 1 0 12.4 0 6.2 6.2 0 0 0-12.4 0M8 12a4 4 0 1 1 4 4 4 4 0 0 1-4-4"/></svg>

After

Width:  |  Height:  |  Size: 856 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.5.9 11 2.7v18.1c-4.1-.5-7.3-2.7-7.3-5.5 0-2.5 2.8-4.7 6.7-5.4V7.6C4.4 8.3 0 11.5 0 15.3c0 4 4.7 7.3 11 7.8l3.5-1.7V.9m.7 6.7V10c1.4.3 2.7.7 3.7 1.3l-2 1.1L24 14l-.5-5.2-1.9 1c-1.7-1-4-1.8-6.4-2z"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23 7.2c0-3-2.4-5.6-5.2-6.5-3.5-1.1-8.1-1-11.4.6-4 2-5.3 6-5.4 10.2C1 15 1.3 24 6.4 24c3.8 0 4.3-4.8 6-7.1 1.3-1.7 3-2.2 4.9-2.7a7.1 7.1 0 0 0 5.7-7Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.7 0 12 0zm5.5 17.3c-.2.4-.6.5-1 .3-2.8-1.8-6.4-2.1-10.6-1.2-.4.2-.7-.1-.9-.5 0-.4.2-.8.6-.9 4.5-1 8.5-.6 11.6 1.3.4.2.5.7.3 1zM19 14c-.3.5-.9.6-1.3.3-3.2-2-8.2-2.5-12-1.3-.4 0-1-.2-1-.6-.2-.5 0-1 .5-1.2 4.4-1.3 9.8-.6 13.5 1.6.4.2.6.8.3 1.2zm0-3.3A19.9 19.9 0 0 0 5.3 9.3c-.6.2-1.2-.2-1.4-.7-.2-.6.2-1.2.7-1.4 4.3-1.3 11.3-1 15.7 1.6.6.3.7 1 .4 1.6-.3.4-1 .6-1.5.3z"/></svg>

After

Width:  |  Height:  |  Size: 495 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m15.4 18-2.1-4.2h-3l5 10.2 5.2-10.2h-3m-7-5.6 2.8 5.6h4.2L10.5 0l-7 13.8h4.1"/></svg>

After

Width:  |  Height:  |  Size: 154 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.6 4.7h1.7V10h-1.7zm4.7 0H18V10h-1.7zM6 0 1.7 4.3v15.4H7V24l4.2-4.3h3.5l7.7-7.7V0zm14.6 11.1L17 14.6h-3.4l-3 3v-3H7V1.7h13.7Z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M22.5 6c-.8.3-1.6.6-2.5.7.9-.5 1.6-1.4 1.9-2.4-.8.5-1.8.9-2.7 1a4.3 4.3 0 0 0-7.3 4C8.2 9 5 7.3 3 4.8a4.2 4.2 0 0 0 1.3 5.7c-.7 0-1.3-.2-2-.5 0 2.1 1.6 3.8 3.5 4.2a4.2 4.2 0 0 1-2 .1 4.3 4.3 0 0 0 4 3A8.5 8.5 0 0 1 2.7 19h-1A12.1 12.1 0 0 0 20.3 8.8v-.6c.8-.6 1.5-1.3 2-2.2"/></svg>

After

Width:  |  Height:  |  Size: 371 B

View File

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

View File

@@ -21,8 +21,9 @@ import {
} from '@/components/ui/command' } from '@/components/ui/command'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $systems, navigate } from '@/lib/stores' import { $systems } from '@/lib/stores'
import { isAdmin } from '@/lib/utils' import { isAdmin } from '@/lib/utils'
import { navigate } from './router'
export default function CommandPalette() { export default function CommandPalette() {
const [open, setOpen] = useState(false) 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 { 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, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react' import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react'
import { $authenticated, 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 {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -15,6 +14,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { useEffect, useState } from 'react'
import { AuthProviderInfo } from 'pocketbase'
import { Link } from '../router'
const honeypot = v.literal('') const honeypot = v.literal('')
const emailSchema = v.pipe(v.string(), v.email('Invalid email address.')) const emailSchema = v.pipe(v.string(), v.email('Invalid email address.'))
@@ -44,6 +46,14 @@ const RegisterSchema = v.looseObject({
passwordConfirm: passwordSchema, passwordConfirm: passwordSchema,
}) })
const showLoginFaliedToast = () => {
toast({
title: 'Login attempt failed',
description: 'Please check your credentials and try again',
variant: 'destructive',
})
}
export function UserAuthForm({ export function UserAuthForm({
className, className,
isFirstRun, isFirstRun,
@@ -52,11 +62,21 @@ export function UserAuthForm({
className?: string className?: string
isFirstRun: boolean isFirstRun: boolean
}) { }) {
const [isLoading, setIsLoading] = React.useState<boolean>(false) const [isLoading, setIsLoading] = useState<boolean>(false)
const [isGitHubLoading, setIsGitHubLoading] = React.useState<boolean>(false) const [isGitHubLoading, setIsOauthLoading] = useState<boolean>(false)
const [errors, setErrors] = React.useState<Record<string, string | undefined>>({}) 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>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
@@ -104,11 +124,7 @@ export function UserAuthForm({
} }
$authenticated.set(true) $authenticated.set(true)
} catch (e) { } catch (e) {
return toast({ showLoginFaliedToast()
title: 'Login attempt failed',
description: 'Please check your credentials and try again',
variant: 'destructive',
})
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -220,44 +236,79 @@ export function UserAuthForm({
<span className="bg-background px-2 text-muted-foreground">Or continue with</span> <span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div> </div>
</div> </div>
<Dialog>
<DialogTrigger asChild> {authProviders.length > 0 && (
<div className="grid gap-2">
{authProviders.map((provider) => (
<button <button
key={provider.name}
type="button" type="button"
className={cn(buttonVariants({ variant: 'outline' }))} className={cn(buttonVariants({ variant: 'outline' }))}
// onClick={async () => { onClick={async () => {
// setIsGitHubLoading(true) setIsOauthLoading(true)
// do stuff try {
// setIsGitHubLoading(false) await pb.collection('users').authWithOAuth2({ provider: provider.name })
// }} $authenticated.set(pb.authStore.isValid)
} catch (e) {
showLoginFaliedToast()
} finally {
setIsOauthLoading(false)
}
}}
disabled={isLoading || isGitHubLoading} disabled={isLoading || isGitHubLoading}
> >
{isGitHubLoading ? ( {isGitHubLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<Github className="mr-2 h-4 w-4" /> <img
)}{' '} className="mr-2 h-4 w-4 dark:invert"
Github 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> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent style={{ maxWidth: 440, width: '90%' }}> <DialogContent style={{ maxWidth: 440, width: '90%' }}>
<DialogHeader> <DialogHeader>
<DialogTitle className="mb-2">OAuth 2 / OIDC support</DialogTitle> <DialogTitle>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> </DialogHeader>
<div className="text-primary/70 text-[0.95em] contents">
<p>Beszel supports OpenID Connect and many OAuth2 authentication providers.</p>
<p>
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>
</div>
</DialogContent> </DialogContent>
</Dialog> </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> </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 () { export default function () {
useEffect(() => { useEffect(() => {
document.title = 'Dashboard / Qoma' document.title = 'Dashboard / Beszel'
}, []) }, [])
return ( return (

View File

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

View File

@@ -1,24 +1,10 @@
import PocketBase from 'pocketbase' import PocketBase from 'pocketbase'
import { atom, WritableAtom } from 'nanostores' import { atom, WritableAtom } from 'nanostores'
import { AlertRecord, ChartTimes, SystemRecord } from '@/types' import { AlertRecord, ChartTimes, SystemRecord } from '@/types'
import { createRouter } from '@nanostores/router'
/** PocketBase JS Client */ /** PocketBase JS Client */
export const pb = new PocketBase('/') 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 */ /** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid) 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 ReactDOM from 'react-dom/client'
import Home from './components/routes/home.tsx' import Home from './components/routes/home.tsx'
import { ThemeProvider } from './components/theme-provider.tsx' import { ThemeProvider } from './components/theme-provider.tsx'
import { import { $alerts, $authenticated, $updatedSystem, $systems, pb } from './lib/stores.ts'
$alerts,
$authenticated,
$updatedSystem,
$router,
$systems,
navigate,
pb,
} from './lib/stores.ts'
import { ModeToggle } from './components/mode-toggle.tsx' import { ModeToggle } from './components/mode-toggle.tsx'
import { import {
cn, cn,
@@ -22,7 +14,16 @@ import {
updateServerList, updateServerList,
} from './lib/utils.ts' } 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,
GithubIcon,
LockKeyholeIcon,
LogOutIcon,
LogsIcon,
ServerIcon,
UserIcon,
UsersIcon,
} 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'
@@ -35,15 +36,18 @@ import {
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuLabel,
} from './components/ui/dropdown-menu.tsx' } from './components/ui/dropdown-menu.tsx'
import { AlertRecord, SystemRecord } from './types' import { AlertRecord, SystemRecord } from './types'
import { $router, Link, navigate } from './components/router.tsx'
const ServerDetail = lazy(() => import('./components/routes/server.tsx')) const ServerDetail = lazy(() => import('./components/routes/server.tsx'))
const CommandPalette = lazy(() => import('./components/command-palette.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 App = () => {
const page = useStore($router) const page = useStore($router)
@@ -122,7 +126,7 @@ const Layout = () => {
<> <>
<div className="container"> <div className="container">
<div className="flex items-center h-16 bg-card px-6 border bt-0 rounded-md my-5"> <div className="flex items-center h-16 bg-card px-6 border bt-0 rounded-md my-5">
<a <Link
href="/" href="/"
aria-label="Home" aria-label="Home"
className={'p-2 pl-0'} className={'p-2 pl-0'}
@@ -132,7 +136,7 @@ const Layout = () => {
}} }}
> >
<Logo className="h-[1.2em] fill-foreground" /> <Logo className="h-[1.2em] fill-foreground" />
</a> </Link>
<div className={'flex ml-auto'}> <div className={'flex ml-auto'}>
<ModeToggle /> <ModeToggle />
@@ -145,7 +149,7 @@ const Layout = () => {
href={'https://github.com/henrygd'} href={'https://github.com/henrygd'}
className={cn('', buttonVariants({ variant: 'ghost', size: 'icon' }))} className={cn('', buttonVariants({ variant: 'ghost', size: 'icon' }))}
> >
<Github className="h-[1.2rem] w-[1.2rem]" /> <GithubIcon className="h-[1.2rem] w-[1.2rem]" />
</a> </a>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -163,14 +167,24 @@ const Layout = () => {
<UserIcon className="h-[1.2rem] w-[1.2rem]" /> <UserIcon className="h-[1.2rem] w-[1.2rem]" />
</a> </a>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuItem onSelect={() => pb.authStore.clear()}> <DropdownMenuLabel>{pb.authStore.model?.email}</DropdownMenuLabel>
<LogOutIcon className="mr-2.5 h-4 w-4" /> <DropdownMenuSeparator />
<span>Log out</span> <DropdownMenuGroup>
</DropdownMenuItem>
{isAdmin() && ( {isAdmin() && (
<> <>
<DropdownMenuSeparator /> <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> <DropdownMenuItem asChild>
<a href="/_/#/logs"> <a href="/_/#/logs">
<LogsIcon className="mr-2.5 h-4 w-4" /> <LogsIcon className="mr-2.5 h-4 w-4" />
@@ -183,8 +197,20 @@ const Layout = () => {
<span>Backups</span> <span>Backups</span>
</a> </a>
</DropdownMenuItem> </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>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>