Enhance user roles to limit camera access (#20024)

* update config for roles and add validator

* ensure admin and viewer are never overridden

* add class method to user to retrieve all allowed cameras

* enforce config roles in auth api endpoints

* add camera access api dependency functions

* protect review endpoints

* protect preview endpoints

* rename param name for better fastapi injection matching

* remove unneeded

* protect export endpoints

* protect event endpoints

* protect media endpoints

* update auth hook for allowed cameras

* update default app view

* ensure anonymous user always returns all cameras

* limit cameras in explore

* cameras is already a list

* limit cameras in review/history

* limit cameras in live view

* limit cameras in camera groups

* only show face library and classification in sidebar for admin

* remove check in delete reviews

since admin role is required, no need to check camera access. fixes failing test

* pass request with camera access for tests

* more async

* camera access tests

* fix proxy auth tests

* allowed cameras for review tests

* combine event tests and refactor for camera access

* fix post validation for roles

* don't limit roles in create user dialog

* fix triggers endpoints

no need to run require camera access dep since the required role is admin

* fix type

* create and edit role dialogs

* delete role dialog

* fix role change dialog

* update settings view for roles

* i18n changes

* minor spacing tweaks

* docs

* use badges and camera name label component

* clarify docs

* display all cameras badge for admin and viewer

* i18n fix

* use validator to prevent reserved and empty roles from being assigned

* split users and roles into separate tabs in settings

* tweak docs

* clarify docs

* change icon

* don't memoize roles

always recalculate on component render
This commit is contained in:
Josh Hawkins
2025-09-12 05:19:29 -06:00
committed by GitHub
parent ba650af6f2
commit ed1e3a7c9a
41 changed files with 2286 additions and 739 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
export default function ProtectedRoute({
requiredRoles,
}: {
requiredRoles: ("admin" | "viewer")[];
requiredRoles: string[];
}) {
const { auth } = useContext(AuthContext);
@@ -77,6 +77,8 @@ import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
type CameraGroupSelectorProps = {
className?: string;
@@ -650,6 +652,9 @@ export function CameraGroupEdit({
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
);
const allowedCameras = useAllowedCameras();
const isCustomRole = useIsCustomRole();
const [openCamera, setOpenCamera] = useState<string | null>();
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@@ -837,12 +842,17 @@ export function CameraGroupEdit({
<FormDescription>{t("group.cameras.desc")}</FormDescription>
<FormMessage />
{[
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}).sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
...(birdseyeConfig?.enabled &&
(!isCustomRole || "birdseye" in allowedCameras)
? ["birdseye"]
: []),
...Object.keys(config?.cameras ?? {})
.filter((camera) => allowedCameras.includes(camera))
.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
].map((camera) => (
<FormControl key={camera}>
<div className="flex items-center justify-between gap-1">
@@ -25,6 +25,7 @@ import { CamerasFilterButton } from "./CamerasFilterButton";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
const REVIEW_FILTERS = [
"cameras",
@@ -72,6 +73,7 @@ export default function ReviewFilterGroup({
setMotionOnly,
}: ReviewFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (filterList?.labels) {
@@ -83,7 +85,9 @@ export default function ReviewFilterGroup({
}
const labels = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -106,7 +110,7 @@ export default function ReviewFilterGroup({
});
return [...labels].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
@@ -118,7 +122,9 @@ export default function ReviewFilterGroup({
}
const zones = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -134,11 +140,11 @@ export default function ReviewFilterGroup({
});
return [...zones].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras ?? {}).sort(
cameras: allowedCameras.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
@@ -146,7 +152,7 @@ export default function ReviewFilterGroup({
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
}),
[config, allLabels, allZones],
[config, allLabels, allZones, allowedCameras],
);
const groups = useMemo(() => {
@@ -24,9 +24,9 @@ import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
type SearchFilterGroupProps = {
className: string;
@@ -46,6 +46,7 @@ export default function SearchFilterGroup({
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (filterList?.labels) {
@@ -57,7 +58,9 @@ export default function SearchFilterGroup({
}
const labels = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -87,7 +90,7 @@ export default function SearchFilterGroup({
});
return [...labels].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
@@ -99,7 +102,9 @@ export default function SearchFilterGroup({
}
const zones = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -118,16 +123,16 @@ export default function SearchFilterGroup({
});
return [...zones].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
cameras: allowedCameras,
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
search_type: ["thumbnail", "description"] as SearchSource[],
}),
[config, allLabels, allZones],
[allLabels, allZones, allowedCameras],
);
const availableSortTypes = useMemo(() => {
@@ -0,0 +1,228 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type CreateRoleOverlayProps = {
show: boolean;
config: FrigateConfig;
onCreate: (role: string, cameras: string[]) => void;
onCancel: () => void;
};
export default function CreateRoleDialog({
show,
config,
onCreate,
onCancel,
}: CreateRoleOverlayProps) {
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const existingRoles = Object.keys(config.auth?.roles || {});
const formSchema = z.object({
role: z
.string()
.min(1, t("roles.dialog.form.role.roleIsRequired"))
.regex(/^[A-Za-z0-9._]+$/, {
message: t("roles.dialog.form.role.roleOnlyInclude"),
})
.refine((role) => !existingRoles.includes(role), {
message: t("roles.dialog.form.role.roleExists"),
}),
cameras: z
.array(z.string())
.min(1, t("roles.dialog.form.cameras.required")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
role: "",
cameras: [],
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
await onCreate(values.role, values.cameras);
form.reset();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!show) {
form.reset({
role: "",
cameras: [],
});
}
}, [show, form]);
const handleCancel = () => {
form.reset({
role: "",
cameras: [],
});
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("roles.dialog.createRole.title")}</DialogTitle>
<DialogDescription>
{t("roles.dialog.createRole.desc")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
>
<FormField
name="role"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("roles.dialog.form.role.title")}
</FormLabel>
<FormControl>
<Input
placeholder={t("roles.dialog.form.role.placeholder")}
className="h-10"
{...field}
/>
</FormControl>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.role.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
key={camera}
control={form.control}
name="cameras"
render={({ field }) => {
return (
<FormItem
key={camera}
className="flex flex-row items-center justify-between space-x-3 space-y-0"
>
<div className="space-y-0.5">
<FormLabel className="font-normal">
<CameraNameLabel
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={camera.replaceAll("_", " ")}
camera={camera}
/>
</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value?.includes(camera)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value as string[]),
camera,
])
: field.onChange(
(field.value as string[])?.filter(
(value: string) => value !== camera,
) || [],
);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
))}
</div>
<FormMessage />
</div>
<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={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -190,7 +190,7 @@ export default function CreateTriggerDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
className="space-y-5 pt-4"
>
<FormField
control={form.control}
@@ -36,7 +36,7 @@ import { useTranslation } from "react-i18next";
type CreateUserOverlayProps = {
show: boolean;
onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
onCreate: (user: string, password: string, role: string) => void;
onCancel: () => void;
};
@@ -123,7 +123,7 @@ export default function CreateUserDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
className="space-y-5 pt-4"
>
<FormField
name="user"
@@ -0,0 +1,109 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useState } from "react";
type DeleteRoleDialogProps = {
show: boolean;
role: string;
onCancel: () => void;
onDelete: () => void;
};
export default function DeleteRoleDialog({
show,
role,
onCancel,
onDelete,
}: DeleteRoleDialogProps) {
const { t } = useTranslation("views/settings");
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleDelete = async () => {
setIsLoading(true);
try {
await onDelete();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("roles.dialog.deleteRole.title")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="roles.dialog.deleteRole.desc"
ns="views/settings"
values={{ role }}
components={{
strong: <span className="font-medium" />,
}}
/>
</DialogDescription>
</DialogHeader>
<div className="py-3">
<div className="text-sm text-muted-foreground">
<p>
<Trans
ns={"views/settings"}
values={{ role }}
components={{ strong: <span className="font-medium" /> }}
>
roles.dialog.deleteRole.warn
</Trans>
</p>
</div>
</div>
<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={t("button.cancel", { ns: "common" })}
variant="outline"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex flex-1"
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,195 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Trans, useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type EditRoleCamerasOverlayProps = {
show: boolean;
config: FrigateConfig;
role: string;
currentCameras: string[];
onSave: (cameras: string[]) => void;
onCancel: () => void;
};
export default function EditRoleCamerasDialog({
show,
config,
role,
currentCameras,
onSave,
onCancel,
}: EditRoleCamerasOverlayProps) {
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const formSchema = z.object({
cameras: z
.array(z.string())
.min(1, t("roles.dialog.form.cameras.required")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
cameras: currentCameras,
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
await onSave(values.cameras);
form.reset();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
form.reset({
cameras: currentCameras,
});
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{t("roles.dialog.editCameras.title", { role })}
</DialogTitle>
<DialogDescription>
<Trans
ns={"views/settings"}
values={{ role }}
components={{ strong: <span className="font-medium" /> }}
>
roles.dialog.editCameras.desc
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
key={camera}
control={form.control}
name="cameras"
render={({ field }) => {
return (
<FormItem
key={camera}
className="flex flex-row items-center justify-between space-x-3 space-y-0"
>
<div className="space-y-0.5">
<FormLabel className="font-normal">
<CameraNameLabel
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={camera.replaceAll("_", " ")}
camera={camera}
/>
</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value?.includes(camera)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value as string[]),
camera,
])
: field.onChange(
(field.value as string[])?.filter(
(value: string) => value !== camera,
) || [],
);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
))}
</div>
<FormMessage />
</div>
<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={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+43 -26
View File
@@ -1,5 +1,5 @@
import { Trans, useTranslation } from "react-i18next";
import { Button } from "../ui/button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -7,22 +7,23 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
} from "@/components/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;
currentRole: string;
availableRoles: string[];
onSave: (role: string) => void;
onCancel: () => void;
};
@@ -30,13 +31,12 @@ export default function RoleChangeDialog({
show,
username,
currentRole,
availableRoles,
onSave,
onCancel,
}: RoleChangeDialogProps) {
const { t } = useTranslation(["views/settings"]);
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
currentRole,
);
const [selectedRole, setSelectedRole] = useState<string>(currentRole);
return (
<Dialog open={show} onOpenChange={onCancel}>
@@ -73,31 +73,46 @@ export default function RoleChangeDialog({
</span>
: {t("users.dialog.changeRole.roleInfo.viewerDesc")}
</li>
{availableRoles
.filter((role) => role !== "admin" && role !== "viewer")
.map((role) => (
<li key={role}>
<span className="font-medium">{role}</span>:{" "}
{t("users.dialog.changeRole.roleInfo.customDesc")}
</li>
))}
</ul>
</div>
<Select
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value as "admin" | "viewer")
}
>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("users.dialog.changeRole.select")} />
</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>{t("role.admin", { ns: "common" })}</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>{t("role.viewer", { ns: "common" })}</span>
</div>
</SelectItem>
{availableRoles.map((role) => (
<SelectItem
key={role}
value={role}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{role === "admin" ? (
<LuShield className="size-4 text-primary" />
) : role === "viewer" ? (
<LuUser className="size-4 text-primary" />
) : (
<LuUser className="size-4 text-muted-foreground" />
)}
<span>
{role === "admin"
? t("role.admin", { ns: "common" })
: role === "viewer"
? t("role.viewer", { ns: "common" })
: role}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -108,6 +123,7 @@ export default function RoleChangeDialog({
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
type="button"
>
@@ -118,6 +134,7 @@ export default function RoleChangeDialog({
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}
@@ -114,7 +114,7 @@ export default function SetPasswordDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="password">
{t("users.dialog.form.newPassword.title")}