split users and roles into separate tabs in settings

This commit is contained in:
Josh Hawkins 2025-09-11 12:17:30 -05:00
parent e888a08fd1
commit 56dc5773e1
5 changed files with 335 additions and 297 deletions

View File

@ -564,15 +564,15 @@
}, },
"roles": { "roles": {
"management": { "management": {
"title": "Role Management", "title": "Viewer Role Management",
"desc": "Manage roles and their camera access permissions for this Frigate instance." "desc": "Manage custom viewer roles and their camera access permissions for this Frigate instance."
}, },
"addRole": "Add Role", "addRole": "Add Role",
"table": { "table": {
"role": "Role", "role": "Role",
"cameras": "Cameras", "cameras": "Cameras",
"actions": "Actions", "actions": "Actions",
"noRoles": "No roles found.", "noRoles": "No custom roles found.",
"editCameras": "Edit Cameras", "editCameras": "Edit Cameras",
"deleteRole": "Delete Role" "deleteRole": "Delete Role"
}, },
@ -581,7 +581,7 @@
"createRole": "Role {{role}} created successfully", "createRole": "Role {{role}} created successfully",
"updateCameras": "Cameras updated for role {{role}}", "updateCameras": "Cameras updated for role {{role}}",
"deleteRole": "Role {{role}} deleted successfully", "deleteRole": "Role {{role}} deleted successfully",
"userRolesUpdated": "{{count}} user(s) assigned to this role have been updated to 'viewer'." "userRolesUpdated": "{{count}} user(s) assigned to this role have been updated to 'viewer', which has access to all cameras."
}, },
"error": { "error": {
"createRoleFailed": "Failed to create role: {{errorMessage}}", "createRoleFailed": "Failed to create role: {{errorMessage}}",
@ -601,7 +601,7 @@
}, },
"deleteRole": { "deleteRole": {
"title": "Delete Role", "title": "Delete Role",
"desc": "This action cannot be undone. This will permanently delete the role and assign any users with this role to the 'viewer' role.", "desc": "This action cannot be undone. This will permanently delete the role and assign any users with this role to the 'viewer' role, which will give viewer access to all cameras.",
"warn": "Are you sure you want to delete <strong>{{role}}</strong>?", "warn": "Are you sure you want to delete <strong>{{role}}</strong>?",
"deleting": "Deleting..." "deleting": "Deleting..."
}, },

View File

@ -33,7 +33,8 @@ import CameraSettingsView from "@/views/settings/CameraSettingsView";
import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
import MotionTunerView from "@/views/settings/MotionTunerView"; import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView"; import UsersView from "@/views/settings/UsersView";
import RolesView from "@/views/settings/RolesView";
import NotificationView from "@/views/settings/NotificationsSettingsView"; import NotificationView from "@/views/settings/NotificationsSettingsView";
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView"; import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView";
@ -57,6 +58,7 @@ const allSettingsViews = [
"triggers", "triggers",
"debug", "debug",
"users", "users",
"roles",
"notifications", "notifications",
"frigateplus", "frigateplus",
] as const; ] as const;
@ -288,7 +290,8 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges} setUnsavedChanges={setUnsavedChanges}
/> />
)} )}
{page == "users" && <AuthenticationView />} {page == "users" && <UsersView />}
{page == "roles" && <RolesView />}
{page == "notifications" && ( {page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} /> <NotificationView setUnsavedChanges={setUnsavedChanges} />
)} )}

View File

