From 9b1450d2ea5b66f521ed6d69a366068ea5f1b503 Mon Sep 17 00:00:00 2001 From: tkalir Date: Mon, 6 Jul 2026 17:02:58 +0300 Subject: [PATCH 1/7] adding API layer for admin page --- src/services/GrantService.ts | 73 ++++++++++++++++++++++ src/stores/RootStore.ts | 3 + src/stores/admin/GrantStore.ts | 109 +++++++++++++++++++++++++++++++++ src/types/grant.ts | 19 ++++++ 4 files changed, 204 insertions(+) create mode 100644 src/services/GrantService.ts create mode 100644 src/stores/admin/GrantStore.ts create mode 100644 src/types/grant.ts diff --git a/src/services/GrantService.ts b/src/services/GrantService.ts new file mode 100644 index 00000000..64281f3f --- /dev/null +++ b/src/services/GrantService.ts @@ -0,0 +1,73 @@ +import axios, { AxiosError } from 'axios'; +import { API_ANYWAY_URL } from '../utils/globalEnvs'; +import { + Grant, + CreateGrantPayload, + UserGrantPayload, + DeleteGrantPayload, +} from '../types/grant'; + +const apiUrl = API_ANYWAY_URL; +const withCredentials = { withCredentials: true }; + +class GrantService { + static async getGrantsList(): Promise { + try { + const response = await axios.get(`${apiUrl}/sd-user/get_grants_list`, withCredentials); + return response.data; + } catch (error) { + GrantService.handleError('fetching grants list', error); + throw error; + } + } + + static async addGrant(data: CreateGrantPayload): Promise { + try { + await axios.post(`${apiUrl}/sd-user/add_grant`, data, withCredentials); + } catch (error) { + GrantService.handleError('adding grant', error); + throw error; + } + } + + static async addToGrant(data: UserGrantPayload): Promise { + try { + await axios.post(`${apiUrl}/sd-user/add_to_grant`, data, withCredentials); + } catch (error) { + GrantService.handleError('assigning grant to user', error); + throw error; + } + } + + static async removeFromGrant(data: UserGrantPayload): Promise { + try { + await axios.post(`${apiUrl}/sd-user/remove_from_grant`, data, withCredentials); + } catch (error) { + GrantService.handleError('removing grant from user', error); + throw error; + } + } + + static async deleteGrant(data: DeleteGrantPayload): Promise { + try { + await axios.post(`${apiUrl}/sd-user/delete_grant`, data, withCredentials); + } catch (error) { + GrantService.handleError('deleting grant', error); + throw error; + } + } + + private static handleError(action: string, error: unknown): void { + if (error instanceof AxiosError) { + if (error.response?.status === 403) { + console.error(`Unauthorized: You do not have permission while ${action}.`); + } else { + console.error(`Error ${action}:`, error.response?.data || error.message); + } + } else { + console.error(`Unexpected error while ${action}:`, error); + } + } +} + +export default GrantService; diff --git a/src/stores/RootStore.ts b/src/stores/RootStore.ts index 0dc9202c..2d92afb3 100644 --- a/src/stores/RootStore.ts +++ b/src/stores/RootStore.ts @@ -5,6 +5,7 @@ import ImageStore from './image/ImageStore'; import IRecommendationStore from './recommendation/RecommendationStore'; import RecommendationStore from './recommendation/RecommendationStore'; import UserStore from './user/UserStore'; +import GrantStore from './admin/GrantStore'; import LocalDBFilterStore from './filter/LocalDBFilterStore'; export default class RootStore { @@ -17,6 +18,7 @@ export default class RootStore { this.imageStore = new ImageStore(this); this.recommendationStore = new RecommendationStore(this); this.userStore = new UserStore(this); + this.grantStore = new GrantStore(this); this.localDbFilterStroe = new LocalDBFilterStore(this); } @@ -27,4 +29,5 @@ export default class RootStore { imageStore: ImageStore; recommendationStore :IRecommendationStore; userStore: UserStore; + grantStore: GrantStore; } diff --git a/src/stores/admin/GrantStore.ts b/src/stores/admin/GrantStore.ts new file mode 100644 index 00000000..8b1d9e71 --- /dev/null +++ b/src/stores/admin/GrantStore.ts @@ -0,0 +1,109 @@ +import { makeAutoObservable } from 'mobx'; +import GrantService from '../../services/GrantService'; +import RootStore from '../RootStore'; +import { Grant, CreateGrantPayload, UserGrantPayload } from '../../types/grant'; + +export default class GrantStore { + rootStore: RootStore; + grants: Grant[] = []; + loading = false; + successMessage = ''; + errorMessage = ''; + + constructor(rootStore: RootStore) { + this.rootStore = rootStore; + makeAutoObservable(this); + } + + setLoading = (value: boolean) => { + this.loading = value; + }; + + setSuccessMessage = (value: string) => { + this.successMessage = value; + }; + + setErrorMessage = (value: string) => { + this.errorMessage = value; + }; + + clearMessages = () => { + this.successMessage = ''; + this.errorMessage = ''; + }; + + fetchGrants = async () => { + if (!this.rootStore.userStore.isAdmin) { + this.setErrorMessage('Unauthorized: admin access required.'); + return; + } + this.setLoading(true); + try { + const list = await GrantService.getGrantsList(); + this.grants = list; + this.setErrorMessage(''); + } catch { + this.setErrorMessage('Failed to load grants.'); + } finally { + this.setLoading(false); + } + }; + + createGrant = async (data: CreateGrantPayload) => { + if (!this.rootStore.userStore.isAdmin) { + this.setErrorMessage('Unauthorized: admin access required.'); + return; + } + try { + await GrantService.addGrant(data); + this.setSuccessMessage('Grant created successfully.'); + this.setErrorMessage(''); + await this.fetchGrants(); + } catch { + this.setErrorMessage('Failed to create grant.'); + } + }; + + deleteGrant = async (grantName: string) => { + if (!this.rootStore.userStore.isAdmin) { + this.setErrorMessage('Unauthorized: admin access required.'); + return; + } + try { + await GrantService.deleteGrant({ grant: grantName }); + this.setSuccessMessage('Grant deleted successfully.'); + this.setErrorMessage(''); + await this.fetchGrants(); + } catch { + this.setErrorMessage('Failed to delete grant.'); + } + }; + + assignGrantToUser = async (data: UserGrantPayload) => { + if (!this.rootStore.userStore.isAdmin) { + this.setErrorMessage('Unauthorized: admin access required.'); + return; + } + try { + await GrantService.addToGrant(data); + this.setSuccessMessage('Grant assigned to user successfully.'); + this.setErrorMessage(''); + } catch { + this.setErrorMessage('Failed to assign grant to user.'); + } + }; + + removeGrantFromUser = async (data: UserGrantPayload) => { + if (!this.rootStore.userStore.isAdmin) { + this.setErrorMessage('Unauthorized: admin access required.'); + return; + } + try { + await GrantService.removeFromGrant(data); + this.setSuccessMessage('Grant removed from user successfully.'); + this.setErrorMessage(''); + } catch { + this.setErrorMessage('Failed to remove grant from user.'); + } + }; +} diff --git a/src/types/grant.ts b/src/types/grant.ts new file mode 100644 index 00000000..7f9b21cc --- /dev/null +++ b/src/types/grant.ts @@ -0,0 +1,19 @@ +export interface Grant { + id: number; + name: string; + description: string; +} + +export interface CreateGrantPayload { + name: string; + description: string; +} + +export interface UserGrantPayload { + email: string; + grant: string; +} + +export interface DeleteGrantPayload { + grant: string; +} From 5379f5ffe3910c8b6a356cc43b58286ed2ff74a8 Mon Sep 17 00:00:00 2001 From: tkalir Date: Mon, 6 Jul 2026 18:00:00 +0300 Subject: [PATCH 2/7] Add admin-protected route --- src/components/auth/AdminRoute.tsx | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/components/auth/AdminRoute.tsx diff --git a/src/components/auth/AdminRoute.tsx b/src/components/auth/AdminRoute.tsx new file mode 100644 index 00000000..1693731b --- /dev/null +++ b/src/components/auth/AdminRoute.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { Button } from 'react-bootstrap'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '../../stores/storeConfig'; +import { Loader } from '../common'; + +interface AdminRouteProps { + children: React.ReactNode; +} + +const AdminRoute: React.FC = observer(({ children }) => { + const { userStore } = useStore(); + + if (userStore.isLoading) { + return ; + } + + if (!userStore.isAuthenticated) { + return ( +
+ + Admin access requires login + + +
+ ); + } + + if (!userStore.isAdmin) { + return ; + } + + return <>{children}; +}); + +export default AdminRoute; From 8c9f56ab64d8865138e13ba3aa183bad9fdee16d Mon Sep 17 00:00:00 2001 From: tkalir Date: Mon, 6 Jul 2026 18:00:45 +0300 Subject: [PATCH 3/7] Add admin page --- src/pages/AdminPage.tsx | 214 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/pages/AdminPage.tsx diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx new file mode 100644 index 00000000..2da46eb8 --- /dev/null +++ b/src/pages/AdminPage.tsx @@ -0,0 +1,214 @@ +import React, { useEffect } from 'react'; +import { observer } from 'mobx-react-lite'; +import { + Container, + Form, + Button, + Spinner, + Table, + ToastContainer, + Toast, + Alert, +} from 'react-bootstrap'; +import { useStore } from '../stores/storeConfig'; + +const AdminPage: React.FC = observer(() => { + const { grantStore } = useStore(); + const { + grants, + loading, + successMessage, + errorMessage, + fetchGrants, + createGrant, + deleteGrant, + assignGrantToUser, + removeGrantFromUser, + clearMessages, + } = grantStore; + + const [createName, setCreateName] = React.useState(''); + const [createDescription, setCreateDescription] = React.useState(''); + const [assignEmail, setAssignEmail] = React.useState(''); + const [assignGrant, setAssignGrant] = React.useState(''); + const [removeEmail, setRemoveEmail] = React.useState(''); + const [removeGrant, setRemoveGrant] = React.useState(''); + + useEffect(() => { + fetchGrants(); + }, [fetchGrants]); + + useEffect(() => { + if (successMessage || errorMessage) { + const timeout = setTimeout(() => clearMessages(), 3000); + return () => clearTimeout(timeout); + } + }, [successMessage, errorMessage, clearMessages]); + + useEffect(() => { + if (grants.length > 0 && !assignGrant) { + setAssignGrant(grants[0].name); + } + if (grants.length > 0 && !removeGrant) { + setRemoveGrant(grants[0].name); + } + }, [grants, assignGrant, removeGrant]); + + const handleCreateGrant = async (e: React.FormEvent) => { + e.preventDefault(); + await createGrant({ name: createName.trim(), description: createDescription.trim() }); + setCreateName(''); + setCreateDescription(''); + }; + + const handleAssignGrant = async (e: React.FormEvent) => { + e.preventDefault(); + await assignGrantToUser({ email: assignEmail.trim(), grant: assignGrant }); + setAssignEmail(''); + }; + + const handleRemoveGrant = async (e: React.FormEvent) => { + e.preventDefault(); + await removeGrantFromUser({ email: removeEmail.trim(), grant: removeGrant }); + setRemoveEmail(''); + }; + + const handleDeleteGrant = async (grantName: string) => { + if (window.confirm(`Delete grant "${grantName}"? This will remove all user associations.`)) { + await deleteGrant(grantName); + } + }; + + return ( + +

Grant Management

+ +

Grants

+ {loading ? ( + + ) : grants.length === 0 ? ( + No grants found. + ) : ( + + + + + + + + + + {grants.map((grant) => ( + + + + + + ))} + +
NameDescriptionActions
{grant.name}{grant.description} + +
+ )} + +

Create Grant

+
+ + Name + setCreateName(e.target.value)} + required + /> + + + Description + setCreateDescription(e.target.value)} + required + /> + + +
+ +

Assign Grant to User

+
+ + User Email + setAssignEmail(e.target.value)} + required + /> + + + Grant + setAssignGrant(e.target.value)} required> + {grants.map((grant) => ( + + ))} + + + +
+ +

