mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 02:48:58 +03:00
UI viewer role (#16978)
* db migration * db model * assign admin role on password reset * add role to jwt and api responses * don't restrict api access for admins yet * use json response * frontend auth context * update auth form for profile endpoint * add access denied page * add protected routes * auth hook * dialogs * user settings view * restrict viewer access to settings * restrict camera functions for viewer role * add password dialog to account menu * spacing tweak * migrator default to admin * escape quotes in migrator * ui tweaks * tweaks * colors * colors * fix merge conflict * fix icons * add api layer enforcement * ui tweaks * fix error message * debug * clean up * remove print * guard apis for admin only * fix tests * fix review tests * use correct error responses from api in toasts * add role to account menu
This commit is contained in:
@@ -20,24 +20,23 @@ import {
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const { login } = React.useContext(AuthContext);
|
||||
|
||||
const formSchema = z.object({
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
user: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
user: "",
|
||||
password: "",
|
||||
},
|
||||
defaultValues: { user: "", password: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
@@ -50,11 +49,14 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
password: values.password,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": 1,
|
||||
},
|
||||
headers: { "X-CSRF-TOKEN": 1 },
|
||||
},
|
||||
);
|
||||
const profileRes = await axios.get("/profile", { withCredentials: true });
|
||||
login({
|
||||
username: profileRes.data.username,
|
||||
role: profileRes.data.role || "viewer",
|
||||
});
|
||||
window.location.href = baseUrl;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useContext } from "react";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
|
||||
export default function ProtectedRoute({
|
||||
requiredRoles,
|
||||
}: {
|
||||
requiredRoles: ("admin" | "viewer")[];
|
||||
}) {
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
);
|
||||
}
|
||||
|
||||
// Unauthenticated mode
|
||||
if (!auth.isAuthenticated) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// Authenticated mode (8971): require login
|
||||
if (!auth.user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback
|
||||
// though isAuthenticated should catch this
|
||||
if (auth.user.role === null) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (!requiredRoles.includes(auth.user.role)) {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -281,10 +281,13 @@ function NewGroupDialog({
|
||||
.catch((error) => {
|
||||
setOpen(false);
|
||||
setEditState("none");
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
@@ -725,10 +728,13 @@ export function CameraGroupEdit({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -44,8 +44,12 @@ export default function SearchActionGroup({
|
||||
pullLatestData();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to delete tracked objects.", {
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete tracked objects.: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,22 +18,52 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { DialogClose } from "../ui/dialog";
|
||||
import { LuLogOut } from "react-icons/lu";
|
||||
import { LuLogOut, LuSquarePen } from "react-icons/lu";
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import SetPasswordDialog from "../overlay/SetPasswordDialog";
|
||||
|
||||
type AccountSettingsProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
const { data: profile } = useSWR("profile");
|
||||
const { data: config } = useSWR("config");
|
||||
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
|
||||
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
const Container = isDesktop ? DropdownMenu : Drawer;
|
||||
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
||||
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
|
||||
|
||||
const handlePasswordSave = async (password: string) => {
|
||||
if (!profile?.username || profile.username === "anonymous") return;
|
||||
axios
|
||||
.put(`users/${profile.username}/password`, { password })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setPasswordDialogOpen(false);
|
||||
toast.success("Password updated successfully.", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Error setting password: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Container modal={!isDesktop}>
|
||||
<Trigger>
|
||||
@@ -65,9 +95,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
>
|
||||
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||
<DropdownMenuLabel>
|
||||
Current User: {profile?.username || "anonymous"}
|
||||
Current User: {profile?.username || "anonymous"}{" "}
|
||||
{profile?.role && `(${profile.role})`}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
||||
{profile?.username && profile.username !== "anonymous" && (
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Set Password"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>Set Password</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
@@ -81,6 +124,12 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Content>
|
||||
<SetPasswordDialog
|
||||
show={passwordDialogOpen}
|
||||
onSave={handlePasswordSave}
|
||||
onCancel={() => setPasswordDialogOpen(false)}
|
||||
username={profile?.username}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { CgDarkMode } from "react-icons/cg";
|
||||
import {
|
||||
@@ -33,10 +32,8 @@ import {
|
||||
useTheme,
|
||||
} from "@/context/theme-provider";
|
||||
import { IoColorPalette } from "react-icons/io5";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRestart } from "@/api/ws";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -55,21 +52,27 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useSWR from "swr";
|
||||
import RestartDialog from "../overlay/dialog/RestartDialog";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import SetPasswordDialog from "../overlay/SetPasswordDialog";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
const { data: profile } = useSWR("profile");
|
||||
const { data: config } = useSWR("config");
|
||||
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
||||
|
||||
// settings
|
||||
|
||||
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const Container = isDesktop ? DropdownMenu : Drawer;
|
||||
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
||||
@@ -79,6 +82,29 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
|
||||
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
|
||||
|
||||
const handlePasswordSave = async (password: string) => {
|
||||
if (!profile?.username || profile.username === "anonymous") return;
|
||||
axios
|
||||
.put(`users/${profile.username}/password`, { password })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setPasswordDialogOpen(false);
|
||||
toast.success("Password updated successfully.", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Error setting password: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container modal={!isDesktop}>
|
||||
@@ -121,13 +147,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
>
|
||||
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||
{isMobile && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<DropdownMenuLabel>
|
||||
Current User: {profile?.username || "anonymous"}
|
||||
Current User: {profile?.username || "anonymous"}{" "}
|
||||
{profile?.role && `(${profile.role})`}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator
|
||||
className={isDesktop ? "mt-3" : "mt-1"}
|
||||
/>
|
||||
{profile?.username && profile.username !== "anonymous" && (
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Set Password"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>Set Password</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
@@ -141,39 +182,45 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</MenuItem>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||
<Link to="/system#general">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="System metrics"
|
||||
>
|
||||
<LuActivity className="mr-2 size-4" />
|
||||
<span>System metrics</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/logs">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="System logs"
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
<span>System logs</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||
<Link to="/system#general">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="System metrics"
|
||||
>
|
||||
<LuActivity className="mr-2 size-4" />
|
||||
<span>System metrics</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/logs">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="System logs"
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
<span>System logs</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
<DropdownMenuLabel
|
||||
className={isDesktop && isAdmin ? "mt-3" : "mt-1"}
|
||||
>
|
||||
Configuration
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -191,143 +238,143 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>Settings</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/config">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Configuration editor"
|
||||
>
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>Configuration editor</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Appearance
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Dark Mode</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Link to="/config">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Light mode"
|
||||
onClick={() => setTheme("light")}
|
||||
aria-label="Configuration editor"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<>
|
||||
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Light</span>
|
||||
)}
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>Configuration editor</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Dark mode"
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
Dark
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Dark</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Use the system settings for light or dark mode"
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
<>
|
||||
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
|
||||
System
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">System</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Theme</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
{colorSchemes.map((scheme) => (
|
||||
<MenuItem
|
||||
key={scheme}
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label={`Color scheme - ${scheme}`}
|
||||
onClick={() => setColorScheme(scheme)}
|
||||
>
|
||||
{scheme === colorScheme ? (
|
||||
<>
|
||||
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Appearance
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Dark Mode</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Light mode"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<>
|
||||
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Light</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Dark mode"
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
Dark
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">Dark</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Use the system settings for light or dark mode"
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
{theme === "system" ? (
|
||||
<>
|
||||
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
|
||||
System
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">System</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
<SubItem>
|
||||
<SubItemTrigger
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSunMoon className="mr-2 size-4" />
|
||||
<span>Theme</span>
|
||||
</SubItemTrigger>
|
||||
<Portal>
|
||||
<SubItemContent
|
||||
className={
|
||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
{colorSchemes.map((scheme) => (
|
||||
<MenuItem
|
||||
key={scheme}
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label={`Color scheme - ${scheme}`}
|
||||
onClick={() => setColorScheme(scheme)}
|
||||
>
|
||||
{scheme === colorScheme ? (
|
||||
<>
|
||||
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-6 mr-2">
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SubItemContent>
|
||||
</Portal>
|
||||
</SubItem>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Help
|
||||
</DropdownMenuLabel>
|
||||
@@ -357,17 +404,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>GitHub</span>
|
||||
</MenuItem>
|
||||
</a>
|
||||
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Restart Frigate"
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
>
|
||||
<LuRotateCw className="mr-2 size-4" />
|
||||
<span>Restart Frigate</span>
|
||||
</MenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator
|
||||
className={isDesktop ? "mt-3" : "mt-1"}
|
||||
/>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label="Restart Frigate"
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
>
|
||||
<LuRotateCw className="mr-2 size-4" />
|
||||
<span>Restart Frigate</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Content>
|
||||
</Container>
|
||||
@@ -376,6 +431,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
onClose={() => setRestartDialogOpen(false)}
|
||||
onRestart={() => sendRestart("restart")}
|
||||
/>
|
||||
<SetPasswordDialog
|
||||
show={passwordDialogOpen}
|
||||
onSave={handlePasswordSave}
|
||||
onCancel={() => setPasswordDialogOpen(false)}
|
||||
username={profile?.username}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,8 +74,12 @@ export default function SearchResultActions({
|
||||
refreshResults();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to delete tracked object.", {
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete tracked object: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from "../ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -12,20 +13,31 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Shield, User } from "lucide-react";
|
||||
import { LuCheck, LuX } from "react-icons/lu";
|
||||
|
||||
type CreateUserOverlayProps = {
|
||||
show: boolean;
|
||||
onCreate: (user: string, password: string) => void;
|
||||
onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function CreateUserDialog({
|
||||
show,
|
||||
onCreate,
|
||||
@@ -33,15 +45,22 @@ export default function CreateUserDialog({
|
||||
}: CreateUserOverlayProps) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
user: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[A-Za-z0-9._]+$/, {
|
||||
message: "Username may only include letters, numbers, . or _",
|
||||
}),
|
||||
password: z.string(),
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
user: z
|
||||
.string()
|
||||
.min(1, "Username is required")
|
||||
.regex(/^[A-Za-z0-9._]+$/, {
|
||||
message: "Username may only include letters, numbers, . or _",
|
||||
}),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
role: z.enum(["admin", "viewer"]),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -49,32 +68,93 @@ export default function CreateUserDialog({
|
||||
defaultValues: {
|
||||
user: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
role: "viewer",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
setIsLoading(true);
|
||||
await onCreate(values.user, values.password);
|
||||
await onCreate(values.user, values.password, values.role);
|
||||
form.reset();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Check if passwords match for real-time feedback
|
||||
const password = form.watch("password");
|
||||
const confirmPassword = form.watch("confirmPassword");
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const showMatchIndicator = password && confirmPassword;
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
form.reset({
|
||||
user: "",
|
||||
password: "",
|
||||
role: "viewer",
|
||||
});
|
||||
}
|
||||
}, [show, form]);
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset({
|
||||
user: "",
|
||||
password: "",
|
||||
role: "viewer",
|
||||
});
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User</DialogTitle>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new user account and specify an role for access to areas of
|
||||
the Frigate UI.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-5 py-4"
|
||||
>
|
||||
<FormField
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User</FormLabel>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
Username
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="Enter username"
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs text-muted-foreground">
|
||||
Only letters, numbers, periods and underscores allowed.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter password"
|
||||
type="password"
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -82,30 +162,121 @@ export default function CreateUserDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="password"
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel className="text-sm font-medium">
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
placeholder="Confirm password"
|
||||
type="password"
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{showMatchIndicator && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||
{passwordsMatch ? (
|
||||
<>
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
<span className="text-green-600">
|
||||
Passwords match
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
<span className="text-red-600">
|
||||
Passwords don't match
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Create user"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
|
||||
Create User
|
||||
</Button>
|
||||
|
||||
<FormField
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value="admin"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="viewer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Viewer</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-xs text-muted-foreground">
|
||||
Admins have full access to all features in the Frigate UI.
|
||||
Viewers are limited to viewing cameras, review items, and
|
||||
historical footage in the UI.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Save"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -6,34 +6,61 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
|
||||
type SetPasswordProps = {
|
||||
type DeleteUserDialogProps = {
|
||||
show: boolean;
|
||||
username?: string;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
export default function DeleteUserDialog({
|
||||
show,
|
||||
username,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}: SetPasswordProps) {
|
||||
}: DeleteUserDialogProps) {
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader className="flex flex-col items-center gap-2 sm:items-start">
|
||||
<div className="space-y-1 text-center sm:text-left">
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
user account and remove all associated data.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div>Are you sure?</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Confirm delete"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div className="my-4 rounded-md border border-destructive/20 bg-destructive/5 p-4 text-center text-sm">
|
||||
<p className="font-medium text-destructive">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-bold">{username}</span>?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label="Delete"
|
||||
className="flex flex-1"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -99,16 +99,13 @@ export default function ExportDialog({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to start export: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to start export: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to start export: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [camera, name, range, setRange, setName, setMode]);
|
||||
|
||||
|
||||
@@ -106,16 +106,13 @@ export default function MobileReviewSettingsDrawer({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to start export: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to start export: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to start export: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [camera, name, range, setRange, setName, setMode]);
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { useState } from "react";
|
||||
import { LuShield, LuUser } from "react-icons/lu";
|
||||
|
||||
type RoleChangeDialogProps = {
|
||||
show: boolean;
|
||||
username: string;
|
||||
currentRole: "admin" | "viewer";
|
||||
onSave: (role: "admin" | "viewer") => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function RoleChangeDialog({
|
||||
show,
|
||||
username,
|
||||
currentRole,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: RoleChangeDialogProps) {
|
||||
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
|
||||
currentRole,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Change User Role
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update permissions for{" "}
|
||||
<span className="font-medium">{username}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
<p>Select the appropriate role for this user:</p>
|
||||
<ul className="mt-2 space-y-1 pl-5">
|
||||
<li>
|
||||
• <span className="font-medium">Admin:</span> Full access to all
|
||||
features.
|
||||
</li>
|
||||
<li>
|
||||
• <span className="font-medium">Viewer:</span> Limited to Live
|
||||
dashboards, Review, Explore, and Exports only.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={(value) =>
|
||||
setSelectedRole(value as "admin" | "viewer")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin" className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LuShield className="size-4 text-primary" />
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="viewer" className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LuUser className="size-4 text-primary" />
|
||||
<span>Viewer</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Save"
|
||||
className="flex flex-1"
|
||||
onClick={() => onSave(selectedRole)}
|
||||
disabled={selectedRole === currentRole}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { LuCheck, LuX } from "react-icons/lu";
|
||||
|
||||
type SetPasswordProps = {
|
||||
show: boolean;
|
||||
onSave: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export default function SetPasswordDialog({
|
||||
show,
|
||||
onSave,
|
||||
onCancel,
|
||||
username,
|
||||
}: SetPasswordProps) {
|
||||
const [password, setPassword] = useState<string>();
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>("");
|
||||
const [passwordStrength, setPasswordStrength] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setError(null);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
// Simple password strength calculation
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
setPasswordStrength(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let strength = 0;
|
||||
// Length check
|
||||
if (password.length >= 8) strength += 1;
|
||||
// Contains number
|
||||
if (/\d/.test(password)) strength += 1;
|
||||
// Contains special char
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
|
||||
// Contains uppercase
|
||||
if (/[A-Z]/.test(password)) strength += 1;
|
||||
|
||||
setPasswordStrength(strength);
|
||||
}, [password]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!password) {
|
||||
setError("Password cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(password);
|
||||
};
|
||||
|
||||
const getStrengthLabel = () => {
|
||||
if (!password) return "";
|
||||
if (passwordStrength <= 1) return "Weak";
|
||||
if (passwordStrength === 2) return "Medium";
|
||||
if (passwordStrength === 3) return "Strong";
|
||||
return "Very Strong";
|
||||
};
|
||||
|
||||
const getStrengthColor = () => {
|
||||
if (!password) return "bg-gray-200";
|
||||
if (passwordStrength <= 1) return "bg-red-500";
|
||||
if (passwordStrength === 2) return "bg-yellow-500";
|
||||
if (passwordStrength === 3) return "bg-green-500";
|
||||
return "bg-green-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Password</DialogTitle>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle>
|
||||
{username ? `Update Password for ${username}` : "Set Password"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a strong password to secure this account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Save Password"
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSave(password!);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
className="h-10"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Enter new password"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Password strength indicator */}
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||
<div
|
||||
className={`${getStrengthColor()} transition-all duration-300`}
|
||||
style={{ width: `${(passwordStrength / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Password strength:{" "}
|
||||
<span className="font-medium">{getStrengthLabel()}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
className="h-10"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => {
|
||||
setConfirmPassword(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
|
||||
{/* Password match indicator */}
|
||||
{password && confirmPassword && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||
{password === confirmPassword ? (
|
||||
<>
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
<span className="text-green-600">Passwords match</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-3.5 text-red-500" />
|
||||
<span className="text-red-600">Passwords don't match</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Save"
|
||||
className="flex flex-1"
|
||||
onClick={handleSave}
|
||||
disabled={!password || password !== confirmPassword}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -87,10 +87,13 @@ export function AnnotationSettingsPane({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -394,8 +394,12 @@ function ObjectDetailsTab({
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to update the description", {
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to update the description: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
setDesc(search.data.description);
|
||||
@@ -422,11 +426,13 @@ function ObjectDetailsTab({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -492,8 +498,12 @@ function ObjectDetailsTab({
|
||||
setIsSubLabelDialogOpen(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to update sub label.", {
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to update sub label: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,10 +176,13 @@ export default function MotionMaskEditPane({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -208,10 +208,13 @@ export default function ObjectMaskEditPane({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -186,10 +186,13 @@ export default function PolygonItem({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -414,10 +414,13 @@ export default function ZoneEditPane({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
Reference in New Issue
Block a user