mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 17:59:28 +08:00
update admin creation for pocketbase 0.23.0
This commit is contained in:
@@ -142,17 +142,17 @@ func (h *Hub) Run() {
|
|||||||
})
|
})
|
||||||
// check if first time setup on login page
|
// check if first time setup on login page
|
||||||
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
||||||
// todo: test that adminNum is correct
|
total, err := h.app.CountRecords("users")
|
||||||
adminNum, err := h.app.CountRecords(core.CollectionNameSuperusers)
|
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return e.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
|
|
||||||
})
|
})
|
||||||
// send test notification
|
// send test notification
|
||||||
se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
||||||
// API endpoint to get config.yml content
|
// API endpoint to get config.yml content
|
||||||
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
|
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()
|
return se.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -2,7 +2,9 @@
|
|||||||
package users
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/migrations"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -63,3 +65,52 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
|||||||
record.Set("settings", settings)
|
record.Set("settings", settings)
|
||||||
return e.Next()
|
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"})
|
||||||
|
}
|
||||||
|
@@ -1,16 +1,32 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TempAdminEmail = "_@b.b"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
m.Register(func(app core.App) error {
|
m.Register(func(app core.App) error {
|
||||||
|
// initial settings
|
||||||
settings := app.Settings()
|
settings := app.Settings()
|
||||||
settings.Meta.AppName = "Beszel"
|
settings.Meta.AppName = "Beszel"
|
||||||
settings.Meta.HideControls = true
|
settings.Meta.HideControls = true
|
||||||
|
if err := app.Save(settings); err != nil {
|
||||||
return app.Save(settings)
|
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)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,7 @@ 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 { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from "lucide-react"
|
import { LoaderCircle, LockIcon, LogInIcon, MailIcon } 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"
|
||||||
@@ -14,7 +14,7 @@ import { Trans, t } from "@lingui/macro"
|
|||||||
|
|
||||||
const honeypot = v.literal("")
|
const honeypot = v.literal("")
|
||||||
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
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({
|
const LoginSchema = v.looseObject({
|
||||||
name: honeypot,
|
name: honeypot,
|
||||||
@@ -24,14 +24,6 @@ const LoginSchema = v.looseObject({
|
|||||||
|
|
||||||
const RegisterSchema = v.looseObject({
|
const RegisterSchema = v.looseObject({
|
||||||
name: honeypot,
|
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,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
@@ -63,6 +55,8 @@ export function UserAuthForm({
|
|||||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
// store email for later use if mfa is enabled
|
||||||
|
let email = ""
|
||||||
try {
|
try {
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Record<string, any>
|
const data = Object.fromEntries(formData) as Record<string, any>
|
||||||
@@ -78,7 +72,8 @@ export function UserAuthForm({
|
|||||||
setErrors(errors)
|
setErrors(errors)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { email, password, passwordConfirm, username } = result.output
|
const { password, passwordConfirm } = result.output
|
||||||
|
email = result.output.email
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
// check that passwords match
|
// check that passwords match
|
||||||
if (password !== passwordConfirm) {
|
if (password !== passwordConfirm) {
|
||||||
@@ -86,27 +81,27 @@ export function UserAuthForm({
|
|||||||
setErrors({ passwordConfirm: msg })
|
setErrors({ passwordConfirm: msg })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await pb.admins.create({
|
await pb.send("/api/beszel/create-user", {
|
||||||
email,
|
method: "POST",
|
||||||
password,
|
body: JSON.stringify({ 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.collection("users").authWithPassword(email, password)
|
await pb.collection("users").authWithPassword(email, password)
|
||||||
} else {
|
} else {
|
||||||
await pb.collection("users").authWithPassword(email, password)
|
await pb.collection("users").authWithPassword(email, password)
|
||||||
}
|
}
|
||||||
$authenticated.set(true)
|
$authenticated.set(true)
|
||||||
} catch (e) {
|
} catch (err: any) {
|
||||||
showLoginFaliedToast()
|
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 {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -118,34 +113,15 @@ export function UserAuthForm({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oauthEnabled = authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0
|
||||||
|
const passwordEnabled = authMethods.password.enabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
{authMethods.emailPassword && (
|
{passwordEnabled && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5">
|
||||||
{isFirstRun && (
|
|
||||||
<div className="grid gap-1 relative">
|
|
||||||
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Label className="sr-only" htmlFor="username">
|
|
||||||
<Trans>Username</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
autoFocus={true}
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
required
|
|
||||||
placeholder={t`username`}
|
|
||||||
type="username"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoComplete="username"
|
|
||||||
autoCorrect="off"
|
|
||||||
disabled={isLoading || isOauthLoading}
|
|
||||||
className="ps-9"
|
|
||||||
/>
|
|
||||||
{errors?.username && <p className="px-1 text-xs text-red-600">{errors.username}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="email">
|
<Label className="sr-only" htmlFor="email">
|
||||||
@@ -216,7 +192,7 @@ export function UserAuthForm({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{(isFirstRun || authMethods.authProviders.length > 0) && (
|
{(isFirstRun || oauthEnabled) && (
|
||||||
// only show 'continue with' during onboarding or if we have auth providers
|
// only show 'continue with' during onboarding or if we have auth providers
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
@@ -232,15 +208,15 @@ export function UserAuthForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authMethods.authProviders.length > 0 && (
|
{oauthEnabled && (
|
||||||
<div className="grid gap-2 -mt-1">
|
<div className="grid gap-2 -mt-1">
|
||||||
{authMethods.authProviders.map((provider) => (
|
{authMethods.oauth2.providers.map((provider) => (
|
||||||
<button
|
<button
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(buttonVariants({ variant: "outline" }), {
|
className={cn(buttonVariants({ variant: "outline" }), {
|
||||||
"justify-self-center": !authMethods.emailPassword,
|
"justify-self-center": !passwordEnabled,
|
||||||
"px-5": !authMethods.emailPassword,
|
"px-5": !passwordEnabled,
|
||||||
})}
|
})}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsOauthLoading(true)
|
setIsOauthLoading(true)
|
||||||
@@ -293,7 +269,7 @@ export function UserAuthForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!authMethods.authProviders.length && isFirstRun && (
|
{!oauthEnabled && isFirstRun && (
|
||||||
// only show GitHub button / dialog during onboarding
|
// only show GitHub button / dialog during onboarding
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -329,7 +305,7 @@ export function UserAuthForm({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authMethods.emailPassword && !isFirstRun && (
|
{passwordEnabled && !isFirstRun && (
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
Reference in New Issue
Block a user