Remove Grant from User

+
+ + User Email + setRemoveEmail(e.target.value)} + required + /> + + + Grant + setRemoveGrant(e.target.value)} required> + {grants.map((grant) => ( + + ))} + + + +
+ + + {successMessage && ( + + {successMessage} + + )} + {errorMessage && ( + + {errorMessage} + + )} + +
+ ); +}); + +export default AdminPage; From c744ff2b5b9c41842b81f50ec991c1abf5c2b4cb Mon Sep 17 00:00:00 2001 From: tkalir Date: Mon, 6 Jul 2026 18:02:02 +0300 Subject: [PATCH 4/7] Add admin to navigation list --- src/components/molecules/NavigationList.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/molecules/NavigationList.tsx b/src/components/molecules/NavigationList.tsx index 8b2cae98..e8be3f60 100644 --- a/src/components/molecules/NavigationList.tsx +++ b/src/components/molecules/NavigationList.tsx @@ -2,11 +2,14 @@ import React from 'react'; import { useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { observer } from 'mobx-react-lite'; import { setHeaderExpanded } from '../../stores'; +import { useStore } from '../../stores/storeConfig'; -const NavigationList: React.FC = () => { +const NavigationList: React.FC = observer(() => { const { t } = useTranslation(); - const dispatch = useDispatch(); + const { userStore } = useStore(); + const dispatch = useDispatch(); const handleLinkClick = () => { dispatch(setHeaderExpanded(false)); }; @@ -25,7 +28,12 @@ const NavigationList: React.FC = () => { {t('About')} + {userStore.isAdmin && ( + + Admin + + )} ); -}; -export default NavigationList; \ No newline at end of file +}); +export default NavigationList; From 30402c2285c1416ea4f6273588d0e40010873491 Mon Sep 17 00:00:00 2001 From: tkalir Date: Tue, 21 Jul 2026 22:01:38 +0300 Subject: [PATCH 5/7] adding admin page route in app.tsx --- src/App.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 5536e3ee..9ca71b4a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import ModelPage from './pages/ModelPage'; import Header from './components/templates/Header/Header'; import MapWithClusters from './pages/MapWithClusters'; import Footer from './components/templates/footer/Footer'; +import AdminRoute from './components/auth/AdminRoute'; import './i18n'; import './App.css'; @@ -21,6 +22,7 @@ const Login = lazy(() => import('./components/auth/Login')); const Register = lazy(() => import('./components/auth/Register')); const Profile = lazy(() => import('./components/auth/Profile')); const LoginPopupRedirect = lazy(() => import('./components/auth/LoginPopupRedirect')); +const AdminPage = lazy(() => import('./pages/AdminPage')); const styles = { app: { @@ -96,6 +98,14 @@ function App() { } /> } /> } /> + + + + } + />