mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 09:02:15 +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:
@@ -11,10 +11,25 @@ import axios from "axios";
|
||||
import CreateUserDialog from "@/components/overlay/CreateUserDialog";
|
||||
import { toast } from "sonner";
|
||||
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { HiTrash } from "react-icons/hi";
|
||||
import { FaUserEdit } from "react-icons/fa";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
|
||||
|
||||
export default function AuthenticationView() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@@ -23,8 +38,12 @@ export default function AuthenticationView() {
|
||||
const [showSetPassword, setShowSetPassword] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showRoleChange, setShowRoleChange] = useState(false);
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<string>();
|
||||
const [selectedUserRole, setSelectedUserRole] = useState<
|
||||
"admin" | "viewer"
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Authentication Settings - Frigate";
|
||||
@@ -32,142 +51,303 @@ export default function AuthenticationView() {
|
||||
|
||||
const onSavePassword = useCallback((user: string, password: string) => {
|
||||
axios
|
||||
.put(`users/${user}/password`, {
|
||||
password: password,
|
||||
})
|
||||
.put(`users/${user}/password`, { password })
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
if (response.status === 200) {
|
||||
setShowSetPassword(false);
|
||||
toast.success("Password updated successfully", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((_error) => {
|
||||
toast.error("Error setting password", {
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save password: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onCreate = async (user: string, password: string) => {
|
||||
try {
|
||||
await axios.post("users", {
|
||||
username: user,
|
||||
password: password,
|
||||
const onCreate = (
|
||||
user: string,
|
||||
password: string,
|
||||
role: "admin" | "viewer",
|
||||
) => {
|
||||
axios
|
||||
.post("users", { username: user, password, role })
|
||||
.then((response) => {
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
setShowCreate(false);
|
||||
mutateUsers((users) => {
|
||||
users?.push({ username: user, role: role });
|
||||
return users;
|
||||
}, false);
|
||||
toast.success(`User ${user} created successfully`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to create user: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
setShowCreate(false);
|
||||
mutateUsers((users) => {
|
||||
users?.push({ username: user });
|
||||
return users;
|
||||
}, false);
|
||||
} catch (error) {
|
||||
toast.error("Error creating user. Check server logs.", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (user: string) => {
|
||||
try {
|
||||
await axios.delete(`users/${user}`);
|
||||
setShowDelete(false);
|
||||
mutateUsers((users) => {
|
||||
return users?.filter((u) => {
|
||||
return u.username !== user;
|
||||
const onDelete = (user: string) => {
|
||||
axios
|
||||
.delete(`users/${user}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setShowDelete(false);
|
||||
mutateUsers(
|
||||
(users) => users?.filter((u) => u.username !== user),
|
||||
false,
|
||||
);
|
||||
toast.success(`User ${user} deleted successfully`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete user: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeRole = (user: string, newRole: "admin" | "viewer") => {
|
||||
if (user === "admin") return; // Prevent role change for 'admin'
|
||||
|
||||
axios
|
||||
.put(`users/${user}/role`, { role: newRole })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setShowRoleChange(false);
|
||||
mutateUsers(
|
||||
(users) =>
|
||||
users?.map((u) =>
|
||||
u.username === user ? { ...u, role: newRole } : u,
|
||||
),
|
||||
false,
|
||||
);
|
||||
toast.success(`Role updated for ${user}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to update role: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}, false);
|
||||
} catch (error) {
|
||||
toast.error("Error deleting user. Check server logs.", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!config || !users) {
|
||||
return <ActivityIndicator />;
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<Heading as="h3" className="my-2">
|
||||
Users
|
||||
</Heading>
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h3" className="my-2">
|
||||
User Management
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage this Frigate instance's user accounts.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
className="flex items-center gap-2 self-start sm:self-auto"
|
||||
aria-label="Add a new user"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setShowCreate(true);
|
||||
}}
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<LuPlus className="text-secondary-foreground" />
|
||||
<LuPlus className="size-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{users.map((u) => (
|
||||
<Card key={u.username} className="mb-1 p-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="ml-3 flex flex-none shrink overflow-hidden text-ellipsis align-middle text-lg">
|
||||
{u.username}
|
||||
</div>
|
||||
<div className="flex flex-1 justify-end space-x-2">
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Update the user's password"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowSetPassword(true);
|
||||
setSelectedUser(u.username);
|
||||
}}
|
||||
>
|
||||
<FaUserEdit />
|
||||
<div className="hidden md:block">Update Password</div>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Delete the user"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowDelete(true);
|
||||
setSelectedUser(u.username);
|
||||
}}
|
||||
>
|
||||
<HiTrash />
|
||||
<div className="hidden md:block">Delete</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">Username</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
No users found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.username} className="group">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.username === "admin" ? (
|
||||
<LuShield className="size-4 text-primary" />
|
||||
) : (
|
||||
<LuUserCog className="size-4 text-primary-variant" />
|
||||
)}
|
||||
{user.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.role === "admin" ? "default" : "outline"
|
||||
}
|
||||
className={
|
||||
user.role === "admin"
|
||||
? "bg-primary/20 text-primary hover:bg-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{user.role || "viewer"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.username !== "admin" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username);
|
||||
setSelectedUserRole(
|
||||
(user.role as "admin" | "viewer") ||
|
||||
"viewer",
|
||||
);
|
||||
setShowRoleChange(true);
|
||||
}}
|
||||
>
|
||||
<LuUserCog className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Role
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Change user role</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setShowSetPassword(true);
|
||||
setSelectedUser(user.username);
|
||||
}}
|
||||
>
|
||||
<FaUserEdit className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Password
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Update password</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{user.username !== "admin" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setShowDelete(true);
|
||||
setSelectedUser(user.username);
|
||||
}}
|
||||
>
|
||||
<HiTrash className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Delete
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete user</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SetPasswordDialog
|
||||
show={showSetPassword}
|
||||
onCancel={() => {
|
||||
setShowSetPassword(false);
|
||||
}}
|
||||
onSave={(password) => {
|
||||
onSavePassword(selectedUser!, password);
|
||||
}}
|
||||
onCancel={() => setShowSetPassword(false)}
|
||||
onSave={(password) => onSavePassword(selectedUser!, password)}
|
||||
/>
|
||||
<DeleteUserDialog
|
||||
show={showDelete}
|
||||
onCancel={() => {
|
||||
setShowDelete(false);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDelete(selectedUser!);
|
||||
}}
|
||||
username={selectedUser ?? "this user"}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
onDelete={() => onDelete(selectedUser!)}
|
||||
/>
|
||||
<CreateUserDialog
|
||||
show={showCreate}
|
||||
onCreate={onCreate}
|
||||
onCancel={() => {
|
||||
setShowCreate(false);
|
||||
}}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
{selectedUser && selectedUserRole && (
|
||||
<RoleChangeDialog
|
||||
show={showRoleChange}
|
||||
username={selectedUser}
|
||||
currentRole={selectedUserRole}
|
||||
onSave={(role) => onChangeRole(selectedUser, role)}
|
||||
onCancel={() => setShowRoleChange(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,10 +171,13 @@ export default function CameraSettingsView({
|
||||
}
|
||||
})
|
||||
.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);
|
||||
|
||||
@@ -267,10 +267,13 @@ export default function NotificationView({
|
||||
}
|
||||
})
|
||||
.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);
|
||||
|
||||
@@ -103,10 +103,13 @@ export default function SearchSettingsView({
|
||||
}
|
||||
})
|
||||
.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);
|
||||
|
||||
@@ -38,10 +38,13 @@ export default function UiSettingsView() {
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to clear stored layout: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to clear stored layout: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [config]);
|
||||
@@ -58,10 +61,13 @@ export default function UiSettingsView() {
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to clear camera groups streaming settings: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to clear streaming settings: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user