From 4ed2ac9e2e1df6bc298d60267fa4fd1646e30298 Mon Sep 17 00:00:00 2001 From: NlightN22 Date: Wed, 28 Feb 2024 01:48:07 +0700 Subject: [PATCH] add admin role control --- package.json | 1 + src/App.tsx | 77 +++++++++++-------- src/AppBody.tsx | 55 ++++++------- src/hooks/useAdminRole.ts | 20 +++++ src/hooks/useRealmAccessRoles.ts | 30 ++++++++ src/pages/AccessSettings.tsx | 7 +- src/pages/FrigateHostsPage.tsx | 11 ++- src/pages/HostConfigPage.tsx | 14 +++- src/pages/HostStoragePage.tsx | 16 +++- src/pages/HostSystemPage.tsx | 15 +++- src/pages/SettingsPage.tsx | 19 ++++- src/services/frigate.proxy/frigate.api.ts | 31 ++++---- src/shared/components/players/VideoPlayer.tsx | 15 +++- src/widgets/header/HeaderAction.tsx | 13 ++-- src/widgets/header/header.links.ts | 6 +- yarn.lock | 5 ++ 16 files changed, 233 insertions(+), 102 deletions(-) create mode 100644 src/hooks/useAdminRole.ts create mode 100644 src/hooks/useRealmAccessRoles.ts diff --git a/package.json b/package.json index 1a074ad..03fe94e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dayjs": "^1.11.9", "embla-carousel-react": "^8.0.0-rc10", "idb-keyval": "^6.2.1", + "jwt-decode": "^4.0.0", "mantine-react-table": "^1.0.0-beta.25", "mobx": "^6.9.0", "mobx-react-lite": "^3.4.3", diff --git a/src/App.tsx b/src/App.tsx index cbb0162..d8b8013 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,10 +7,18 @@ import { getCookie, setCookie } from 'cookies-next'; import { BrowserRouter } from 'react-router-dom'; import AppBody from './AppBody'; import Forbidden from './pages/403'; -import { QueryClient } from '@tanstack/react-query'; import { Notifications } from '@mantine/notifications'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false + } + } +}) + function App() { const systemColorScheme = useColorScheme() const [colorScheme, setColorScheme] = useState(getCookie('mantine-color-scheme') as ColorScheme || systemColorScheme); @@ -20,7 +28,8 @@ function App() { setCookie('mantine-color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 }); } - const auth = useAuth(); + const auth = useAuth() + // automatically sign-in useEffect(() => { if (!hasAuthParams() && @@ -38,37 +47,39 @@ function App() { } return ( -
- - ({ - // placeholder: { - // backgroundColor: 'transparent', - // } - // }) - // }, - } - }} - > - - - - - - -
+ +
+ + ({ + // placeholder: { + // backgroundColor: 'transparent', + // } + // }) + // }, + } + }} + > + + + + + + +
+
); } export default App; diff --git a/src/AppBody.tsx b/src/AppBody.tsx index 5c5854f..3894d4a 100644 --- a/src/AppBody.tsx +++ b/src/AppBody.tsx @@ -1,28 +1,19 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useState } from 'react'; import { AppShell, useMantineTheme, } from "@mantine/core" import { HeaderAction } from './widgets/header/HeaderAction'; import { headerLinks } from './widgets/header/header.links'; import AppRouter from './router/AppRouter'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Context } from '.'; import SideBar from './shared/components/SideBar'; import { observer } from 'mobx-react-lite'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false - } - } -}) - const AppBody = () => { + const { sideBarsStore } = useContext(Context) const [leftSideBar, setLeftSidebar] = useState(false) const [rightSideBar, setRightSidebar] = useState(false) - const leftSideBarIsHidden = (isHidden: boolean) => { setLeftSidebar(!isHidden) } @@ -34,29 +25,27 @@ const AppBody = () => { console.log("render Main") return ( - - - } - aside={ - !sideBarsStore.rightVisible ? <> : - - } - > - - - + header={ + + } + aside={ + !sideBarsStore.rightVisible ? <> : + + } + > + + ) }; diff --git a/src/hooks/useAdminRole.ts b/src/hooks/useAdminRole.ts new file mode 100644 index 0000000..0ecba78 --- /dev/null +++ b/src/hooks/useAdminRole.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { frigateQueryKeys, frigateApi } from "../services/frigate.proxy/frigate.api"; +import { useRealmAccessRoles } from "./useRealmAccessRoles"; + + +export const useAdminRole = () => { + const { data: adminConfig, isError, isPending } = useQuery({ + queryKey: [frigateQueryKeys.getAdminRole], + queryFn: frigateApi.getAdminRole + }) + + const roles = useRealmAccessRoles() + + if (isPending) return { isAdmin: undefined, isLoading: true } + if (isError) return { isAdmin: false, isError: true } + if (!adminConfig) return { isAdmin: true } + if (adminConfig && !adminConfig.value) return { isAdmin: true } + const isAdmin = roles.some(role => role === adminConfig.value) + return { isAdmin } +} \ No newline at end of file diff --git a/src/hooks/useRealmAccessRoles.ts b/src/hooks/useRealmAccessRoles.ts new file mode 100644 index 0000000..1168a86 --- /dev/null +++ b/src/hooks/useRealmAccessRoles.ts @@ -0,0 +1,30 @@ +import { jwtDecode } from "jwt-decode"; +import { useState, useEffect } from "react"; +import { useAuth } from "react-oidc-context"; + +interface CustomJwtPayload { + realm_access?: { + roles: string[]; + }; +} + +export const useRealmAccessRoles = () => { + const { user } = useAuth(); + const [roles, setRoles] = useState([]); + + useEffect(() => { + if (user) { + try { + const decoded = jwtDecode(user.access_token); + const realmAccess = decoded.realm_access; + if (realmAccess && realmAccess.roles) { + setRoles(realmAccess.roles); + } + } catch (error) { + console.error("Error decoding token:", error); + } + } + }, [user]); + + return roles; +}; \ No newline at end of file diff --git a/src/pages/AccessSettings.tsx b/src/pages/AccessSettings.tsx index ec58996..4ab844f 100644 --- a/src/pages/AccessSettings.tsx +++ b/src/pages/AccessSettings.tsx @@ -10,6 +10,8 @@ import { dimensions } from '../shared/dimensions/dimensions'; import CamerasTransferList from '../shared/components/CamerasTransferList'; import { Context } from '..'; import { strings } from '../shared/strings/strings'; +import { useAdminRole } from '../hooks/useAdminRole'; +import Forbidden from './403'; const AccessSettings = () => { const { data, isPending, isError, refetch } = useQuery({ @@ -17,7 +19,7 @@ const AccessSettings = () => { queryFn: frigateApi.getRoles }) const { sideBarsStore } = useContext(Context) - + const { isAdmin, isLoading: adminLoading } = useAdminRole() useEffect(() => { sideBarsStore.rightVisible = false @@ -29,8 +31,9 @@ const AccessSettings = () => { const [roleId, setRoleId] = useState() - if (isPending) return + if (isPending || adminLoading) return if (isError || !data) return + if (!isAdmin) return const rolesSelect: OneSelectItem[] = data.map(role => ({ value: role.id, label: role.name })) const handleSelectRole = (value: string) => { diff --git a/src/pages/FrigateHostsPage.tsx b/src/pages/FrigateHostsPage.tsx index a067a45..1072741 100644 --- a/src/pages/FrigateHostsPage.tsx +++ b/src/pages/FrigateHostsPage.tsx @@ -1,14 +1,16 @@ import React, { useContext, useEffect, useState } from 'react'; import FrigateHostsTable from '../widgets/FrigateHostsTable'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; -import { deleteFrigateHostSchema, GetFrigateHost, putFrigateHostSchema} from '../services/frigate.proxy/frigate.schema'; +import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; +import { deleteFrigateHostSchema, GetFrigateHost, putFrigateHostSchema } from '../services/frigate.proxy/frigate.schema'; import CenterLoader from '../shared/components/loaders/CenterLoader'; import RetryErrorPage from './RetryErrorPage'; import { Context } from '..'; import { strings } from '../shared/strings/strings'; import { Button, Flex } from '@mantine/core'; import { observer } from 'mobx-react-lite' +import { useAdminRole } from '../hooks/useAdminRole'; +import Forbidden from './403'; const FrigateHostsPage = observer(() => { const queryClient = useQueryClient() @@ -24,6 +26,7 @@ const FrigateHostsPage = observer(() => { sideBarsStore.setRightChildren(null) }, []) + const { isAdmin, isLoading: adminLoading } = useAdminRole() const [pageData, setPageData] = useState(data) useEffect(() => { @@ -70,8 +73,10 @@ const FrigateHostsPage = observer(() => { if (data) setPageData([...data]) } - if (hostsPending) return + if (hostsPending || adminLoading) return + if (!isAdmin) return if (hostsError) return + return (
{ diff --git a/src/pages/HostConfigPage.tsx b/src/pages/HostConfigPage.tsx index 700c0c3..2992761 100644 --- a/src/pages/HostConfigPage.tsx +++ b/src/pages/HostConfigPage.tsx @@ -10,11 +10,15 @@ import Editor, { DiffEditor, useMonaco, loader, Monaco } from '@monaco-editor/re import * as monaco from "monaco-editor"; import CenterLoader from '../shared/components/loaders/CenterLoader'; import RetryErrorPage from './RetryErrorPage'; +import { useAdminRole } from '../hooks/useAdminRole'; +import Forbidden from './403'; const HostConfigPage = () => { + const { sideBarsStore } = useContext(Context) + let { id } = useParams<'id'>() - const queryClient = useQueryClient() + const { isAdmin, isLoading: adminLoading } = useAdminRole() const theme = useMantineTheme(); const { isPending: configPending, error: configError, data: config, refetch } = useQuery({ queryKey: [frigateQueryKeys.getFrigateHost, id], @@ -27,6 +31,11 @@ const HostConfigPage = () => { }, }) + useEffect(() => { + sideBarsStore.rightVisible = false + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) +}, []) const clipboard = useClipboard({ timeout: 500 }) @@ -74,9 +83,10 @@ const HostConfigPage = () => { console.log('save config', save_option) }, [editorRef]) - if (configPending) return + if (configPending || adminLoading) return if (configError) return + if (!isAdmin) return return ( diff --git a/src/pages/HostStoragePage.tsx b/src/pages/HostStoragePage.tsx index 3dab211..6fcd1a7 100644 --- a/src/pages/HostStoragePage.tsx +++ b/src/pages/HostStoragePage.tsx @@ -1,9 +1,23 @@ -import React from 'react'; +import React, { useContext, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { Context } from '..'; +import { useAdminRole } from '../hooks/useAdminRole'; +import Forbidden from './403'; const HostStoragePage = () => { let { id } = useParams<'id'>() + const { sideBarsStore } = useContext(Context) + const { isAdmin, isLoading: adminLoading } = useAdminRole() + useEffect(() => { + sideBarsStore.rightVisible = false + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) + }, []) + + + + if (!isAdmin) return return (
Storage Page - NOT YET IMPLEMENTED diff --git a/src/pages/HostSystemPage.tsx b/src/pages/HostSystemPage.tsx index 991eb87..6856a8a 100644 --- a/src/pages/HostSystemPage.tsx +++ b/src/pages/HostSystemPage.tsx @@ -1,8 +1,21 @@ -import React from 'react'; +import React, { useContext, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { Context } from '..'; +import { useAdminRole } from '../hooks/useAdminRole'; +import Forbidden from './403'; const HostSystemPage = () => { let { id } = useParams<'id'>() + const { sideBarsStore } = useContext(Context) + const { isAdmin, isLoading: adminLoading } = useAdminRole() + + useEffect(() => { + sideBarsStore.rightVisible = false + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) + }, []) + + if (!isAdmin) return return (
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 02e977c..86d8965 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { useQuery, useMutation, @@ -13,6 +13,9 @@ import { strings } from '../shared/strings/strings'; import { dimensions } from '../shared/dimensions/dimensions'; import { useMediaQuery } from '@mantine/hooks'; import { GetConfig } from '../services/frigate.proxy/frigate.schema'; +import { Context } from '..'; +import { useAdminRole } from '../hooks/useAdminRole'; +import Forbidden from './403'; const SettingsPage = () => { const queryClient = useQueryClient() @@ -20,6 +23,16 @@ const SettingsPage = () => { queryKey: [frigateQueryKeys.getConfig], queryFn: frigateApi.getConfig, }) + + const { sideBarsStore } = useContext(Context) + useEffect(() => { + sideBarsStore.rightVisible = false + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) + }, []) + + const { isAdmin, isLoading: adminLoading } = useAdminRole() + const ecryptedValue = '**********' const mapEncryptedToView = (data: GetConfig[] | undefined): GetConfig[] | undefined => { @@ -81,9 +94,9 @@ const SettingsPage = () => { mutation.mutate(configsToUpdate); } - if (configPending) return - + if (configPending || adminLoading) return if (configError) return + if (!isAdmin) return return ( diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts index 6c01d64..fcbd3f2 100644 --- a/src/services/frigate.proxy/frigate.api.ts +++ b/src/services/frigate.proxy/frigate.api.ts @@ -13,7 +13,7 @@ import { EventFrigate } from "../../types/event"; import { keycloakConfig } from "../.."; -const getToken = (): string | undefined => { +export const getToken = (): string | undefined => { const key = `oidc.user:${keycloakConfig.authority}:${keycloakConfig.client_id}`; const stored = sessionStorage.getItem(key); const storedObject = stored ? JSON.parse(stored) : null; @@ -27,14 +27,14 @@ const instanceApi = axios.create({ instanceApi.interceptors.request.use( config => { - const token = getToken(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; + const token = getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; }, error => Promise.reject(error) - ); +); export const frigateApi = { getConfig: () => instanceApi.get('apiv1/config').then(res => res.data), @@ -42,9 +42,9 @@ export const frigateApi = { getHosts: () => instanceApi.get('apiv1/frigate-hosts').then(res => { return res.data }), - getHostsWithCameras: () => instanceApi.get('apiv1/frigate-hosts', { params: { include: 'cameras' } }).then(res => { - return res.data - }), + // getHostsWithCameras: () => instanceApi.get('apiv1/frigate-hosts', { params: { include: 'cameras' } }).then(res => { + // return res.data + // }), getHost: (id: string) => instanceApi.get(`apiv1/frigate-hosts/${id}`).then(res => { return res.data }), @@ -57,12 +57,11 @@ export const frigateApi = { return res.data }), getRoles: () => instanceApi.get('apiv1/roles').then(res => res.data), - getUsersByRole: (roleName: string) => instanceApi.get(`apiv1/users/${roleName}`).then(res => res.data), - getRoleWCameras: (roleId: string) => instanceApi.get(`apiv1/roles/${roleId}`).then(res => res.data), putRoleWCameras: (roleId: string, cameraIDs: string[]) => instanceApi.put(`apiv1/roles/${roleId}/cameras`, { cameraIDs: cameraIDs - }).then(res => res.data) + }).then(res => res.data), + getAdminRole: () => instanceApi.get('apiv1/config/admin').then(res => res.data), } export const proxyPrefix = `${proxyURL.protocol}//${proxyURL.host}/proxy/` @@ -71,14 +70,15 @@ export const proxyApi = { getHostConfigRaw: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config/raw`).then(res => res.data), getHostConfig: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config`).then(res => res.data), getImageFrigate: async (imageUrl: string) => { - const response = await axios.get(imageUrl, { + const response = await instanceApi.get(imageUrl, { responseType: 'blob' }) return response.data }, getVideoFrigate: async (videoUrl: string, onProgress: (percentage: number | undefined) => void) => { - const response = await axios.get(videoUrl, { + const response = await instanceApi.get(videoUrl, { responseType: 'blob', + timeout: 10 * 60 * 1000, onDownloadProgress: (progressEvent) => { const total = progressEvent.total const current = progressEvent.loaded; @@ -188,4 +188,5 @@ export const frigateQueryKeys = { getRoles: 'roles', getRoleWCameras: 'roles-cameras', getUsersByRole: 'users-role', + getAdminRole: 'admin-role', } diff --git a/src/shared/components/players/VideoPlayer.tsx b/src/shared/components/players/VideoPlayer.tsx index 9c883a8..6d98384 100644 --- a/src/shared/components/players/VideoPlayer.tsx +++ b/src/shared/components/players/VideoPlayer.tsx @@ -2,6 +2,7 @@ import React, { useRef, useEffect } from 'react'; import videojs from 'video.js'; import Player from 'video.js/dist/types/player'; import 'video.js/dist/video-js.css' +import { getToken } from '../../../services/frigate.proxy/frigate.api'; interface VideoPlayerProps { videoUrl: string @@ -12,6 +13,16 @@ const VideoPlayer = ({ videoUrl }: VideoPlayerProps) => { const playerRef = useRef(null); useEffect(() => { + + //@ts-ignore + videojs.Vhs.xhr.beforeRequest = function(options: any) { + options.headers = { + ...options.headers, + Authorization: `Bearer ${getToken()}`, + }; + return options; + }; + const defaultOptions = { preload: 'auto', autoplay: true, @@ -19,6 +30,7 @@ const VideoPlayer = ({ videoUrl }: VideoPlayerProps) => { { src: videoUrl, type: 'application/vnd.apple.mpegurl', + withCredentials: true, }, ], controls: true, @@ -69,4 +81,5 @@ const VideoPlayer = ({ videoUrl }: VideoPlayerProps) => { ); }; -export default VideoPlayer; \ No newline at end of file +export default VideoPlayer; + diff --git a/src/widgets/header/HeaderAction.tsx b/src/widgets/header/HeaderAction.tsx index a31c6a0..da1e8f8 100644 --- a/src/widgets/header/HeaderAction.tsx +++ b/src/widgets/header/HeaderAction.tsx @@ -7,6 +7,7 @@ import ColorSchemeToggle from "../../shared/components/buttons/ColorSchemeToggle import Logo from "../../shared/components/images/LogoImage"; import { routesPath } from "../../router/routes.path"; import DrawerMenu from "../../shared/components/menu/DrawerMenu"; +import { useAdminRole } from "../../hooks/useAdminRole"; const HEADER_HEIGHT = rem(60) @@ -55,7 +56,8 @@ const useStyles = createStyles((theme) => ({ export interface LinkItem { label: string - link: string + link: string, + admin?: boolean } export interface HeaderActionProps { @@ -67,16 +69,17 @@ export const HeaderAction = ({ links }: HeaderActionProps) => { const { classes } = useStyles(); const navigate = useNavigate() const auth = useAuth() + const { isAdmin } = useAdminRole() const handleNavigate = (link: string) => { navigate(link) } - const items = links.map(item => - + const items = links.filter(link => !(link.admin && !isAdmin)).map(link => + - diff --git a/src/widgets/header/header.links.ts b/src/widgets/header/header.links.ts index fcb085a..3f06e59 100644 --- a/src/widgets/header/header.links.ts +++ b/src/widgets/header/header.links.ts @@ -4,8 +4,8 @@ import { HeaderActionProps, LinkItem } from "./HeaderAction"; export const headerLinks: LinkItem[] = [ { link: routesPath.MAIN_PATH, label: headerMenu.home }, - { link: routesPath.SETTINGS_PATH, label: headerMenu.settings }, + { link: routesPath.SETTINGS_PATH, label: headerMenu.settings, admin: true }, { link: routesPath.RECORDINGS_PATH, label: headerMenu.recordings }, - { link: routesPath.HOSTS_PATH, label: headerMenu.hostsConfig }, - { link: routesPath.ACCESS_PATH, label: headerMenu.acessSettings }, + { link: routesPath.HOSTS_PATH, label: headerMenu.hostsConfig, admin: true }, + { link: routesPath.ACCESS_PATH, label: headerMenu.acessSettings, admin: true }, ] \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 45adf69..e7a0368 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6737,6 +6737,11 @@ jwt-decode@^3.1.2: resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== +jwt-decode@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" + integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== + keycode@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"