remove server admin from config and add onboarding ui

This commit is contained in:
miloschwartz 2025-06-19 22:11:05 -04:00
parent f300838f8e
commit d03f45279c
No known key found for this signature in database
15 changed files with 345 additions and 190 deletions

View file

@ -41,11 +41,6 @@ rate_limits:
window_minutes: 1
max_requests: 500
users:
server_admin:
email: "admin@example.com"
password: "Password123!"
flags:
require_email_verification: false
disable_signup_without_invite: true

View file

@ -54,10 +54,6 @@ email:
smtp_pass: "{{.EmailSMTPPass}}"
no_reply: "{{.EmailNoReply}}"
{{end}}
users:
server_admin:
email: "{{.AdminUserEmail}}"
password: "{{.AdminUserPassword}}"
flags:
require_email_verification: {{.EnableEmail}}

View file

@ -16,7 +16,6 @@ import (
"syscall"
"text/template"
"time"
"unicode"
"math/rand"
"strconv"
@ -40,8 +39,6 @@ type Config struct {
BaseDomain string
DashboardDomain string
LetsEncryptEmail string
AdminUserEmail string
AdminUserPassword string
DisableSignupWithoutInvite bool
DisableUserCreateOrg bool
EnableEmail bool
@ -171,6 +168,7 @@ func main() {
}
fmt.Println("Installation complete!")
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
}
func readString(reader *bufio.Reader, prompt string, defaultValue string) string {
@ -236,30 +234,6 @@ func collectUserInput(reader *bufio.Reader) Config {
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
// Admin user configuration
fmt.Println("\n=== Admin User Configuration ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for {
pass1 := readPassword("Create admin user password", reader)
pass2 := readPassword("Confirm admin user password", reader)
if pass1 != pass2 {
fmt.Println("Passwords do not match")
} else {
config.AdminUserPassword = pass1
if valid, message := validatePassword(config.AdminUserPassword); valid {
break
} else {
fmt.Println("Invalid password:", message)
fmt.Println("Password requirements:")
fmt.Println("- At least one uppercase English letter")
fmt.Println("- At least one lowercase English letter")
fmt.Println("- At least one digit")
fmt.Println("- At least one special character")
}
}
}
// Security settings
fmt.Println("\n=== Security Settings ===")
config.DisableSignupWithoutInvite = readBool(reader, "Disable signup without invite", true)
@ -290,60 +264,10 @@ func collectUserInput(reader *bufio.Reader) Config {
fmt.Println("Error: Let's Encrypt email is required")
os.Exit(1)
}
if config.AdminUserEmail == "" || config.AdminUserPassword == "" {
fmt.Println("Error: Admin user email and password are required")
os.Exit(1)
}
return config
}
func validatePassword(password string) (bool, string) {
if len(password) == 0 {
return false, "Password cannot be empty"
}
var (
hasUpper bool
hasLower bool
hasDigit bool
hasSpecial bool
)
for _, char := range password {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsDigit(char):
hasDigit = true
case unicode.IsPunct(char) || unicode.IsSymbol(char):
hasSpecial = true
}
}
var missing []string
if !hasUpper {
missing = append(missing, "an uppercase letter")
}
if !hasLower {
missing = append(missing, "a lowercase letter")
}
if !hasDigit {
missing = append(missing, "a digit")
}
if !hasSpecial {
missing = append(missing, "a special character")
}
if len(missing) > 0 {
return false, fmt.Sprintf("Password must contain %s", strings.Join(missing, ", "))
}
return true, ""
}
func createConfigFiles(config Config) error {
os.MkdirAll("config", 0755)
os.MkdirAll("config/letsencrypt", 0755)

View file

@ -1128,5 +1128,9 @@
"light": "light",
"dark": "dark",
"system": "system",
"theme": "Theme"
"theme": "Theme",
"initialSetupTitle": "Initial Server Setup",
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
"createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account."
}

View file

@ -197,21 +197,6 @@ export const configSchema = z.object({
no_reply: z.string().email().optional()
})
.optional(),
users: z.object({
server_admin: z.object({
email: z
.string()
.email()
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
.pipe(z.string().email())
.transform((v) => v.toLowerCase()),
password: passwordSchema
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
.pipe(passwordSchema)
})
}),
flags: z
.object({
require_email_verification: z.boolean().optional(),

View file

@ -10,3 +10,5 @@ export * from "./changePassword";
export * from "./requestPasswordReset";
export * from "./resetPassword";
export * from "./checkResourceSession";
export * from "./setServerAdmin";
export * from "./initialSetupComplete";

View file

@ -0,0 +1,42 @@
import { NextFunction, Request, Response } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response } from "@server/lib";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
export type InitialSetupCompleteResponse = {
complete: boolean;
};
export async function initialSetupComplete(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const [existing] = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
return response<InitialSetupCompleteResponse>(res, {
data: {
complete: !!existing
},
success: true,
error: false,
message: "Initial setup check completed",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to check initial setup completion"
)
);
}
}

View file

@ -0,0 +1,88 @@
import { NextFunction, Request, Response } from "express";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import createHttpError from "http-errors";
import { generateId } from "@server/auth/sessions/app";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { passwordSchema } from "@server/auth/passwordSchema";
import { response } from "@server/lib";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
import { UserType } from "@server/types/UserTypes";
import moment from "moment";
export const bodySchema = z.object({
email: z.string().toLowerCase().email(),
password: passwordSchema
});
export type SetServerAdminBody = z.infer<typeof bodySchema>;
export type SetServerAdminResponse = null;
export async function setServerAdmin(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { email, password } = parsedBody.data;
const [existing] = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (existing) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admin already exists"
)
);
}
const passwordHash = await hashPassword(password);
const userId = generateId(15);
await db.insert(users).values({
userId: userId,
email: email,
type: UserType.Internal,
username: email,
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,
emailVerified: true
});
return response<SetServerAdminResponse>(res, {
data: null,
success: true,
error: false,
message: "Server admin set successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to set server admin"
)
);
}
}

