2025-09-12 14:19:29 +03:00
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
2024-05-18 19:36:13 +03:00
|
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
|
|
|
import useSWR from "swr";
|
2024-05-29 17:01:39 +03:00
|
|
|
import Heading from "@/components/ui/heading";
|
2024-05-18 19:36:13 +03:00
|
|
|
import { User } from "@/types/user";
|
2024-05-29 17:01:39 +03:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import SetPasswordDialog from "@/components/overlay/SetPasswordDialog";
|
2024-05-18 19:36:13 +03:00
|
|
|
import axios from "axios";
|
2024-05-29 17:01:39 +03:00
|
|
|
import CreateUserDialog from "@/components/overlay/CreateUserDialog";
|
2024-05-18 19:36:13 +03:00
|
|
|
import { toast } from "sonner";
|
2024-05-29 17:01:39 +03:00
|
|
|
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
|
2024-05-18 22:05:28 +03:00
|
|
|
import { HiTrash } from "react-icons/hi";
|
|
|
|
|
import { FaUserEdit } from "react-icons/fa";
|
2025-03-16 18:36:20 +03:00
|
|
|
|
2025-09-12 14:19:29 +03:00
|
|
|
import { LuPencil, LuPlus, LuShield, LuUserCog } from "react-icons/lu";
|
2025-03-08 19:01:08 +03:00
|
|
|
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";
|
2025-09-12 14:19:29 +03:00
|
|
|
import CreateRoleDialog from "@/components/overlay/CreateRoleDialog";
|
|
|
|
|
import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
|
2025-03-16 18:36:20 +03:00
|
|
|
import { useTranslation } from "react-i18next";
|
2025-09-12 14:19:29 +03:00
|
|
|
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2025-11-07 17:02:06 +03:00
|
|
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
2024-05-18 19:36:13 +03:00
|
|
|
|
2025-09-12 14:19:29 +03:00
|
|
|
type AuthenticationViewProps = {
|
|
|
|
|
section?: "users" | "roles";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function AuthenticationView({
|
|
|
|
|
section,
|
|
|
|
|
}: AuthenticationViewProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation("views/settings");
|
2025-09-12 14:19:29 +03:00
|
|
|
const { data: config, mutate: updateConfig } =
|
|
|
|
|
useSWR<FrigateConfig>("config");
|
2024-05-18 19:36:13 +03:00
|
|
|
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
|
|
|
|
|
|
|
|
|
|
const [showSetPassword, setShowSetPassword] = useState(false);
|
|
|
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
|
const [showDelete, setShowDelete] = useState(false);
|
2025-03-08 19:01:08 +03:00
|
|
|
const [showRoleChange, setShowRoleChange] = useState(false);
|
2025-09-12 14:19:29 +03:00
|
|
|
const [showCreateRole, setShowCreateRole] = useState(false);
|
|
|
|
|
const [showEditRole, setShowEditRole] = useState(false);
|
|
|
|
|
const [showDeleteRole, setShowDeleteRole] = useState(false);
|
2024-05-18 19:36:13 +03:00
|
|
|
|
|
|
|
|
const [selectedUser, setSelectedUser] = useState<string>();
|
2025-09-12 14:19:29 +03:00
|
|
|
const [selectedUserRole, setSelectedUserRole] = useState<string>();
|
|
|
|
|
|
|
|
|
|
const [selectedRole, setSelectedRole] = useState<string>();
|
|
|
|
|
const [currentRoleCameras, setCurrentRoleCameras] = useState<string[]>([]);
|
|
|
|
|
const [selectedRoleForDelete, setSelectedRoleForDelete] = useState<string>();
|
2024-05-18 19:36:13 +03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-03-16 18:36:20 +03:00
|
|
|
document.title = t("documentTitle.authentication");
|
|
|
|
|
}, [t]);
|
2024-05-18 19:36:13 +03:00
|
|
|
|
2025-03-16 18:36:20 +03:00
|
|
|
const onSavePassword = useCallback(
|
|
|
|
|
(user: string, password: string) => {
|
|
|
|
|
axios
|
|
|
|
|
.put(`users/${user}/password`, { password })
|
|
|
|
|
.then((response) => {
|
|
|
|
|
if (response.status === 200) {
|
|
|
|
|
setShowSetPassword(false);
|
|
|
|
|
toast.success(t("users.toast.success.updatePassword"), {
|
|
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
|
|
|
|
toast.error(
|
|
|
|
|
t("users.toast.error.setPasswordFailed", {
|
|
|
|
|
errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
2024-05-18 19:36:13 +03:00
|
|
|
});
|
2025-03-16 18:36:20 +03:00
|
|
|
},
|
|
|
|
|
[t],
|
|
|
|
|
);
|
2024-05-18 19:36:13 +03:00
|
|
|
|
2025-09-12 14:19:29 +03:00
|
|
|
const onCreate = (user: string, password: string, role: string) => {
|
2025-03-08 19:01:08 +03:00
|
|
|
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);
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.success(t("users.toast.success.createUser", { user }), {
|
2025-03-08 19:01:08 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(
|
|
|
|
|
t("users.toast.error.createUserFailed", {
|
|
|
|
|
errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
2024-05-18 19:36:13 +03:00
|
|
|
});
|
2025-03-08 19:01:08 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.success(t("users.toast.success.deleteUser", { user }), {
|
2025-03-08 19:01:08 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(
|
|
|
|
|
t("users.toast.error.deleteUserFailed", {
|
|
|
|
|
errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
2024-05-18 19:36:13 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-12 14:19:29 +03:00
|
|
|
const onChangeRole = (user: string, newRole: string) => {
|
|
|
|
|
if (user === "admin") return;
|
2025-03-08 19:01:08 +03:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.success(t("users.toast.success.roleUpdated", { user }), {
|
2025-03-08 19:01:08 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(
|
|
|
|
|
t("users.toast.error.roleUpdateFailed", {
|
|
|
|
|
errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
2024-05-18 19:36:13 +03:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-12 14:19:29 +03:00
|
|
|
type ConfigSetBody = {
|
|
|
|
|
requires_restart: number;
|
|
|
|
|
config_data: {
|
|
|
|
|
auth: {
|
|
|
|
|
roles: {
|
|
|
|
|
[key: string]: string[] | string;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
update_topic?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onCreateRole = useCallback(
|
|
|
|
|
async (role: string, cameras: string[]) => {
|
|
|
|
|
const configBody: ConfigSetBody = {
|
|
|
|
|
requires_restart: 0,
|
|
|
|
|
config_data: {
|
|
|
|
|
auth: {
|
|
|
|
|
roles: {
|
|
|
|
|
[role]: cameras,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
update_topic: "config/auth",
|
|
|
|
|
};
|
|
|
|
|
return axios
|
|
|
|
|
.put("config/set", configBody)
|
|
|
|
|
.then((response) => {
|
|
|
|
|
if (response.status === 200) {
|
|
|
|
|
setShowCreateRole(false);
|
|
|
|
|
updateConfig();
|
|
|
|
|
toast.success(t("roles.toast.success.createRole", { role }), {
|
|
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
|
|
|
|
toast.error(
|
|
|
|
|
t("roles.toast.error.createRoleFailed", {
|
|
|
|
|
errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[t, updateConfig],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onEditRoleCameras = useCallback(
|
|
|
|
|
async (cameras: string[]) => {
|
|
|
|
|
if (!selectedRole) return;
|
|
|
|
|
const configBody: ConfigSetBody = {
|
|
|
|
|
requires_restart: 0,
|
|
|
|
|
config_data: {
|
|
|
|
|
auth: {
|
|
|
|
|
roles: {
|
|
|
|
|
[selectedRole]: cameras,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
update_topic: "config/auth",
|
|
|
|
|
};
|
|
|
|
|
return axios
|
|
|
|
|
.put("config/set", configBody)
|
|
|
|
|
.then((response) => {
|
|
|
|
|
if (response.status === 200) {
|
|
|
|
|
setShowEditRole(false);
|
|
|
|
|
setSelectedRole(undefined);
|
|
|
|
|
setCurrentRoleCameras([]);
|
|
|
|
|
updateConfig();
|
|
|
|
|
toast.success(
|
|
|
|
|
t("roles.toast.success.updateCameras", { role: selectedRole }),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
|
|
|
|
toast.error(
|
|
|
|
|
t("roles.toast.error.updateCamerasFailed", {
|
|
|
|
|
errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[t, selectedRole, updateConfig],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onDeleteRole = useCallback(
|
|
|
|
|
async (role: string) => {
|
|
|
|
|
// Update users assigned to this role to 'viewer'
|
|
|
|
|
const usersToUpdate = users?.filter((user) => user.role === role) || [];
|
|
|
|
|
if (usersToUpdate.length > 0) {
|
|
|
|
|
Promise.all(
|
|
|
|
|
usersToUpdate.map((user) =>
|
|
|
|
|
axios.put(`users/${user.username}/role`, { role: "viewer" }),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.then(() => {
|
|
|
|
|
mutateUsers(
|
|
|
|
|
(users) =>
|
|
|
|
|
users?.map((u) =>
|
|
|
|
|
u.role === role ? { ...u, role: "viewer" } : u,
|
|
|
|
|
),
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
toast.success(
|
|
|
|
|
t("roles.toast.success.userRolesUpdated", {
|
|
|
|
|
count: usersToUpdate.length,
|
|
|
|
|
}),
|
|
|
|
|
{ position: "top-center" },
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
|
|
|
|
toast.error(
|
|
|
|
|
t("roles.toast.error.userUpdateFailed", { errorMessage }),
|
|
|
|
|
{ position: "top-center" },
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now delete the role from config
|
|
|
|
|
const configBody: ConfigSetBody = {
|
|
|
|
|
requires_restart: 0,
|
|
|
|
|
config_data: {
|
|
|
|
|
auth: {
|
|
|
|
|
roles: {
|
|
|
|
|
[role]: "",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
update_topic: "config/auth",
|
|
|
|
|
};
|
|
|
|
|
return axios
|
|
|
|
|
.put("config/set", configBody)
|
|
|
|
|
.then((response) => {
|
|
|
|
|
if (response.status === 200) {
|
|
|
|
|
setShowDeleteRole(false);
|
|
|
|
|
setSelectedRoleForDelete("");
|
|
|
|
|
updateConfig();
|
|
|
|
|
toast.success(t("roles.toast.success.deleteRole", { role }), {
|
|
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
|
|
|
|
toast.error(
|
|
|
|
|
t("roles.toast.error.deleteRoleFailed", {
|
|
|
|
|
errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[t, updateConfig, users, mutateUsers],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const roles = config?.auth?.roles
|
|
|
|
|
? Object.entries(config.auth.roles)
|
|
|
|
|
.filter(([name]) => name !== "admin")
|
|
|
|
|
.map(([name, data]) => ({
|
|
|
|
|
name,
|
|
|
|
|
cameras: Array.isArray(data) ? data : [],
|
|
|
|
|
}))
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const availableRoles = useMemo(() => {
|
|
|
|
|
return config ? [...Object.keys(config.auth?.roles || {})] : [];
|
|
|
|
|
}, [config]);
|
|
|
|
|
|
2024-05-18 19:36:13 +03:00
|
|
|
if (!config || !users) {
|
2025-03-08 19:01:08 +03:00
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
|
|
|
<ActivityIndicator />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2024-05-18 19:36:13 +03:00
|
|
|
}
|
|
|
|
|
|
2025-09-12 14:19:29 +03:00
|
|
|
// Users section
|
|
|
|
|
const UsersSection = (
|
|
|
|
|
<>
|
2025-10-09 00:02:38 +03:00
|
|
|
<div className="mb-5 flex flex-row items-center justify-between gap-2 md:mr-3">
|
2025-09-12 14:19:29 +03:00
|
|
|
<div className="flex flex-col items-start">
|
2025-10-08 22:59:21 +03:00
|
|
|
<Heading as="h4" className="mb-2">
|
2025-09-12 14:19:29 +03:00
|
|
|
{t("users.management.title")}
|
|
|
|
|
</Heading>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{t("users.management.desc")}
|
|
|
|
|
</p>
|
2024-05-18 19:36:13 +03:00
|
|
|
</div>
|
2025-09-12 14:19:29 +03:00
|
|
|
<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 className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
2025-10-09 00:02:38 +03:00
|
|
|
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3">
|
2025-09-12 14:19:29 +03:00
|
|
|
<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 ? (
|
2025-03-08 19:01:08 +03:00
|
|
|
<TableRow>
|
2025-09-12 14:19:29 +03:00
|
|
|
<TableCell colSpan={3} className="h-24 text-center">
|
|
|
|
|
{t("users.table.noUsers")}
|
|
|
|
|
</TableCell>
|
2025-03-08 19:01:08 +03:00
|
|
|
</TableRow>
|
2025-09-12 14:19:29 +03:00
|
|
|
) : (
|
|
|
|
|
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>
|
2025-03-08 19:01:08 +03:00
|
|
|
</TableCell>
|
2025-09-12 14:19:29 +03:00
|
|
|
<TableCell>
|
|
|
|
|
<Badge
|
|
|
|
|
variant={
|
|
|
|
|
user.role === "admin" ? "default" : "outline"
|
|
|
|
|
}
|
|
|
|
|
className={
|
|
|
|
|
user.role === "admin"
|
|
|
|
|
? "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" && (
|
2025-03-08 19:01:08 +03:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="h-8 px-2"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedUser(user.username);
|
|
|
|
|
setSelectedUserRole(
|
2025-09-12 14:19:29 +03:00
|
|
|
user.role || "viewer",
|
2025-03-08 19:01:08 +03:00
|
|
|
);
|
|
|
|
|
setShowRoleChange(true);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<LuUserCog className="size-3.5" />
|
|
|
|
|
<span className="ml-1.5 hidden sm:inline-block">
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("role.title", { ns: "common" })}
|
2025-03-08 19:01:08 +03:00
|
|
|
</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
2025-03-16 18:36:20 +03:00
|
|
|
<p>{t("users.table.changeRole")}</p>
|
2025-03-08 19:01:08 +03:00
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-12 14:19:29 +03:00
|
|
|
<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" && (
|
2025-03-08 19:01:08 +03:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
2025-09-12 14:19:29 +03:00
|
|
|
variant="destructive"
|
2025-03-08 19:01:08 +03:00
|
|
|
className="h-8 px-2"
|
|
|
|
|
onClick={() => {
|
2025-09-12 14:19:29 +03:00
|
|
|
setShowDelete(true);
|
2025-03-08 19:01:08 +03:00
|
|
|
setSelectedUser(user.username);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-09-12 14:19:29 +03:00
|
|
|
<HiTrash className="size-3.5" />
|
2025-03-08 19:01:08 +03:00
|
|
|
<span className="ml-1.5 hidden sm:inline-block">
|
2025-09-12 14:19:29 +03:00
|
|
|
{t("button.delete", { ns: "common" })}
|
2025-03-08 19:01:08 +03:00
|
|
|
</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
2025-09-12 14:19:29 +03:00
|
|
|
<p>{t("users.table.deleteUser")}</p>
|
2025-03-08 19:01:08 +03:00
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
2025-09-12 14:19:29 +03:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
2025-03-08 19:01:08 +03:00
|
|
|
</div>
|
2024-05-18 19:36:13 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<SetPasswordDialog
|
|
|
|
|
show={showSetPassword}
|
2025-03-08 19:01:08 +03:00
|
|
|
onCancel={() => setShowSetPassword(false)}
|
|
|
|
|
onSave={(password) => onSavePassword(selectedUser!, password)}
|
2024-05-18 19:36:13 +03:00
|
|
|
/>
|
|
|
|
|
<DeleteUserDialog
|
|
|
|
|
show={showDelete}
|
2025-03-08 19:01:08 +03:00
|
|
|
username={selectedUser ?? "this user"}
|
|
|
|
|
onCancel={() => setShowDelete(false)}
|
|
|
|
|
onDelete={() => onDelete(selectedUser!)}
|
2024-05-18 19:36:13 +03:00
|
|
|
/>
|
|
|
|
|
<CreateUserDialog
|
|
|
|
|
show={showCreate}
|
|
|
|
|
onCreate={onCreate}
|
2025-03-08 19:01:08 +03:00
|
|
|
onCancel={() => setShowCreate(false)}
|
2024-05-18 19:36:13 +03:00
|
|
|
/>
|
2025-03-08 19:01:08 +03:00
|
|
|
{selectedUser && selectedUserRole && (
|
|
|
|
|
<RoleChangeDialog
|
|
|
|
|
show={showRoleChange}
|
|
|
|
|
username={selectedUser}
|
|
|
|
|
currentRole={selectedUserRole}
|
2025-09-12 14:19:29 +03:00
|
|
|
availableRoles={availableRoles}
|
|
|
|
|
onSave={(role) => onChangeRole(selectedUser!, role)}
|
2025-03-08 19:01:08 +03:00
|
|
|
onCancel={() => setShowRoleChange(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-09-12 14:19:29 +03:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Roles section
|
|
|
|
|
const RolesSection = (
|
|
|
|
|
<>
|
2025-10-09 00:02:38 +03:00
|
|
|
<div className="mb-5 flex flex-row items-center justify-between gap-2 md:mr-3">
|
2025-09-12 14:19:29 +03:00
|
|
|
<div className="flex flex-col items-start">
|
2025-10-08 22:59:21 +03:00
|
|
|
<Heading as="h4" className="mb-2">
|
2025-09-12 14:19:29 +03:00
|
|
|
{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">
|
2025-10-09 00:02:38 +03:00
|
|
|
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3">
|
2025-09-12 14:19:29 +03:00
|
|
|
<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"}
|
|
|
|
|
>
|
|
|
|
|
<LuPencil 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
|
|
|
|
|
show={showCreateRole}
|
|
|
|
|
config={config}
|
|
|
|
|
onCreate={onCreateRole}
|
|
|
|
|
onCancel={() => setShowCreateRole(false)}
|
|
|
|
|
/>
|
|
|
|
|
{selectedRole && (
|
|
|
|
|
<EditRoleCamerasDialog
|
|
|
|
|
show={showEditRole}
|
|
|
|
|
config={config}
|
|
|
|
|
role={selectedRole}
|
|
|
|
|
currentCameras={currentRoleCameras}
|
|
|
|
|
onSave={onEditRoleCameras}
|
|
|
|
|
onCancel={() => {
|
|
|
|
|
setShowEditRole(false);
|
|
|
|
|
setSelectedRole(undefined);
|
|
|
|
|
setCurrentRoleCameras([]);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<DeleteRoleDialog
|
|
|
|
|
show={showDeleteRole}
|
|
|
|
|
role={selectedRoleForDelete || ""}
|
|
|
|
|
onCancel={() => {
|
|
|
|
|
setShowDeleteRole(false);
|
|
|
|
|
setSelectedRoleForDelete("");
|
|
|
|
|
}}
|
|
|
|
|
onDelete={async () => {
|
|
|
|
|
if (selectedRoleForDelete) {
|
|
|
|
|
try {
|
|
|
|
|
await onDeleteRole(selectedRoleForDelete);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Error handling is already done in onDeleteRole
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex size-full flex-col">
|
|
|
|
|
<Toaster position="top-center" closeButton={true} />
|
2025-10-09 00:02:38 +03:00
|
|
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
2025-09-12 14:19:29 +03:00
|
|
|
{section === "users" && UsersSection}
|
|
|
|
|
{section === "roles" && RolesSection}
|
|
|
|
|
{!section && (
|
|
|
|
|
<>
|
|
|
|
|
{UsersSection}
|
|
|
|
|
<Separator className="my-6 flex bg-secondary" />
|
|
|
|
|
{RolesSection}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2024-05-18 19:36:13 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|