Introducing the Setup Wizard for creating the first user

- no longer setup a default
- still able to do that with env vars however
This commit is contained in:
Jamie Curnow
2025-09-08 19:47:00 +10:00
parent 432afe73ad
commit fa11945235
31 changed files with 867 additions and 660 deletions

View File

@@ -13,8 +13,9 @@ import {
import { useAuthState } from "src/context";
import { useHealth } from "src/hooks";
const Dashboard = lazy(() => import("src/pages/Dashboard"));
const Setup = lazy(() => import("src/pages/Setup"));
const Login = lazy(() => import("src/pages/Login"));
const Dashboard = lazy(() => import("src/pages/Dashboard"));
const Settings = lazy(() => import("src/pages/Settings"));
const Certificates = lazy(() => import("src/pages/Certificates"));
const Access = lazy(() => import("src/pages/Access"));
@@ -37,6 +38,10 @@ function Router() {
return <Unhealthy />;
}
if (!health.data?.setup) {
return <Setup />;
}
if (!authenticated) {
return (
<Suspense fallback={<LoadingPage />}>

View File

@@ -88,15 +88,19 @@ interface PostArgs {
url: string;
params?: queryString.StringifiableRecord;
data?: any;
noAuth?: boolean;
}
export async function post({ url, params, data }: PostArgs, abortController?: AbortController) {
export async function post({ url, params, data, noAuth }: PostArgs, abortController?: AbortController) {
const apiUrl = buildUrl({ url, params });
const method = "POST";
let headers = {
...buildAuthHeader(),
};
let headers: Record<string, string> = {};
if (!noAuth) {
headers = {
...buildAuthHeader(),
};
}
let body: string | FormData | undefined;
// Check if the data is an instance of FormData

View File

@@ -1,12 +1,27 @@
import * as api from "./base";
import type { User } from "./models";
export async function createUser(item: User, abortController?: AbortController): Promise<User> {
export interface AuthOptions {
type: string;
secret: string;
}
export interface NewUser {
name: string;
nickname: string;
email: string;
isDisabled?: boolean;
auth?: AuthOptions;
roles?: string[];
}
export async function createUser(item: NewUser, noAuth?: boolean, abortController?: AbortController): Promise<User> {
return await api.post(
{
url: "/users",
// todo: only use whitelist of fields for this data
data: item,
noAuth,
},
abortController,
);

View File

@@ -3,6 +3,7 @@ import type { AppVersion } from "./models";
export interface HealthResponse {
status: string;
version: AppVersion;
setup: boolean;
}
export interface TokenResponse {

View File

@@ -72,6 +72,8 @@
"role.standard-user": "Standard User",
"save": "Save",
"settings.title": "Settings",
"setup.preamble": "Get started by creating your admin account.",
"setup.title": "Welcome!",
"sign-in": "Sign in",
"streams.actions-title": "Stream #{id}",
"streams.add": "Add Stream",

View File

@@ -218,6 +218,12 @@
"settings.title": {
"defaultMessage": "Settings"
},
"setup.preamble": {
"defaultMessage": "Get started by creating your admin account."
},
"setup.title": {
"defaultMessage": "Welcome!"
},
"sign-in": {
"defaultMessage": "Sign in"
},

View File

@@ -122,18 +122,15 @@ const Dashboard = () => {
<pre>
<code>{`Todo:
- Users: permissions modal and trigger after adding user
- modal dialgs for everything
- Tables
- check mobile
- fix bad jwt not refreshing entire page
- add help docs for host types
- show user as disabled on user table
More for api, then implement here:
- Properly implement refresh tokens
- don't create default user, instead use the is_setup from v3
- also remove the initial user/pass env vars
- update docs for this
- Add error message_18n for all backend errors
- minor: certificates expand with hosts needs to omit 'is_deleted'
`}</code>

View File

@@ -0,0 +1,10 @@
.logo {
width: 200px;
}
.helperBtns {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
}

View File

@@ -0,0 +1,191 @@
import { useQueryClient } from "@tanstack/react-query";
import cn from "classnames";
import { Field, Form, Formik } from "formik";
import { useState } from "react";
import { Alert } from "react-bootstrap";
import { createUser } from "src/api/backend";
import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components";
import { useAuthState } from "src/context";
import { intl } from "src/locale";
import { validateEmail, validateString } from "src/modules/Validations";
import styles from "./index.module.css";
interface Payload {
name: string;
email: string;
password: string;
}
export default function Setup() {
const queryClient = useQueryClient();
const { login } = useAuthState();
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const onSubmit = async (values: Payload, { setSubmitting }: any) => {
setErrorMsg(null);
// Set a nickname, which is the first word of the name
const nickname = values.name.split(" ")[0];
const { password, ...payload } = {
...values,
...{
nickname,
auth: {
type: "password",
secret: values.password,
},
},
};
try {
const user = await createUser(payload, true);
if (user && typeof user.id !== "undefined" && user.id) {
try {
await login(user.email, password);
// Trigger a Health change
await queryClient.refetchQueries({ queryKey: ["health"] });
// window.location.reload();
} catch (err: any) {
setErrorMsg(err.message);
}
} else {
setErrorMsg("cannot_create_user");
}
} catch (err: any) {
setErrorMsg(err.message);
}
setSubmitting(false);
};
return (
<Page className="page page-center">
<div className={cn("d-none", "d-md-flex", styles.helperBtns)}>
<LocalePicker />
<ThemeSwitcher />
</div>
<div className="container container-tight py-4">
<div className="text-center mb-4">
<img
className={styles.logo}
src="/images/logo-text-horizontal-grey.png"
alt="Nginx Proxy Manager"
/>
</div>
<div className="card card-md">
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg}
</Alert>
<Formik
initialValues={
{
name: "",
email: "",
password: "",
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<div className="card-body text-center py-4 p-sm-5">
<h1 className="mt-5">{intl.formatMessage({ id: "setup.title" })}</h1>
<p className="text-secondary">{intl.formatMessage({ id: "setup.preamble" })}</p>
</div>
<hr />
<div className="card-body">
<div className="mb-3">
<Field name="name" validate={validateString(1, 50)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="name"
className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.full-name" })}
{...field}
/>
<label htmlFor="name">
{intl.formatMessage({ id: "user.full-name" })}
</label>
{form.errors.name ? (
<div className="invalid-feedback">
{form.errors.name && form.touched.name
? form.errors.name
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="email"
type="email"
className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
{...field}
/>
<label htmlFor="email">
{intl.formatMessage({ id: "email-address" })}
</label>
{form.errors.email ? (
<div className="invalid-feedback">
{form.errors.email && form.touched.email
? form.errors.email
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
<div className="mb-3">
<Field name="password" validate={validateString(8, 100)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="password"
type="password"
className={`form-control ${form.errors.password && form.touched.password ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.new-password" })}
{...field}
/>
<label htmlFor="password">
{intl.formatMessage({ id: "user.new-password" })}
</label>
{form.errors.password ? (
<div className="invalid-feedback">
{form.errors.password && form.touched.password
? form.errors.password
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
<div className="text-center my-3 mx-3">
<Button
type="submit"
actionType="primary"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
className="w-100"
>
{intl.formatMessage({ id: "save" })}
</Button>
</div>
</Form>
)}
</Formik>
</div>
</div>
</Page>
);
}