add an account step to the setup wizard

This commit is contained in:
Josh Hawkins
2026-08-26 12:01:57 -05:00
parent e0a869de25
commit 9e04bc2d97
4 changed files with 248 additions and 1 deletions
+8
View File
@@ -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,
+13
View File
@@ -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.",
+202
View File
@@ -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<User[]>("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<string | null>(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 (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.account.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{auth.isAuthenticated
? t("setupWizard.account.descriptionSignedIn", {
username: adminUsername,
})
: t("setupWizard.account.descriptionAnonymous")}
</p>
</div>
<div className="flex items-center justify-between rounded-md border p-4">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{adminUsername}</span>
{passwordSet && (
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<FaCircleCheck className="size-3 text-success" />
{t("setupWizard.account.passwordSet")}
</span>
)}
</div>
<Button
type="button"
variant="select"
onClick={() => setShowPassword(true)}
>
{t("setupWizard.account.changePassword")}
</Button>
</div>
<div className="flex flex-col gap-2">
<div>
<h3 className="text-sm font-medium">
{t("setupWizard.account.usersTitle")}
</h3>
<p className="text-xs text-muted-foreground">
{t("setupWizard.account.usersDescription")}
</p>
</div>
{isLoading && <ActivityIndicator />}
{usersError && (
<p className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
{t("setupWizard.account.usersFailed")}
</p>
)}
{otherUsers.map((user) => (
<div
key={user.username}
className="flex items-center justify-between rounded-md border p-3"
>
<span className="text-sm">{user.username}</span>
<span className="text-xs text-muted-foreground">{user.role}</span>
</div>
))}
<Button
type="button"
variant="outline"
className="self-start"
onClick={() => setShowCreate(true)}
>
{t("setupWizard.account.addUser")}
</Button>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
<Button type="button" variant="outline" onClick={onSkip}>
{t("setupWizard.actions.skip")}
</Button>
<Button type="button" variant="select" onClick={onNext}>
{t("setupWizard.actions.next")}
</Button>
</div>
</div>
{/* 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 */}
<SetPasswordDialog
show={showPassword}
initialError={passwordError}
isLoading={passwordSaving}
onSave={handleSavePassword}
onCancel={() => {
setShowPassword(false);
setPasswordError(null);
}}
/>
<CreateUserDialog
show={showCreate}
onCreate={handleCreateUser}
onCancel={() => setShowCreate(false)}
/>
</div>
);
}
+25 -1
View File
@@ -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<FrigateConfig>("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 (
<SetupAccount
onNext={handleSkipStep}
onBack={handleBack}
onSkip={handleSkipStep}
/>
);
case "camera":
return <SetupCamera onNext={handleCameraNext} onBack={handleBack} />;
case "detector":