@ -38,7 +38,13 @@ import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
export default function AuthenticationView() { type AuthenticationViewProps = {
section?: "users" | "roles";
};
export default function AuthenticationView({
section,
}: AuthenticationViewProps) {
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
@ -377,10 +383,12 @@ export default function AuthenticationView() {
const roles = useMemo(() => { const roles = useMemo(() => {
return config?.auth?.roles return config?.auth?.roles
? Object.entries(config.auth.roles).map(([name, data]) => ({ ? Object.entries(config.auth.roles)
name, .filter(([name]) => name !== "admin")
cameras: Array.isArray(data) ? data : [], .map(([name, data]) => ({
})) name,
cameras: Array.isArray(data) ? data : [],
}))
: []; : [];
}, [config]); }, [config]);
@ -396,319 +404,166 @@ export default function AuthenticationView() {
); );
} }
return ( // Users section
<div className="flex size-full flex-col"> const UsersSection = (
<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="mb-5 flex flex-row items-center justify-between gap-2">
<div className="mb-5 flex flex-row items-center justify-between gap-2"> <div className="flex flex-col items-start">
<div className="flex flex-col items-start"> <Heading as="h3" className="my-2">
<Heading as="h3" className="my-2"> {t("users.management.title")}
{t("users.management.title")} </Heading>
</Heading> <p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground"> {t("users.management.desc")}
{t("users.management.desc")} </p>
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label={t("users.addUser")}
variant="default"
onClick={() => setShowCreate(true)}
>
<LuPlus className="size-4" />
{t("users.addUser")}
</Button>
</div> </div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <Button
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt"> className="flex items-center gap-2 self-start sm:self-auto"
<div className="h-full overflow-auto"> aria-label={t("users.addUser")}
<Table> variant="default"
<TableHeader className="sticky top-0 bg-muted/50"> onClick={() => setShowCreate(true)}
>
<LuPlus className="size-4" />
{t("users.addUser")}
</Button>
</div>
<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]">
{t("users.table.username")}
</TableHead>
<TableHead>{t("users.table.role")}</TableHead>
<TableHead className="text-right">
{t("users.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow> <TableRow>
<TableHead className="w-[250px]"> <TableCell colSpan={3} className="h-24 text-center">
{t("users.table.username")} {t("users.table.noUsers")}
</TableHead> </TableCell>
<TableHead>{t("users.table.role")}</TableHead>
<TableHead className="text-right">
{t("users.table.actions")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> ) : (
<TableBody> users.map((user) => (
{users.length === 0 ? ( <TableRow key={user.username} className="group">
<TableRow> <TableCell className="font-medium">
<TableCell colSpan={3} className="h-24 text-center"> <div className="flex items-center gap-2">
{t("users.table.noUsers")} {user.username === "admin" ? (
<LuShield className="size-4 text-primary" />
) : (
<LuUserCog className="size-4 text-primary-variant" />
)}
{user.username}
</div>
</TableCell> </TableCell>
</TableRow> <TableCell>
) : ( <Badge
users.map((user) => ( variant={
<TableRow key={user.username} className="group"> user.role === "admin" ? "default" : "outline"
<TableCell className="font-medium"> }
<div className="flex items-center gap-2"> className={
{user.username === "admin" ? ( user.role === "admin"
<LuShield className="size-4 text-primary" /> ? "bg-primary/20 text-primary hover:bg-primary/30"
) : ( : ""
<LuUserCog className="size-4 text-primary-variant" /> }
)} >
{user.username} {t("role." + (user.role || "viewer"), {
</div> ns: "common",
</TableCell> })}
<TableCell> </Badge>
<Badge </TableCell>
variant={ <TableCell className="text-right">
user.role === "admin" ? "default" : "outline" <TooltipProvider>
} <div className="flex items-center justify-end gap-2">
className={ {user.username !== "admin" &&
user.role === "admin" user.username !== "viewer" && (
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{t("role." + (user.role || "viewer"), {
ns: "common",
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{user.username !== "admin" &&
user.username !== "viewer" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedUser(user.username);
setSelectedUserRole(
user.role || "viewer",
);
setShowRoleChange(true);
}}
>
<LuUserCog className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("role.title", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.table.changeRole")}</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">
{t("users.table.password")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.updatePassword")}</p>
</TooltipContent>
</Tooltip>
{user.username !== "admin" && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
size="sm" size="sm"
variant="destructive" variant="outline"
className="h-8 px-2" className="h-8 px-2"
onClick={() => { onClick={() => {
setShowDelete(true);
setSelectedUser(user.username); setSelectedUser(user.username);
setSelectedUserRole(
user.role || "viewer",
);
setShowRoleChange(true);
}} }}
> >
<HiTrash className="size-3.5" /> <LuUserCog className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block"> <span className="ml-1.5 hidden sm:inline-block">
{t("button.delete", { ns: "common" })} {t("role.title", { ns: "common" })}
</span> </span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t("users.table.deleteUser")}</p> <p>{t("users.table.changeRole")}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<Separator className="my-6 flex bg-secondary" /> <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">
{t("users.table.password")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.updatePassword")}</p>
</TooltipContent>
</Tooltip>
<div className="mb-5 flex flex-row items-center justify-between gap-2"> {user.username !== "admin" && (
<div className="flex flex-col items-start"> <Tooltip>
<Heading as="h3" className="my-2"> <TooltipTrigger asChild>
{t("roles.management.title")} <Button
</Heading> size="sm"
<p className="text-sm text-muted-foreground"> variant="destructive"
{t("roles.management.desc")} className="h-8 px-2"
</p> onClick={() => {
</div> setShowDelete(true);
<Button setSelectedUser(user.username);
className="flex items-center gap-2 self-start sm:self-auto" }}
aria-label={t("roles.addRole")} >
variant="default" <HiTrash className="size-3.5" />
onClick={() => setShowCreateRole(true)} <span className="ml-1.5 hidden sm:inline-block">
> {t("button.delete", { ns: "common" })}
<LuPlus className="size-4" /> </span>
{t("roles.addRole")} </Button>
</Button> </TooltipTrigger>
</div> <TooltipContent>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <p>{t("users.table.deleteUser")}</p>
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt"> </TooltipContent>
<div className="h-full overflow-auto"> </Tooltip>
<Table> )}
<TableHeader className="sticky top-0 bg-muted/50"> </div>
<TableRow> </TooltipProvider>
<TableHead className="w-[250px]">
{t("roles.table.role")}
</TableHead>
<TableHead>{t("roles.table.cameras")}</TableHead>
<TableHead className="text-right">
{t("roles.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
{t("roles.table.noRoles")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ))
roles.map((roleData) => ( )}
<TableRow key={roleData.name} className="group"> </TableBody>
<TableCell className="font-medium"> </Table>
{roleData.name}
</TableCell>
<TableCell>
{roleData.cameras.length === 0 ? (
<Badge
variant="default"
className="bg-primary/20 text-xs text-primary hover:bg-primary/30"
>
{t("menu.live.allCameras", { ns: "common" })}
</Badge>
) : roleData.cameras.length > 5 ? (
<Badge variant="outline" className="text-xs">
{roleData.cameras.length} cameras
</Badge>
) : (
<div className="flex flex-wrap gap-1">
{roleData.cameras.map((camera) => (
<Badge
key={camera}
variant="outline"
className="text-xs"
>
<CameraNameLabel
camera={camera}
className="text-xs smart-capitalize"
/>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{roleData.name !== "admin" &&
roleData.name !== "viewer" && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedRole(roleData.name);
setCurrentRoleCameras(
roleData.cameras,
);
setShowEditRole(true);
}}
disabled={roleData.name === "admin"}
>
<FaUserEdit className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("roles.table.editCameras")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.editCameras")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setSelectedRoleForDelete(
roleData.name,
);
setShowDeleteRole(true);
}}
disabled={roleData.name === "admin"}
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("button.delete", {
ns: "common",
})}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.deleteRole")}</p>
</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div> </div>
</div> </div>
</div> </div>
<SetPasswordDialog <SetPasswordDialog
show={showSetPassword} show={showSetPassword}
onCancel={() => setShowSetPassword(false)} onCancel={() => setShowSetPassword(false)}
@ -735,6 +590,159 @@ export default function AuthenticationView() {
onCancel={() => setShowRoleChange(false)} onCancel={() => setShowRoleChange(false)}
/> />
)} )}
</>
);
// Roles section
const RolesSection = (
<>
<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">
{t("roles.management.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("roles.management.desc")}
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label={t("roles.addRole")}
variant="default"
onClick={() => setShowCreateRole(true)}
>
<LuPlus className="size-4" />
{t("roles.addRole")}
</Button>
</div>
<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]">
{t("roles.table.role")}
</TableHead>
<TableHead>{t("roles.table.cameras")}</TableHead>
<TableHead className="text-right">
{t("roles.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
{t("roles.table.noRoles")}
</TableCell>
</TableRow>
) : (
roles.map((roleData) => (
<TableRow key={roleData.name} className="group">
<TableCell className="font-medium">
{roleData.name}
</TableCell>
<TableCell>
{roleData.cameras.length === 0 ? (
<Badge
variant="default"
className="bg-primary/20 text-xs text-primary hover:bg-primary/30"
>
{t("menu.live.allCameras", { ns: "common" })}
</Badge>
) : roleData.cameras.length > 5 ? (
<Badge variant="outline" className="text-xs">
{roleData.cameras.length} cameras
</Badge>
) : (
<div className="flex flex-wrap gap-1">
{roleData.cameras.map((camera) => (
<Badge
key={camera}
variant="outline"
className="text-xs"
>
<CameraNameLabel
camera={camera}
className="text-xs smart-capitalize"
/>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{roleData.name !== "admin" &&
roleData.name !== "viewer" && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedRole(roleData.name);
setCurrentRoleCameras(
roleData.cameras,
);
setShowEditRole(true);
}}
disabled={roleData.name === "admin"}
>
<FaUserEdit className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("roles.table.editCameras")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.editCameras")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setSelectedRoleForDelete(
roleData.name,
);
setShowDeleteRole(true);
}}
disabled={roleData.name === "admin"}
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("button.delete", {
ns: "common",
})}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.deleteRole")}</p>
</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<CreateRoleDialog <CreateRoleDialog
show={showCreateRole} show={showCreateRole}
config={config} config={config}
@ -772,6 +780,23 @@ export default function AuthenticationView() {
} }
}} }}
/> />
</>
);
return (
<div className="flex size-full flex-col">
<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">
{section === "users" && UsersSection}
{section === "roles" && RolesSection}
{!section && (
<>
{UsersSection}
<Separator className="my-6 flex bg-secondary" />
{RolesSection}
</>
)}
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function RolesView() {
return <AuthenticationView section="roles" />;
}

View File

@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function UsersView() {
return <AuthenticationView section="users" />;
}