View file

@ -785,3 +785,6 @@ authRouter.post("/access-token", resource.authWithAccessToken);
authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl);
authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
authRouter.put("/set-server-admin", auth.setServerAdmin);
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);

View file

@ -1,13 +1,11 @@
import { ensureActions } from "./ensureActions";
import { copyInConfig } from "./copyInConfig";
import { setupServerAdmin } from "./setupServerAdmin";
import logger from "@server/logger";
import { clearStaleData } from "./clearStaleData";
export async function runSetupFunctions() {
try {
await copyInConfig(); // copy in the config to the db as needed
await setupServerAdmin();
await ensureActions(); // make sure all of the actions are in the db and the roles
await clearStaleData();
} catch (error) {

View file

@ -1,85 +0,0 @@
import { generateId, invalidateAllSessions } from "@server/auth/sessions/app";
import { hashPassword, verifyPassword } from "@server/auth/password";
import config from "@server/lib/config";
import { db } from "@server/db";
import { users } from "@server/db";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
import moment from "moment";
import { fromError } from "zod-validation-error";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export async function setupServerAdmin() {
const {
server_admin: { email, password }
} = config.getRawConfig().users;
const parsed = passwordSchema.safeParse(password);
if (!parsed.success) {
throw Error(
`Invalid server admin password: ${fromError(parsed.error).toString()}`
);
}
const passwordHash = await hashPassword(password);
await db.transaction(async (trx) => {
try {
const [existing] = await trx
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (existing) {
const passwordChanged = !(await verifyPassword(
password,
existing.passwordHash!
));
if (passwordChanged) {
await trx
.update(users)
.set({ passwordHash })
.where(eq(users.userId, existing.userId));
// this isn't using the transaction, but it's probably fine
await invalidateAllSessions(existing.userId);
logger.info(`Server admin password updated`);
}
if (existing.email !== email) {
await trx
.update(users)
.set({ email, username: email })
.where(eq(users.userId, existing.userId));
logger.info(`Server admin email updated`);
}
} else {
const userId = generateId(15);
await trx.update(users).set({ serverAdmin: false });
await db.insert(users).values({
userId: userId,
email: email,
type: UserType.Internal,
username: email,
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,
emailVerified: true
});
logger.info(`Server admin created`);
}
} catch (e) {
console.error("Failed to setup server admin");
logger.error(e);
trx.rollback();
}
});
}

View file

@ -0,0 +1,17 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
export default async function Layout(props: { children: React.ReactNode }) {
const setupRes = await internal.get<
AxiosResponse<InitialSetupCompleteResponse>
>(`/auth/initial-setup-complete`, await authCookieHeader());
const complete = setupRes.data.data.complete;
if (complete) {
redirect("/");
}
return <div>{props.children}</div>;
}

View file

@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { passwordSchema } from "@server/auth/passwordSchema";
const formSchema = z
.object({
email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema,
confirmPassword: z.string()
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match"
});
export default function InitialSetupPage() {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
setError(null);
try {
const res = await api.put("/auth/set-server-admin", {
email: values.email,
password: values.password
});
if (res && res.status === 200) {
router.replace("/");
return;
}
} catch (e) {
setError(formatAxiosError(e, t("setupErrorCreateAdmin")));
}
setLoading(false);
}
return (
<Card className="w-full max-w-md mx-auto mt-12">
<CardHeader>
<div className="flex flex-row items-center justify-center">
<Image
src="/logo/pangolin_orange.svg"
alt={t("pangolinLogoAlt")}
width={100}
height={100}
/>
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
{t("initialSetupTitle")}
</h1>
<CardDescription>
{t("initialSetupDescription")}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="username"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("password")}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
autoComplete="new-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("confirmPassword")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
autoComplete="new-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
>
{t("createAdminAccount")}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View file

@ -11,6 +11,7 @@ import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Layout } from "@app/components/Layout";
import { rootNavItems } from "./navigation";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
export const dynamic = "force-dynamic";
@ -27,6 +28,15 @@ export default async function Page(props: {
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const setupRes = await internal.get<
AxiosResponse<InitialSetupCompleteResponse>
>(`/auth/initial-setup-complete`, await authCookieHeader());
const complete = setupRes.data.data.complete;
if (!complete) {
console.log("compelte", complete);
redirect("/auth/initial-setup");
}
if (!user) {
if (params.redirect) {
const safe = cleanRedirect(params.redirect);

View file

@ -14,6 +14,8 @@ const alertVariants = cva(
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
info:
"border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500",
},
},
defaultVariants: {