From d03f45279cffa7a31b43f040c74b9093e4f2759a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 19 Jun 2025 22:11:05 -0400 Subject: [PATCH] remove server admin from config and add onboarding ui --- config/config.example.yml | 5 - install/config/config.yml | 4 - install/main.go | 78 +-------- messages/en-US.json | 8 +- server/lib/readConfigFile.ts | 15 -- server/routers/auth/index.ts | 2 + server/routers/auth/initialSetupComplete.ts | 42 +++++ server/routers/auth/setServerAdmin.ts | 88 ++++++++++ server/routers/external.ts | 3 + server/setup/index.ts | 2 - server/setup/setupServerAdmin.ts | 85 ---------- src/app/auth/initial-setup/layout.tsx | 17 ++ src/app/auth/initial-setup/page.tsx | 174 ++++++++++++++++++++ src/app/page.tsx | 10 ++ src/components/ui/alert.tsx | 2 + 15 files changed, 345 insertions(+), 190 deletions(-) create mode 100644 server/routers/auth/initialSetupComplete.ts create mode 100644 server/routers/auth/setServerAdmin.ts delete mode 100644 server/setup/setupServerAdmin.ts create mode 100644 src/app/auth/initial-setup/layout.tsx create mode 100644 src/app/auth/initial-setup/page.tsx diff --git a/config/config.example.yml b/config/config.example.yml index 7b5c144d..33ed9370 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -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 diff --git a/install/config/config.yml b/install/config/config.yml index 5a9df5ba..8665b07f 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -54,10 +54,6 @@ email: smtp_pass: "{{.EmailSMTPPass}}" no_reply: "{{.EmailNoReply}}" {{end}} -users: - server_admin: - email: "{{.AdminUserEmail}}" - password: "{{.AdminUserPassword}}" flags: require_email_verification: {{.EnableEmail}} diff --git a/install/main.go b/install/main.go index 9ee4fd1f..5af3cbf7 100644 --- a/install/main.go +++ b/install/main.go @@ -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) diff --git a/messages/en-US.json b/messages/en-US.json index 6873d7f3..4990774b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1128,5 +1128,9 @@ "light": "light", "dark": "dark", "system": "system", - "theme": "Theme" -} \ No newline at end of file + "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." +} diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 7a142739..a82eac75 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -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(), diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index b2eaf8d2..6955e16c 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -10,3 +10,5 @@ export * from "./changePassword"; export * from "./requestPasswordReset"; export * from "./resetPassword"; export * from "./checkResourceSession"; +export * from "./setServerAdmin"; +export * from "./initialSetupComplete"; diff --git a/server/routers/auth/initialSetupComplete.ts b/server/routers/auth/initialSetupComplete.ts new file mode 100644 index 00000000..8da9acd7 --- /dev/null +++ b/server/routers/auth/initialSetupComplete.ts @@ -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 { + try { + const [existing] = await db + .select() + .from(users) + .where(eq(users.serverAdmin, true)); + + return response(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" + ) + ); + } +} diff --git a/server/routers/auth/setServerAdmin.ts b/server/routers/auth/setServerAdmin.ts new file mode 100644 index 00000000..7c49753e --- /dev/null +++ b/server/routers/auth/setServerAdmin.ts @@ -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; + +export type SetServerAdminResponse = null; + +export async function setServerAdmin( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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(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" + ) + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 3bb3ebda..8cb3a19d 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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); diff --git a/server/setup/index.ts b/server/setup/index.ts index b93af2aa..05971893 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -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) { diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts deleted file mode 100644 index f93ea22d..00000000 --- a/server/setup/setupServerAdmin.ts +++ /dev/null @@ -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(); - } - }); -} diff --git a/src/app/auth/initial-setup/layout.tsx b/src/app/auth/initial-setup/layout.tsx new file mode 100644 index 00000000..8407f0da --- /dev/null +++ b/src/app/auth/initial-setup/layout.tsx @@ -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 + >(`/auth/initial-setup-complete`, await authCookieHeader()); + const complete = setupRes.data.data.complete; + if (complete) { + redirect("/"); + } + + return
{props.children}
; +} diff --git a/src/app/auth/initial-setup/page.tsx b/src/app/auth/initial-setup/page.tsx new file mode 100644 index 00000000..17e6c2ec --- /dev/null +++ b/src/app/auth/initial-setup/page.tsx @@ -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(null); + const [checking, setChecking] = useState(true); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + confirmPassword: "" + } + }); + + async function onSubmit(values: z.infer) { + 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 ( + + +
+ {t("pangolinLogoAlt")} +
+
+

+ {t("initialSetupTitle")} +

+ + {t("initialSetupDescription")} + +
+
+ +
+ + ( + + {t("email")} + + + + + + )} + /> + ( + + {t("password")} + + + + + + )} + /> + ( + + + {t("confirmPassword")} + + + + + + + )} + /> + {error && ( + + {error} + + )} + + + +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 6cab7cbd..2d2d6894 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 + >(`/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); diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 8f3e489a..3783ecfe 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -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: {