From 9e04bc2d97a2f5b8244295ed97570290a601254a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:56:50 -0500 Subject: [PATCH] add an account step to the setup wizard --- web/e2e/helpers/setup-wizard.ts | 8 + web/public/locales/en/views/setup.json | 13 ++ web/src/components/setup/SetupAccount.tsx | 202 ++++++++++++++++++++++ web/src/pages/SetupWizard.tsx | 26 ++- 4 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 web/src/components/setup/SetupAccount.tsx diff --git a/web/e2e/helpers/setup-wizard.ts b/web/e2e/helpers/setup-wizard.ts index 4c86d5ef20..9013465ab8 100644 --- a/web/e2e/helpers/setup-wizard.ts +++ b/web/e2e/helpers/setup-wizard.ts @@ -34,6 +34,14 @@ export async function installFirstRun( export async function gotoDetectorStep(page: Page, addCamera: () => void) { await page.getByRole("button", { name: "Get Started" }).click(); + + // the account step sits between welcome and camera whenever auth is on, + // which the default mock config has it + await expect( + page.getByRole("heading", { name: "Secure your account" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Skip" }).click(); + await expect(page.getByText("Add Your First Camera")).toBeVisible(); // the camera step's Next only renders once its local addedCameras fills, diff --git a/web/public/locales/en/views/setup.json b/web/public/locales/en/views/setup.json index 5a276504b3..8eb2d40a34 100644 --- a/web/public/locales/en/views/setup.json +++ b/web/public/locales/en/views/setup.json @@ -2,6 +2,7 @@ "setupWizard": { "steps": { "welcome": "Welcome", + "account": "Account", "camera": "Add Camera", "hwaccel": "Acceleration", "detector": "Detection", @@ -14,6 +15,18 @@ "getStarted": "Get Started", "skipSetup": "Skip setup and configure manually" }, + "account": { + "title": "Secure your account", + "descriptionSignedIn": "You're signed in as {{username}} using the temporary password from the Frigate logs. Set one you'll remember.", + "descriptionAnonymous": "This port doesn't require a login. Set a password for the admin account so you can sign in on the secured port.", + "passwordSet": "Password set", + "changePassword": "Change password", + "usersTitle": "Other users", + "usersDescription": "Add accounts for anyone else who needs access. Roles can be changed later in Settings.", + "addUser": "Add user", + "usersFailed": "Could not load the user list. You can still set the admin password.", + "userFailed": "Failed to add the user. Please try again." + }, "camera": { "title": "Add Your First Camera", "description": "Connect a camera to start monitoring. You can also add more cameras later in Settings.", diff --git a/web/src/components/setup/SetupAccount.tsx b/web/src/components/setup/SetupAccount.tsx new file mode 100644 index 0000000000..2573b523d9 --- /dev/null +++ b/web/src/components/setup/SetupAccount.tsx @@ -0,0 +1,202 @@ +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import CreateUserDialog from "@/components/overlay/CreateUserDialog"; +import SetPasswordDialog from "@/components/overlay/SetPasswordDialog"; +import { Button } from "@/components/ui/button"; +import { AuthContext } from "@/context/auth-context"; +import axios from "axios"; +import { useCallback, useContext, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FaCircleCheck } from "react-icons/fa6"; +import { toast } from "sonner"; +import useSWR from "swr"; + +type User = { + username: string; + role: string; +}; + +type SetupAccountProps = { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +}; + +export default function SetupAccount({ + onNext, + onBack, + onSkip, +}: SetupAccountProps) { + const { t } = useTranslation(["views/setup"]); + const { auth } = useContext(AuthContext); + + const { + data: users, + isLoading, + error: usersError, + mutate: mutateUsers, + } = useSWR("users", { revalidateOnFocus: false }); + + // the internal port has no signed in user, so the built-in admin is the + // account being secured + const adminUsername = auth.isAuthenticated + ? (auth.user?.username ?? "admin") + : "admin"; + + const [showPassword, setShowPassword] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [passwordSaving, setPasswordSaving] = useState(false); + const [passwordSet, setPasswordSet] = useState(false); + const [showCreate, setShowCreate] = useState(false); + + const handleSavePassword = useCallback( + (password: string) => { + setPasswordSaving(true); + axios + .put(`users/${adminUsername}/password`, { password }) + .then(() => { + setShowPassword(false); + setPasswordError(null); + setPasswordSet(true); + }) + .catch((error) => { + setPasswordError( + error.response?.data?.message || + error.response?.data?.detail || + t("setupWizard.errors.saveFailed"), + ); + }) + .finally(() => setPasswordSaving(false)); + }, + [adminUsername, t], + ); + + const handleCreateUser = useCallback( + (username: string, password: string, role: string) => + axios + .post("users", { username, password, role }) + .then(() => { + setShowCreate(false); + mutateUsers(); + }) + .catch((error) => { + toast.error( + error.response?.data?.message || + error.response?.data?.detail || + t("setupWizard.account.userFailed"), + ); + }), + [mutateUsers, t], + ); + + const otherUsers = (users ?? []).filter( + (user) => user.username !== adminUsername, + ); + + return ( +
+
+

+ {t("setupWizard.account.title")} +

+

+ {auth.isAuthenticated + ? t("setupWizard.account.descriptionSignedIn", { + username: adminUsername, + }) + : t("setupWizard.account.descriptionAnonymous")} +

+
+ +
+
+ {adminUsername} + {passwordSet && ( + + + {t("setupWizard.account.passwordSet")} + + )} +
+ +
+ +
+
+

+ {t("setupWizard.account.usersTitle")} +

+

+ {t("setupWizard.account.usersDescription")} +

+
+ + {isLoading && } + + {usersError && ( +

+ {t("setupWizard.account.usersFailed")} +

+ )} + + {otherUsers.map((user) => ( +
+ {user.username} + {user.role} +
+ ))} + + +
+ +
+ +
+ + +
+
+ + {/* no username prop: passing one puts the dialog in current-password + mode, which the admin is exempt from and an anonymous internal port + user has no way to satisfy */} + { + setShowPassword(false); + setPasswordError(null); + }} + /> + + setShowCreate(false)} + /> +
+ ); +} diff --git a/web/src/pages/SetupWizard.tsx b/web/src/pages/SetupWizard.tsx index c0f763db8e..3743313d37 100644 --- a/web/src/pages/SetupWizard.tsx +++ b/web/src/pages/SetupWizard.tsx @@ -1,4 +1,5 @@ import StepIndicator from "@/components/indicators/StepIndicator"; +import SetupAccount from "@/components/setup/SetupAccount"; import SetupCamera from "@/components/setup/SetupCamera"; import SetupComplete from "@/components/setup/SetupComplete"; import SetupDetector from "@/components/setup/SetupDetector"; @@ -8,13 +9,16 @@ import SetupWelcome from "@/components/setup/SetupWelcome"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { useTheme } from "@/context/theme-provider"; +import { FrigateConfig } from "@/types/frigateConfig"; import { dismissSetup } from "@/utils/setupWizard"; import { useCallback, useMemo, useReducer } from "react"; import { useTranslation } from "react-i18next"; import { LuMoon, LuSun } from "react-icons/lu"; +import useSWR from "swr"; type StepKey = | "welcome" + | "account" | "camera" | "detector" | "hwaccel" @@ -23,6 +27,7 @@ type StepKey = const STEP_KEYS: StepKey[] = [ "welcome", + "account", "camera", "detector", "hwaccel", @@ -122,7 +127,18 @@ export default function SetupWizard() { const [state, dispatch] = useReducer(wizardReducer, initialState); const { theme, systemTheme, setTheme } = useTheme(); - const steps = STEP_KEYS; + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + // with native auth off there are no users to manage, so the step would lie + const steps = useMemo( + () => + config?.auth?.enabled === false + ? STEP_KEYS.filter((key) => key !== "account") + : STEP_KEYS, + [config], + ); const stepLabels = useMemo( () => steps.map((key) => `setupWizard.steps.${key}`), [steps], @@ -183,6 +199,14 @@ export default function SetupWizard() { onSkip={handleSkipSetup} /> ); + case "account": + return ( + + ); case "camera": return ; case "detector":