oauth integration / reset password
10
main.go
@@ -52,10 +52,12 @@ func main() {
|
||||
Scheme: "http",
|
||||
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))
|
||||
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
|
||||
})
|
||||
@@ -103,7 +105,7 @@ func main() {
|
||||
// create ssh key if it doesn't exist
|
||||
getSSHKey()
|
||||
// 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)
|
||||
if requestData.AuthRecord == nil {
|
||||
return apis.NewForbiddenError("Forbidden", nil)
|
||||
@@ -120,7 +122,7 @@ func main() {
|
||||
// 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 {
|
||||
e.Router.GET("/api/beszel/first-run", func(c echo.Context) error {
|
||||
adminNum, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -405,7 +407,7 @@ func handleStatusAlerts(newStatus string, oldRecord *models.Record) error {
|
||||
sendAlert(EmailData{
|
||||
to: user.Get("email").(string),
|
||||
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
|
||||
|
@@ -11,7 +11,7 @@ func init() {
|
||||
dao := daos.New(db)
|
||||
|
||||
settings, _ := dao.FindSettings()
|
||||
settings.Meta.AppName = "Qoma"
|
||||
settings.Meta.AppName = "Beszel"
|
||||
settings.Meta.HideControls = true
|
||||
|
||||
return dao.SaveSettings(settings)
|
||||
|
77
readme.md
Normal 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.
|
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
1
site/public/icons/apple.svg
Normal 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 |
1
site/public/icons/bitbucket.svg
Normal 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 |
1
site/public/icons/discord.svg
Normal 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 |
1
site/public/icons/facebook.svg
Normal 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 |
1
site/public/icons/gitea.svg
Normal 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 |
1
site/public/icons/gitee.svg
Normal 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 |
1
site/public/icons/github.svg
Normal 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 |
1
site/public/icons/gitlab.svg
Normal 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 |
1
site/public/icons/google.svg
Normal 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 |
1
site/public/icons/instagram.svg
Normal 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 |
1
site/public/icons/lock.svg
Normal 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 |
1
site/public/icons/oidc.svg
Normal 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 |
1
site/public/icons/patreon.svg
Normal 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 |
1
site/public/icons/spotify.svg
Normal 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 |
1
site/public/icons/strava.svg
Normal 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 |
1
site/public/icons/twitch.svg
Normal 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 |
1
site/public/icons/twitter.svg
Normal 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 |
@@ -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])
|
||||
|
@@ -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)
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
100
site/src/components/login/forgot-pass-form.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
49
site/src/components/login/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
24
site/src/components/router.tsx
Normal 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>
|
||||
}
|
@@ -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 (
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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>
|
||||
|