add admin role control

This commit is contained in:
NlightN22 2024-02-28 01:48:07 +07:00
parent 093cdb97e7
commit 4ed2ac9e2e
16 changed files with 233 additions and 102 deletions

View File

@ -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",

View File

@ -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<ColorScheme>(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 (
<div className="App">
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
// fontFamily: '-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji', //default system fonts
colorScheme: colorScheme,
components: {
Button: {
defaultProps: {
radius: "xl",
}
},
// Image: {
// styles: (theme) => ({
// placeholder: {
// backgroundColor: 'transparent',
// }
// })
// },
}
}}
>
<BrowserRouter>
<Notifications />
<AppBody />
</BrowserRouter>
</MantineProvider >
</ColorSchemeProvider>
</div>
<QueryClientProvider client={queryClient}>
<div className="App">
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
// fontFamily: '-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji', //default system fonts
colorScheme: colorScheme,
components: {
Button: {
defaultProps: {
radius: "xl",
}
},
// Image: {
// styles: (theme) => ({
// placeholder: {
// backgroundColor: 'transparent',
// }
// })
// },
}
}}
>
<BrowserRouter>
<Notifications />
<AppBody />
</BrowserRouter>
</MantineProvider >
</ColorSchemeProvider>
</div>
</QueryClientProvider>
);
}
export default App;

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<AppShell
styles={{
main: {
paddingLeft: !leftSideBar ? "1rem" : '',
paddingRight: !rightSideBar ? '1rem' : '',
background: theme.colorScheme === 'dark' ? theme.colors.dark[8] : undefined,
},
}}
navbarOffsetBreakpoint="sm"
asideOffsetBreakpoint="sm"
<AppShell
styles={{
main: {
paddingLeft: !leftSideBar ? "1rem" : '',
paddingRight: !rightSideBar ? '1rem' : '',
background: theme.colorScheme === 'dark' ? theme.colors.dark[8] : undefined,
},
}}
navbarOffsetBreakpoint="sm"
asideOffsetBreakpoint="sm"
header={
<HeaderAction links={headerLinks} />
}
aside={
!sideBarsStore.rightVisible ? <></> :
<SideBar isHidden = { rightSideBarIsHidden } side = "right" />
}
>
<AppRouter />
</AppShell>
</QueryClientProvider>
header={
<HeaderAction links={headerLinks} />
}
aside={
!sideBarsStore.rightVisible ? <></> :
<SideBar isHidden={rightSideBarIsHidden} side="right" />
}
>
<AppRouter />
</AppShell>
)
};

20
src/hooks/useAdminRole.ts Normal file
View File

@ -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 }
}

View File

@ -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<string[]>([]);
useEffect(() => {
if (user) {
try {
const decoded = jwtDecode<CustomJwtPayload>(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;
};

View File

@ -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<string>()
if (isPending) return <CenterLoader />
if (isPending || adminLoading) return <CenterLoader />
if (isError || !data) return <RetryErrorPage />
if (!isAdmin) return <Forbidden />
const rolesSelect: OneSelectItem[] = data.map(role => ({ value: role.id, label: role.name }))
const handleSelectRole = (value: string) => {

View File

@ -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 <CenterLoader />
if (hostsPending || adminLoading) return <CenterLoader />
if (!isAdmin) return <Forbidden />
if (hostsError) return <RetryErrorPage />
return (
<div>
{

View File

@ -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 <CenterLoader />
if (configPending || adminLoading) return <CenterLoader />
if (configError) return <RetryErrorPage onRetry={refetch} />
if (!isAdmin) return <Forbidden />
return (
<Flex direction='column' h='100%' w='100%' justify='stretch'>

View File

@ -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 <Forbidden />
return (
<div>
Storage Page - NOT YET IMPLEMENTED

View File

@ -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 <Forbidden />
return (
<div>

View File

@ -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 <CenterLoader />
if (configPending || adminLoading) return <CenterLoader />
if (configError) return <RetryErrorPage onRetry={refetch} />
if (!isAdmin) return <Forbidden />
return (
<Flex h='100%'>

View File

@ -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<GetConfig[]>('apiv1/config').then(res => res.data),
@ -42,9 +42,9 @@ export const frigateApi = {
getHosts: () => instanceApi.get<GetFrigateHost[]>('apiv1/frigate-hosts').then(res => {
return res.data
}),
getHostsWithCameras: () => instanceApi.get<GetFrigateHostWithCameras[]>('apiv1/frigate-hosts', { params: { include: 'cameras' } }).then(res => {
return res.data
}),
// getHostsWithCameras: () => instanceApi.get<GetFrigateHostWithCameras[]>('apiv1/frigate-hosts', { params: { include: 'cameras' } }).then(res => {
// return res.data
// }),
getHost: (id: string) => instanceApi.get<GetFrigateHostWithCameras>(`apiv1/frigate-hosts/${id}`).then(res => {
return res.data
}),
@ -57,12 +57,11 @@ export const frigateApi = {
return res.data
}),
getRoles: () => instanceApi.get<GetRole[]>('apiv1/roles').then(res => res.data),
getUsersByRole: (roleName: string) => instanceApi.get<GetUserByRole[]>(`apiv1/users/${roleName}`).then(res => res.data),
getRoleWCameras: (roleId: string) => instanceApi.get<GetRoleWCameras>(`apiv1/roles/${roleId}`).then(res => res.data),
putRoleWCameras: (roleId: string, cameraIDs: string[]) => instanceApi.put<GetRoleWCameras>(`apiv1/roles/${roleId}/cameras`,
{
cameraIDs: cameraIDs
}).then(res => res.data)
}).then(res => res.data),
getAdminRole: () => instanceApi.get<GetConfig>('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<Blob>(imageUrl, {
const response = await instanceApi.get<Blob>(imageUrl, {
responseType: 'blob'
})
return response.data
},
getVideoFrigate: async (videoUrl: string, onProgress: (percentage: number | undefined) => void) => {
const response = await axios.get<Blob>(videoUrl, {
const response = await instanceApi.get<Blob>(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',
}

View File

@ -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<Player | null>(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;
export default VideoPlayer;

View File

@ -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 =>
<Menu key={item.label} trigger="hover" transitionProps={{ exitDuration: 0 }} withinPortal>
const items = links.filter(link => !(link.admin && !isAdmin)).map(link =>
<Menu key={link.label} trigger="hover" transitionProps={{ exitDuration: 0 }} withinPortal>
<Menu.Target>
<Button variant="subtle" uppercase onClick={() => handleNavigate(item.link)}>
{item.label}
<Button variant="subtle" uppercase onClick={() => handleNavigate(link.link)}>
{link.label}
</Button>
</Menu.Target>
</Menu>

View File

@ -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 },
]

View File

@ -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"