mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 06:42:51 +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) {
|
export async function gotoDetectorStep(page: Page, addCamera: () => void) {
|
||||||
await page.getByRole("button", { name: "Get Started" }).click();
|
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();
|
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
||||||
|
|
||||||
// the camera step's Next only renders once its local addedCameras fills,
|
// the camera step's Next only renders once its local addedCameras fills,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"setupWizard": {
|
"setupWizard": {
|
||||||
"steps": {
|
"steps": {
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
|
"account": "Account",
|
||||||
"camera": "Add Camera",
|
"camera": "Add Camera",
|
||||||
"hwaccel": "Acceleration",
|
"hwaccel": "Acceleration",
|
||||||
"detector": "Detection",
|
"detector": "Detection",
|
||||||
@@ -14,6 +15,18 @@
|
|||||||
"getStarted": "Get Started",
|
"getStarted": "Get Started",
|
||||||
"skipSetup": "Skip setup and configure manually"
|
"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": {
|
"camera": {
|
||||||
"title": "Add Your First Camera",
|
"title": "Add Your First Camera",
|
||||||
"description": "Connect a camera to start monitoring. You can also add more cameras later in Settings.",
|
"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 StepIndicator from "@/components/indicators/StepIndicator";
|
||||||
|
import SetupAccount from "@/components/setup/SetupAccount";
|
||||||
import SetupCamera from "@/components/setup/SetupCamera";
|
import SetupCamera from "@/components/setup/SetupCamera";
|
||||||
import SetupComplete from "@/components/setup/SetupComplete";
|
import SetupComplete from "@/components/setup/SetupComplete";
|
||||||
import SetupDetector from "@/components/setup/SetupDetector";
|
import SetupDetector from "@/components/setup/SetupDetector";
|
||||||
@@ -8,13 +9,16 @@ import SetupWelcome from "@/components/setup/SetupWelcome";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { useTheme } from "@/context/theme-provider";
|
import { useTheme } from "@/context/theme-provider";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { dismissSetup } from "@/utils/setupWizard";
|
import { dismissSetup } from "@/utils/setupWizard";
|
||||||
import { useCallback, useMemo, useReducer } from "react";
|
import { useCallback, useMemo, useReducer } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuMoon, LuSun } from "react-icons/lu";
|
import { LuMoon, LuSun } from "react-icons/lu";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
type StepKey =
|
type StepKey =
|
||||||
| "welcome"
|
| "welcome"
|
||||||
|
| "account"
|
||||||
| "camera"
|
| "camera"
|
||||||
| "detector"
|
| "detector"
|
||||||
| "hwaccel"
|
| "hwaccel"
|
||||||
@@ -23,6 +27,7 @@ type StepKey =
|
|||||||
|
|
||||||
const STEP_KEYS: StepKey[] = [
|
const STEP_KEYS: StepKey[] = [
|
||||||
"welcome",
|
"welcome",
|
||||||
|
"account",
|
||||||
"camera",
|
"camera",
|
||||||
"detector",
|
"detector",
|
||||||
"hwaccel",
|
"hwaccel",
|
||||||
@@ -122,7 +127,18 @@ export default function SetupWizard() {
|
|||||||
const [state, dispatch] = useReducer(wizardReducer, initialState);
|
const [state, dispatch] = useReducer(wizardReducer, initialState);
|
||||||
const { theme, systemTheme, setTheme } = useTheme();
|
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(
|
const stepLabels = useMemo(
|
||||||
() => steps.map((key) => `setupWizard.steps.${key}`),
|
() => steps.map((key) => `setupWizard.steps.${key}`),
|
||||||
[steps],
|
[steps],
|
||||||
@@ -183,6 +199,14 @@ export default function SetupWizard() {
|
|||||||
onSkip={handleSkipSetup}
|
onSkip={handleSkipSetup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "account":
|
||||||
|
return (
|
||||||
|
<SetupAccount
|
||||||
|
onNext={handleSkipStep}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSkip={handleSkipStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "camera":
|
case "camera":
|
||||||
return <SetupCamera onNext={handleCameraNext} onBack={handleBack} />;
|
return <SetupCamera onNext={handleCameraNext} onBack={handleBack} />;
|
||||||
case "detector":
|
case "detector":
|
||||||
|
|||||||
Reference in New Issue
Block a user