From ed017525465398b300e60960a353c8abadd334c6 Mon Sep 17 00:00:00 2001 From: Henry Dollman Date: Wed, 27 Nov 2024 16:32:23 -0500 Subject: [PATCH] update admin creation for pocketbase 0.23.0 --- beszel/internal/hub/hub.go | 12 +-- beszel/internal/users/users.go | 51 +++++++++++ beszel/migrations/initial-settings.go | 20 ++++- .../site/src/components/login/auth-form.tsx | 86 +++++++------------ 4 files changed, 106 insertions(+), 63 deletions(-) diff --git a/beszel/internal/hub/hub.go b/beszel/internal/hub/hub.go index 3e81745..a91fe40 100644 --- a/beszel/internal/hub/hub.go +++ b/beszel/internal/hub/hub.go @@ -142,17 +142,17 @@ func (h *Hub) Run() { }) // check if first time setup on login page se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error { - // todo: test that adminNum is correct - adminNum, err := h.app.CountRecords(core.CollectionNameSuperusers) - if err != nil { - return err - } - return e.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0}) + total, err := h.app.CountRecords("users") + return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0}) }) // send test notification se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification) // API endpoint to get config.yml content se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig) + // create first user endpoint only needed if no users exist + if totalUsers, _ := h.app.CountRecords("users"); totalUsers == 0 { + se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser) + } return se.Next() }) diff --git a/beszel/internal/users/users.go b/beszel/internal/users/users.go index 88332af..ac6d732 100644 --- a/beszel/internal/users/users.go +++ b/beszel/internal/users/users.go @@ -2,7 +2,9 @@ package users import ( + "beszel/migrations" "log" + "net/http" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" @@ -63,3 +65,52 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error { record.Set("settings", settings) return e.Next() } + +// Custom API endpoint to create the first user. +// Mimics previous default behavior in PocketBase < 0.23.0 allowing user to be created through the Beszel UI. +func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error { + // check that there are no users + totalUsers, err := um.app.CountRecords("users") + if err != nil || totalUsers > 0 { + return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"}) + } + // check that there is only one superuser and the email matches the email of the superuser we set up in initial-settings.go + adminUsers, err := um.app.FindAllRecords(core.CollectionNameSuperusers) + if err != nil || len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail { + return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"}) + } + // create first user using supplied email and password in request body + data := struct { + Email string `json:"email"` + Password string `json:"password"` + }{} + if err := e.BindBody(&data); err != nil { + return e.JSON(http.StatusBadRequest, map[string]string{"err": err.Error()}) + } + if data.Email == "" || data.Password == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"err": "Bad request"}) + } + + collection, _ := um.app.FindCollectionByNameOrId("users") + user := core.NewRecord(collection) + user.SetEmail(data.Email) + user.SetPassword(data.Password) + user.Set("role", "admin") + user.Set("verified", true) + if err := um.app.Save(user); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()}) + } + // create superuser using the email of the first user + collection, _ = um.app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + adminUser := core.NewRecord(collection) + adminUser.SetEmail(data.Email) + adminUser.SetPassword(data.Password) + if err := um.app.Save(adminUser); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()}) + } + // delete the intial superuser + if err := um.app.Delete(adminUsers[0]); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()}) + } + return e.JSON(http.StatusOK, map[string]string{"msg": "User created"}) +} diff --git a/beszel/migrations/initial-settings.go b/beszel/migrations/initial-settings.go index 3b0a277..a908dc7 100644 --- a/beszel/migrations/initial-settings.go +++ b/beszel/migrations/initial-settings.go @@ -1,16 +1,32 @@ package migrations import ( + "log" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/tools/security" +) + +var ( + TempAdminEmail = "_@b.b" ) func init() { m.Register(func(app core.App) error { + // initial settings settings := app.Settings() settings.Meta.AppName = "Beszel" settings.Meta.HideControls = true - - return app.Save(settings) + if err := app.Save(settings); err != nil { + log.Println("failed to save settings", err) + return err + } + // create superuser + collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + user := core.NewRecord(collection) + user.SetEmail(TempAdminEmail) + user.SetPassword(security.RandomString(12)) + return app.Save(user) }, nil) } diff --git a/beszel/site/src/components/login/auth-form.tsx b/beszel/site/src/components/login/auth-form.tsx index 13471d8..65f2937 100644 --- a/beszel/site/src/components/login/auth-form.tsx +++ b/beszel/site/src/components/login/auth-form.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from "lucide-react" +import { LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react" import { $authenticated, pb } from "@/lib/stores" import * as v from "valibot" import { toast } from "../ui/use-toast" @@ -14,7 +14,7 @@ import { Trans, t } from "@lingui/macro" const honeypot = v.literal("") const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`)) -const passwordSchema = v.pipe(v.string(), v.minLength(10, t`Password must be at least 10 characters.`)) +const passwordSchema = v.pipe(v.string(), v.minLength(8, t`Password must be at least 8 characters.`)) const LoginSchema = v.looseObject({ name: honeypot, @@ -24,14 +24,6 @@ const LoginSchema = v.looseObject({ const RegisterSchema = v.looseObject({ name: honeypot, - username: v.pipe( - v.string(), - v.regex( - /^(?=.*[a-zA-Z])[a-zA-Z0-9_-]+$/, - "Invalid username. You may use alphanumeric characters, underscores, and hyphens." - ), - v.minLength(3, "Username must be at least 3 characters long.") - ), email: emailSchema, password: passwordSchema, passwordConfirm: passwordSchema, @@ -63,6 +55,8 @@ export function UserAuthForm({ async (e: React.FormEvent) => { e.preventDefault() setIsLoading(true) + // store email for later use if mfa is enabled + let email = "" try { const formData = new FormData(e.target as HTMLFormElement) const data = Object.fromEntries(formData) as Record @@ -78,7 +72,8 @@ export function UserAuthForm({ setErrors(errors) return } - const { email, password, passwordConfirm, username } = result.output + const { password, passwordConfirm } = result.output + email = result.output.email if (isFirstRun) { // check that passwords match if (password !== passwordConfirm) { @@ -86,27 +81,27 @@ export function UserAuthForm({ setErrors({ passwordConfirm: msg }) return } - await pb.admins.create({ - email, - password, - passwordConfirm: password, - }) - await pb.admins.authWithPassword(email, password) - await pb.collection("users").create({ - username, - email, - password, - passwordConfirm: password, - role: "admin", - verified: true, + await pb.send("/api/beszel/create-user", { + method: "POST", + body: JSON.stringify({ email, password }), }) await pb.collection("users").authWithPassword(email, password) } else { await pb.collection("users").authWithPassword(email, password) } $authenticated.set(true) - } catch (e) { + } catch (err: any) { showLoginFaliedToast() + // todo: implement MFA + // const mfaId = err.response?.mfaId + // if (!mfaId) { + // showLoginFaliedToast() + // throw err + // } + // the user needs to authenticate again with another auth method, for example OTP + // const result = await pb.collection("users").requestOTP(email) + // ... show a modal for users to check their email and to enter the received code ... + // await pb.collection("users").authWithOTP(result.otpId, "EMAIL_CODE", { mfaId: mfaId }) } finally { setIsLoading(false) } @@ -118,34 +113,15 @@ export function UserAuthForm({ return null } + const oauthEnabled = authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0 + const passwordEnabled = authMethods.password.enabled + return (
- {authMethods.emailPassword && ( + {passwordEnabled && ( <>
setErrors({})}>
- {isFirstRun && ( -
- - - - {errors?.username &&

{errors.username}

} -
- )}
- {(isFirstRun || authMethods.authProviders.length > 0) && ( + {(isFirstRun || oauthEnabled) && ( // only show 'continue with' during onboarding or if we have auth providers
@@ -232,15 +208,15 @@ export function UserAuthForm({ )} - {authMethods.authProviders.length > 0 && ( + {oauthEnabled && (
- {authMethods.authProviders.map((provider) => ( + {authMethods.oauth2.providers.map((provider) => (
)} - {!authMethods.authProviders.length && isFirstRun && ( + {!oauthEnabled && isFirstRun && ( // only show GitHub button / dialog during onboarding @@ -329,7 +305,7 @@ export function UserAuthForm({ )} - {authMethods.emailPassword && !isFirstRun && ( + {passwordEnabled && !isFirstRun && (