mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 19:06:52 +03:00
add an account step to the setup wizard
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user