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:
Josh Hawkins
2025-03-08 10:01:08 -06:00
committed by GitHub
parent 6f9d9cd5a8
commit 74ca009b0b
50 changed files with 1951 additions and 732 deletions
+276 -96
View File
@@ -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);
+14 -8
View File
@@ -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]);