diff --git a/package.json b/package.json
index 55b57d1..d3e89a6 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"i18next": "^23.14.0",
"mutative": "^1.0.6",
"postcss": "^8.4.39",
+ "oidc-client-ts": "^3.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.0.3",
diff --git a/src/App.tsx b/src/App.tsx
index 55d8fd3..027e311 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,12 +4,12 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { Suspense } from 'react';
+import { Suspense, useEffect } from 'react';
import { Route, Routes } from 'react-router-dom';
import './App.css';
import ThemeHandler from './components/common/handler/ThemeHandler';
import PegaseStar from './components/pegase/star/PegaseStar';
-import { UserContext } from '@/store/contexts/UserContext';
+import { UserSettingsContext } from '@/store/contexts/UserSettingsContext.tsx';
import { THEME_COLOR } from '@/shared/types';
import { menuBottomData, menuTopData } from './routes';
import { PegaseToastContainer } from './shared/notification/containers';
@@ -19,36 +19,51 @@ import { RdsNavbar } from 'rte-design-system-react';
import { navBarConfig } from '@/shared/const/navBarConfig';
import { useTranslation } from 'react-i18next';
import { translateMenuItemLabel } from '@/shared/utils/textUtils.ts';
+import { PEGASE_NAVBAR_ID } from '@/shared/constants.ts';
+import UserProvider from '@/store/contexts/UserProvider.tsx';
+import { AuthService } from '@/shared/services/authService.ts';
function App() {
const { t } = useTranslation();
+ useEffect(() => {
+ const handleAuth = async () => {
+ if (window.location.href.includes('code=')) {
+ await AuthService.handleCallback();
+ window.location.replace('/'); // Redirect to home page after login
+ }
+ };
+ void handleAuth();
+ }, []);
+
return (
-
-
-
-
-
-
-
-
-
- } />
- } />
- {Object.entries([...menuBottomData, ...menuTopData]).map(([key, route]) => (
-
- ))}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ } />
+ } />
+ {Object.entries([...menuBottomData, ...menuTopData]).map(([key, route]) => (
+
+ ))}
+
+
+
+
+
+
);
}
diff --git a/src/components/common/handler/ThemeHandler.tsx b/src/components/common/handler/ThemeHandler.tsx
index 3c30596..7d0d3e3 100644
--- a/src/components/common/handler/ThemeHandler.tsx
+++ b/src/components/common/handler/ThemeHandler.tsx
@@ -4,14 +4,14 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { UserContext } from '@/store/contexts/UserContext';
+import { UserSettingsContext } from '@/store/contexts/UserSettingsContext.tsx';
import usePrevious from '@/hooks/common/usePrevious';
import { THEME_COLOR } from '@/shared/types';
import { useEffect } from 'react';
const ThemeHandler = () => {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)');
- const themeColor = UserContext.useStore((store) => store.theme);
+ const themeColor = UserSettingsContext.useStore((store) => store.theme);
const previousTheme = usePrevious(themeColor, undefined);
useEffect(() => {
if (previousTheme) {
diff --git a/src/components/common/handler/test/ThemeHandler.test.tsx b/src/components/common/handler/test/ThemeHandler.test.tsx
index c53ba63..5060069 100644
--- a/src/components/common/handler/test/ThemeHandler.test.tsx
+++ b/src/components/common/handler/test/ThemeHandler.test.tsx
@@ -8,12 +8,12 @@ import { render } from '@testing-library/react';
import { describe, expect, it, Mock, vi } from 'vitest';
import { THEME_COLOR } from '@/shared/types';
import ThemeHandler from '../ThemeHandler';
-import { UserContext } from '@/store/contexts/UserContext';
+import { UserSettingsContext } from '@/store/contexts/UserSettingsContext.tsx';
import usePrevious from '@/hooks/common/usePrevious';
-// Mocking the UserContext and usePrevious hook
-vi.mock('@/store/contexts/UserContext', () => ({
- UserContext: {
+// Mocking the UserSettingsContext and usePrevious hook
+vi.mock('@/store/contexts/UserSettingsContext', () => ({
+ UserSettingsContext: {
useStore: vi.fn(),
},
}));
@@ -23,7 +23,7 @@ vi.mock('@/hooks/common/usePrevious', () => ({
}));
describe('ThemeHandler', () => {
- const mockUseStore = UserContext.useStore as Mock;
+ const mockUseStore = UserSettingsContext.useStore as Mock;
const mockUsePrevious = usePrevious as Mock;
beforeEach(() => {
diff --git a/src/components/common/layout/stdAvatarGroup/StdAvatarGroup.tsx b/src/components/common/layout/stdAvatarGroup/StdAvatarGroup.tsx
index 5981c4a..e295ce0 100644
--- a/src/components/common/layout/stdAvatarGroup/StdAvatarGroup.tsx
+++ b/src/components/common/layout/stdAvatarGroup/StdAvatarGroup.tsx
@@ -4,15 +4,14 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { User } from '@/shared/types/common/User.type';
-import StdAvatar from '../stdAvatar/StdAvatar';
-import { AvatarSize } from '../stdAvatar/StdAvatar';
+import { UserInfo } from '@/shared/types/common/User.type';
+import StdAvatar, { AvatarSize } from '../stdAvatar/StdAvatar';
import { classBuilder as groupContainerClassBuilder } from './avatarGroupClassBuilder';
import { getColor, getUserFullname, getUserInitials, splitUserList } from './avatarTools';
import { useRdsId } from 'rte-design-system-react';
type StdAvatarGroup = {
- users: User[];
+ users: UserInfo[];
avatarSize: AvatarSize;
id?: string;
};
diff --git a/src/components/common/layout/stdAvatarGroup/avatarTools.ts b/src/components/common/layout/stdAvatarGroup/avatarTools.ts
index 21cb9a5..f53544c 100644
--- a/src/components/common/layout/stdAvatarGroup/avatarTools.ts
+++ b/src/components/common/layout/stdAvatarGroup/avatarTools.ts
@@ -4,13 +4,13 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { User } from '@/shared/types/common/User.type';
+import { UserInfo } from '@/shared/types/common/User.type';
export const AVATAR_COLORS = ['green', 'purple', 'blue', 'pink', 'gray', 'orange'] as const;
const USER_SEPARATOR = ' - ';
const MAX_USER_CHIP = 3;
-export const splitUserList = (users: User[]) => {
+export const splitUserList = (users: UserInfo[]) => {
if (users.length <= MAX_USER_CHIP) {
return users;
}
@@ -18,19 +18,19 @@ export const splitUserList = (users: User[]) => {
return [firstUser, secondUser, otherUsers];
};
-export const getInitials = (user: User) => {
+export const getInitials = (user: UserInfo) => {
const [firstName, lastName = ''] = user.fullname.split(' ');
return lastName.charAt(0) + firstName.charAt(0);
};
-export const getUserInitials = (users: User | User[]) => {
+export const getUserInitials = (users: UserInfo | UserInfo[]) => {
if (!Array.isArray(users)) {
return getInitials(users);
}
return `+${users.length}`;
};
-export const getUserFullname = (users: User | User[]) => {
+export const getUserFullname = (users: UserInfo | UserInfo[]) => {
if (!Array.isArray(users)) {
return users.fullname;
}
@@ -38,7 +38,7 @@ export const getUserFullname = (users: User | User[]) => {
};
//assign a random color from COLORS
-export const getColor = (users: User | User[]) => {
+export const getColor = (users: UserInfo | UserInfo[]) => {
if (!Array.isArray(users)) {
return AVATAR_COLORS[
users.fullname.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % AVATAR_COLORS.length
diff --git a/src/envVariables.ts b/src/envVariables.ts
index 883d455..8ce41bb 100644
--- a/src/envVariables.ts
+++ b/src/envVariables.ts
@@ -6,11 +6,17 @@
type EnvVariableType = {
VITE_BACK_END_BASE_URL: string;
+ VITE_OAUTH2_CLIENT_ID: string;
+ VITE_OAUTH2_REDIRECT_URL: string;
+ VITE_OAUTH2_AUTHORITY: string;
};
// Environment Variable Template to Be Replaced at Runtime
export const envVariables: EnvVariableType = {
VITE_BACK_END_BASE_URL: '${URL_BACKEND}',
+ VITE_OAUTH2_CLIENT_ID: '${PEGASE_OAUTH2_CLIENT_ID}',
+ VITE_OAUTH2_REDIRECT_URL: '${PEGASE_OAUTH2_REDIRECT_URL}',
+ VITE_OAUTH2_AUTHORITY: '${PEGASE_OAUTH2_AUTHORITY}',
};
-export const getEnvVariables = (key: keyof EnvVariableType) =>
+export const getEnvVariables = (key: keyof EnvVariableType): string =>
envVariables[key].startsWith('$') ? (import.meta.env[key] as string) : envVariables[key];
diff --git a/src/hooks/test/useFetchProjectList.test.ts b/src/hooks/test/useFetchProjectList.test.ts
index 87836d4..ef16793 100644
--- a/src/hooks/test/useFetchProjectList.test.ts
+++ b/src/hooks/test/useFetchProjectList.test.ts
@@ -16,23 +16,22 @@ vi.mock('@/envVariables', () => ({
describe('useFetchProjectList', () => {
beforeEach(() => {
- global.fetch = vi.fn(() =>
- Promise.resolve({
- json: () =>
- Promise.resolve({
- content: [
- {
- projectId: '1',
- name: 'Project 1',
- tags: ['Tag1', 'Tag2'],
- creationDate: '2023-10-01',
- createdBy: 'User A',
- },
- ],
- totalElements: 1,
- }),
- }),
- ) as unknown as typeof fetch;
+ global.fetch = vi.fn().mockResolvedValueOnce({
+ ok: true,
+ json: async () =>
+ Promise.resolve({
+ content: [
+ {
+ projectId: '1',
+ name: 'Project 1',
+ tags: ['Tag1', 'Tag2'],
+ creationDate: '2023-10-01',
+ createdBy: 'User A',
+ },
+ ],
+ totalElements: 1,
+ }),
+ });
});
afterEach(() => {
@@ -60,7 +59,7 @@ describe('useFetchProjectList', () => {
renderHook(() => useFetchProjectList('test', 0, 9));
await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/project/search?page=1&size=9&search=test');
+ expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/project/search?page=1&size=9&search=test', {});
});
});
@@ -68,7 +67,7 @@ describe('useFetchProjectList', () => {
renderHook(() => useFetchProjectList('', 1, 9));
await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/project/search?page=2&size=9&search=');
+ expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/project/search?page=2&size=9&search=', {});
});
});
});
diff --git a/src/hooks/test/useStudyTableDisplay.test.ts b/src/hooks/test/useStudyTableDisplay.test.ts
index 28bda89..66d10f3 100644
--- a/src/hooks/test/useStudyTableDisplay.test.ts
+++ b/src/hooks/test/useStudyTableDisplay.test.ts
@@ -15,6 +15,7 @@ vi.mock('@/envVariables', () => ({
describe('useStudyTableDisplay', () => {
beforeEach(() => {
global.fetch = vi.fn();
+ vi.restoreAllMocks();
});
afterEach(() => {
@@ -48,11 +49,11 @@ describe('useStudyTableDisplay', () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
- json: async () => mockResponse,
+ json: async () => Promise.resolve(mockResponse),
});
const { result } = renderHook(() =>
- useStudyTableDisplay({ searchTerm: 'test', sortBy: { status: 'desc' }, reloadStudies: true }),
+ useStudyTableDisplay({ searchTerm: 'test', sortBy: { status: 'desc' }, reloadStudies: false }),
);
await waitFor(() => {
expect(result.current.rows).toHaveLength(2);
@@ -61,16 +62,18 @@ describe('useStudyTableDisplay', () => {
//expect(global.fetch).toHaveBeenCalledTimes(1); TODO: ANT-2719
expect(global.fetch).toHaveBeenCalledWith(
'https://mockapi.com/v1/study/search?page=1&size=9&projectId=&search=test&sortColumn=status&sortDirection=desc',
+ {},
);
});
await act(async () => {
- renderHook(() => useStudyTableDisplay({ searchTerm: 'mouad', sortBy: { project: 'asc' }, reloadStudies: true }));
+ renderHook(() => useStudyTableDisplay({ searchTerm: 'mouad', sortBy: { project: 'asc' }, reloadStudies: false }));
});
//expect(global.fetch).toHaveBeenCalledTimes(1); TODO: ANT-2719
expect(global.fetch).toHaveBeenCalledWith(
'https://mockapi.com/v1/study/search?page=1&size=9&projectId=&search=mouad&sortColumn=project&sortDirection=asc',
+ {},
);
});
diff --git a/src/hooks/useFetchProjectList.ts b/src/hooks/useFetchProjectList.ts
index 0970502..54b491c 100644
--- a/src/hooks/useFetchProjectList.ts
+++ b/src/hooks/useFetchProjectList.ts
@@ -9,6 +9,7 @@ import { ProjectActionType, ProjectInfo } from '@/shared/types/pegase/Project.ty
import { fetchProjectFromSearchTerm } from '@/shared/services/projectService.ts';
import { PROJECT_ACTION } from '@/shared/enum/project.ts';
import { useProjectDispatch } from '@/store/contexts/ProjectContext.tsx';
+import { PaginatedResponse } from '@/shared/types';
export const useFetchProjectList = (searchTerm: string, current: number, intervalSize: number) => {
const [projects, setProjects] = useState([]);
@@ -16,17 +17,23 @@ export const useFetchProjectList = (searchTerm: string, current: number, interva
const dispatch = useProjectDispatch();
const fetchProjects = useCallback(
- async (searchTerm: string, current: number, intervalSize: number) => {
- fetchProjectFromSearchTerm(searchTerm, current, intervalSize)
- .then((json) => {
- dispatch?.({
- type: PROJECT_ACTION.INIT_PROJECT_LIST,
- payload: json.content,
- } as ProjectActionType);
- setProjects(json.content);
- setCount(json.totalElements);
- })
- .catch((error) => console.error(error));
+ async (term: string, currentPage: number, size: number) => {
+ try {
+ const { content, totalElements } = (await fetchProjectFromSearchTerm(
+ term,
+ currentPage,
+ size,
+ )) as PaginatedResponse;
+
+ dispatch?.({
+ type: PROJECT_ACTION.INIT_PROJECT_LIST,
+ payload: content,
+ } as ProjectActionType);
+ setProjects(content);
+ setCount(totalElements);
+ } catch (error) {
+ console.error(error);
+ }
},
[current, intervalSize, searchTerm],
);
diff --git a/src/hooks/useHandlePinnedProjectList.ts b/src/hooks/useHandlePinnedProjectList.ts
index cb03f93..ca891b8 100644
--- a/src/hooks/useHandlePinnedProjectList.ts
+++ b/src/hooks/useHandlePinnedProjectList.ts
@@ -5,7 +5,7 @@
*/
import { useCallback, useEffect } from 'react';
-import { ProjectActionType } from '@/shared/types/pegase/Project.type';
+import { ProjectActionType, ProjectInfo } from '@/shared/types/pegase/Project.type';
import { fetchPinnedProjects, pinProject, unpinProject } from '@/shared/services/pinnedProjectService';
import { v4 as uuidv4 } from 'uuid';
import { dismissToast, notifyToast, NotifyWithActionProps } from '@/shared/notification/notification.tsx';
@@ -20,7 +20,7 @@ export const useHandlePinnedProjectList = () => {
const getPinnedProjects = useCallback(async () => {
try {
- const projects = await fetchPinnedProjects(userId);
+ const projects = (await fetchPinnedProjects(userId)) as ProjectInfo[];
if (projects?.length) {
dispatch?.({
type: PROJECT_ACTION.INIT_PINNED_PROJECT_LIST,
diff --git a/src/hooks/useStudyTableDisplay.ts b/src/hooks/useStudyTableDisplay.ts
index 6bb827e..7bcdcc4 100644
--- a/src/hooks/useStudyTableDisplay.ts
+++ b/src/hooks/useStudyTableDisplay.ts
@@ -5,7 +5,7 @@
*/
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
-import { StudyDTO } from '@/shared/types';
+import { PaginatedResponse, StudyDTO } from '@/shared/types';
import { fetchSearchStudies } from '@/shared/services/studyService.ts';
const ITEMS_PER_PAGE = 9;
@@ -37,7 +37,7 @@ export const useStudyTableDisplay = ({
const [rows, setRows] = useState([]);
const [count, setCount] = useState(0);
const [currentPage, setCurrentPage] = useState(0);
- const [error, setError] = useState(null);
+ const [errorValue, setErrorValue] = useState(null);
const searchTermRef = useRef(searchTerm);
const projectIdRef = useRef(projectId);
@@ -57,12 +57,13 @@ export const useStudyTableDisplay = ({
useEffect(() => {
fetchSearchStudies(searchTermRef.current, projectIdRef.current, currentPage, intervalSize, sortByRef.current)
- .then(({ content, totalElements }) => {
+ .then((json) => {
+ const { content, totalElements } = json as PaginatedResponse;
setRows(content);
setCount(totalElements);
})
- .catch((error) => setError(error));
- }, [currentPage, searchTerm, projectId, sortBy, reloadStudies]);
+ .catch((error: unknown) => setErrorValue(error as Error));
+ }, [currentPage, searchTermRef, projectIdRef, sortByRef, reloadStudies]);
- return { rows, count, intervalSize, currentPage, setPage: setCurrentPage, error };
+ return { rows, count, intervalSize, currentPage, setPage: setCurrentPage, error: errorValue };
};
diff --git a/src/mocks/data/list/user.mocks.ts b/src/mocks/data/list/user.mocks.ts
index 7128087..15874c4 100644
--- a/src/mocks/data/list/user.mocks.ts
+++ b/src/mocks/data/list/user.mocks.ts
@@ -6,9 +6,9 @@
import { randomNumber } from '@/mocks/mockTools';
import { LIST_FIRSTNAME, LIST_NAME } from './names';
-import { User } from '@/shared/types/common/User.type';
+import { UserInfo } from '@/shared/types/common/User.type';
-export const generateFixedUser = (userIdx: number, seed = 1): User => {
+export const generateFixedUser = (userIdx: number, seed = 1): UserInfo => {
const firstName = LIST_FIRSTNAME[userIdx % LIST_FIRSTNAME.length];
const name = LIST_NAME[(userIdx + seed) % LIST_FIRSTNAME.length];
return {
diff --git a/src/pages/pegase/projects/projectDetails/ProjectDetails.tsx b/src/pages/pegase/projects/projectDetails/ProjectDetails.tsx
index 58a3a78..97c9077 100644
--- a/src/pages/pegase/projects/projectDetails/ProjectDetails.tsx
+++ b/src/pages/pegase/projects/projectDetails/ProjectDetails.tsx
@@ -37,12 +37,12 @@ const ProjectDetails = () => {
const [projectInfo, setProjectDetails] = useState({} as ProjectInfo);
const location = useLocation();
- const { projectId } = location.state || {};
+ const projectId = location.state?.projectId as string | null;
useEffect(() => {
- const getProjectDetails = async (projectId: string) => {
+ const getProjectDetails = async (id: string) => {
try {
- const data = await fetchProjectDetails(projectId);
+ const data = (await fetchProjectDetails(id)) as ProjectInfo;
setProjectDetails({
id: data.id,
@@ -57,7 +57,7 @@ const ProjectDetails = () => {
studies: [],
});
} catch (error) {
- console.error(`Error retrieving project details: ${projectId}`, error);
+ console.error(`Error retrieving project details: ${id}`, error);
}
};
if (projectId && !projectInfo.id) {
diff --git a/src/pages/pegase/settings/Settings.tsx b/src/pages/pegase/settings/Settings.tsx
index b1666e2..ea521a0 100644
--- a/src/pages/pegase/settings/Settings.tsx
+++ b/src/pages/pegase/settings/Settings.tsx
@@ -4,15 +4,15 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { UserContext } from '@/store/contexts/UserContext';
+import { UserSettingsContext } from '@/store/contexts/UserSettingsContext.tsx';
import { THEME_COLOR } from '@/shared/types';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
import { RdsSwitch } from 'rte-design-system-react';
const Settings = () => {
- const themeColor = UserContext.useStore((store) => store.theme);
- const setContext = UserContext.useSetStore();
+ const themeColor = UserSettingsContext.useStore((store) => store.theme);
+ const setContext = UserSettingsContext.useSetStore();
const { i18n } = useTranslation();
const changeLanguageHandler = (lang: string) => {
void i18n.changeLanguage(lang);
diff --git a/src/pages/pegase/studies/KeywordsInput.tsx b/src/pages/pegase/studies/KeywordsInput.tsx
index ec733dd..1111a02 100644
--- a/src/pages/pegase/studies/KeywordsInput.tsx
+++ b/src/pages/pegase/studies/KeywordsInput.tsx
@@ -4,7 +4,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { useState } from 'react';
+import { Dispatch, SetStateAction, useState } from 'react';
import { RdsButton, RdsIcon, RdsIconId, RdsInputText } from 'rte-design-system-react';
import { fetchSuggestedKeywords } from '@/shared/services/studyService';
import { clsx } from 'clsx';
@@ -12,21 +12,21 @@ import { useTranslation } from 'react-i18next';
interface KeywordsInputProps {
keywords: string[];
- setKeywords: React.Dispatch>;
+ setKeywords: Dispatch>;
maxNbKeywords?: number;
maxNbCharacters?: number;
minNbCharacters?: number;
width?: string;
}
-const KeywordsInput: React.FC = ({
+const KeywordsInput = ({
keywords,
setKeywords,
maxNbKeywords,
maxNbCharacters,
minNbCharacters,
width,
-}) => {
+}: KeywordsInputProps) => {
const { t } = useTranslation();
const [keywordInput, setKeywordInput] = useState('');
const [errorMessage, setErrorMessage] = useState('');
@@ -39,7 +39,7 @@ const KeywordsInput: React.FC = ({
setKeywordInput(value);
setErrorMessage(''); // Clear error message when input changes
try {
- const tags = await fetchSuggestedKeywords(value);
+ const tags = (await fetchSuggestedKeywords(value)) as string[];
setSuggestedKeywords(tags);
} catch (error) {
setErrorMessage('Failed to fetch suggested keywords');
@@ -47,7 +47,7 @@ const KeywordsInput: React.FC = ({
};
const handleAddKeyword = (suggestedKeyword = keywordInput) => {
- if (suggestedKeyword.trim()) {
+ if (keywords?.length > 0 && suggestedKeyword.trim()) {
if (keywords.includes(suggestedKeyword.trim())) {
setErrorMessage(t('projectModal.@keyword_already_exists'));
} else if (
@@ -84,7 +84,7 @@ const KeywordsInput: React.FC = ({
if (!input) {
return false;
} else {
- if (minNbCharacters) {
+ if (minNbCharacters && !maxNbCharacters) {
return input.length >= minNbCharacters;
} else if (minNbCharacters && maxNbCharacters) {
return input.length >= minNbCharacters && input.length <= maxNbCharacters;
@@ -104,7 +104,7 @@ const KeywordsInput: React.FC = ({
onChange={handleKeywordChange}
placeHolder="Add a keyword"
variant="outlined"
- maxLength={maxNbCharacters as number | undefined}
+ maxLength={maxNbCharacters}
/>
{shouldAddKeywordButton(keywordInput) && (
@@ -164,7 +164,7 @@ const KeywordsInput: React.FC = ({
{/* Clear All Keywords Button */}
- {keywords.length > 0 && (
+ {keywords?.length > 0 && (
Clear all
diff --git a/src/pages/pegase/studies/ProjectInput.tsx b/src/pages/pegase/studies/ProjectInput.tsx
index e7daa89..5965980 100644
--- a/src/pages/pegase/studies/ProjectInput.tsx
+++ b/src/pages/pegase/studies/ProjectInput.tsx
@@ -22,7 +22,7 @@ const ProjectInput: React.FC
= ({ value, onChange }) => {
useEffect(() => {
const loadProjects = async () => {
try {
- const projectList = await fetchProjectsFromPartialName(value);
+ const projectList = (await fetchProjectsFromPartialName(value)) as string[];
setProjects(projectList);
} catch (error) {
setErrorMessage('Failed to fetch projects');
@@ -30,7 +30,7 @@ const ProjectInput: React.FC = ({ value, onChange }) => {
};
if (value) {
- loadProjects();
+ void loadProjects();
} else {
setProjects([]);
}
diff --git a/src/shared/const/authConfig.ts b/src/shared/const/authConfig.ts
new file mode 100644
index 0000000..3bff489
--- /dev/null
+++ b/src/shared/const/authConfig.ts
@@ -0,0 +1,23 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { getEnvVariables } from '@/envVariables.ts';
+
+interface AuthConfig {
+ authority: string;
+ client_id: string;
+ redirect_uri: string;
+ scope: string;
+ maxExpiresIn: number;
+}
+
+export const config: AuthConfig = {
+ client_id: getEnvVariables('VITE_OAUTH2_CLIENT_ID'),
+ redirect_uri: getEnvVariables('VITE_OAUTH2_REDIRECT_URL'),
+ authority: getEnvVariables('VITE_OAUTH2_AUTHORITY'),
+ scope: 'openid email profile',
+ maxExpiresIn: 600,
+};
diff --git a/src/shared/services/authService.ts b/src/shared/services/authService.ts
new file mode 100644
index 0000000..24f9cce
--- /dev/null
+++ b/src/shared/services/authService.ts
@@ -0,0 +1,40 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { User, UserManager } from 'oidc-client-ts';
+import { config } from '@/shared/const/authConfig';
+
+const userManager = new UserManager(config);
+
+export const AuthService = {
+ login: async () => await userManager.signinRedirect(),
+ refresh: () => userManager.signinSilent(),
+ logout: () => userManager.signoutRedirect(),
+ getUser: async (): Promise => await userManager.getUser(),
+ handleCallback: () => userManager.signinRedirectCallback(),
+
+ getAccessToken: async (): Promise => {
+ const user = await userManager.getUser();
+ return user?.access_token || null;
+ },
+
+ authFetch: async (url: string, options: RequestInit = {}): Promise => {
+ const token = await AuthService.getAccessToken();
+ if (token) {
+ if (options.headers instanceof Headers) {
+ options.headers.append('Authorization', `Bearer ${token}`);
+ } else if (Array.isArray(options.headers)) {
+ options.headers.push(['Authorization', `Bearer ${token}`]);
+ } else {
+ options.headers = {
+ ...options.headers,
+ Authorization: `Bearer ${token}`,
+ };
+ }
+ }
+ return await fetch(url, options);
+ },
+};
diff --git a/src/shared/services/pinnedProjectService.ts b/src/shared/services/pinnedProjectService.ts
index 3355b55..ca09bac 100644
--- a/src/shared/services/pinnedProjectService.ts
+++ b/src/shared/services/pinnedProjectService.ts
@@ -6,6 +6,7 @@
import { PROJECT_PIN_ENDPOINT, PROJECT_PINNED_ENDPOINT, PROJECT_UNPIN_ENDPOINT } from '@/shared/const/apiEndPoint';
import { ProjectInfo } from '@/shared/types/pegase/Project.type';
+import { AuthService } from '@/shared/services/authService.ts';
/**
* Retrieve pinned projects list by user id
@@ -13,21 +14,21 @@ import { ProjectInfo } from '@/shared/types/pegase/Project.type';
* @param {string} userId - User id
* @returns {Promise} - Promise object that represents a list of projects
*/
-export const fetchPinnedProjects = async (userId: string) => {
+export const fetchPinnedProjects = async (userId: string): Promise => {
const apiUrl = `${PROJECT_PINNED_ENDPOINT}?userId=${userId}`;
- const response = await fetch(apiUrl);
+ const response = await AuthService.authFetch(apiUrl);
if (!response?.ok) {
throw new Error('Failed to fetch project details');
}
- const json = await response.json();
+ const json = (await response.json()) as Partial[];
return json.map((project: Partial) => ({
...project,
projectId: project.id?.toString(),
pinned: project.pinned ?? true,
- }));
+ })) as ProjectInfo[];
};
/**
@@ -39,11 +40,11 @@ export const fetchPinnedProjects = async (userId: string) => {
* @return {Promise} - Object that describes a project
*/
-export const pinProject = async (projectId: string) => {
+export const pinProject = async (projectId: string): Promise => {
const userId = 'me00247';
const apiUrl = `${PROJECT_PIN_ENDPOINT}?userId=${userId}&projectId=${projectId}`;
- const response = await fetch(apiUrl, {
+ const response = await AuthService.authFetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -55,7 +56,7 @@ export const pinProject = async (projectId: string) => {
throw new Error(`${errorText}`);
}
- return await response.json();
+ return (await response.json()) as ProjectInfo;
};
/**
@@ -67,7 +68,7 @@ export const pinProject = async (projectId: string) => {
export const unpinProject = async (userId: string, projectId: string) => {
const apiUrl = `${PROJECT_UNPIN_ENDPOINT}?userId=${userId}&projectId=${projectId}`;
- const response = await fetch(apiUrl, {
+ const response = await AuthService.authFetch(apiUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
diff --git a/src/shared/services/projectService.ts b/src/shared/services/projectService.ts
index 10c4b79..2433782 100644
--- a/src/shared/services/projectService.ts
+++ b/src/shared/services/projectService.ts
@@ -6,9 +6,17 @@
import { PROJECT_AUTOCOMPLETE_ENDPOINT, PROJECT_ENDPOINT, PROJECT_SEARCH_ENDPOINT } from '@/shared/const/apiEndPoint';
import { ProjectInfo, ProjectResponse } from '@/shared/types/pegase/Project.type';
+import { AuthService } from '@/shared/services/authService.ts';
+import { PaginatedResponse } from '@/shared/types';
-export const deleteProjectById = async (projectId: string) => {
- const response = await fetch(`${PROJECT_ENDPOINT}/${projectId}`, {
+/**
+ * Delete project
+ *
+ * @param {string} projectId
+ * @return {Promise}
+ */
+export const deleteProjectById = async (projectId: string): Promise => {
+ const response = await AuthService.authFetch(`${PROJECT_ENDPOINT}/${projectId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
@@ -17,7 +25,7 @@ export const deleteProjectById = async (projectId: string) => {
if (!response.ok) {
const errorText = await response.text();
- const errorData = JSON.parse(errorText);
+ const errorData = JSON.parse(errorText) as Error;
throw new Error(`${errorData.message || errorText}`);
}
};
@@ -26,30 +34,30 @@ export const deleteProjectById = async (projectId: string) => {
* Retrieve details of a project
*
* @param {string} projectId - Project id
- * @return {Promise} - Project details
+ * @return {Promise} - Project details
*/
-export const fetchProjectDetails = async (projectId: string) => {
- const response = await fetch(`${PROJECT_ENDPOINT}/${projectId}`);
+export const fetchProjectDetails = async (projectId: string): Promise => {
+ const response = await AuthService.authFetch(`${PROJECT_ENDPOINT}/${projectId}`);
if (!response?.ok) {
throw new Error('Failed to fetch project details');
}
- return await response.json();
+ return (await response.json()) as ProjectInfo;
};
/**
* Retrieve a project from a partial name of project
*
* @param {string} query - Partial name of a project
- * @return {Promise} - List of project name
+ * @return {Promise} - List of project name
*/
-export const fetchProjectsFromPartialName = async (query: string): Promise => {
- const response = await fetch(`${PROJECT_AUTOCOMPLETE_ENDPOINT}?partialName=${query}`);
+export const fetchProjectsFromPartialName = async (query: string): Promise => {
+ const response = await AuthService.authFetch(`${PROJECT_AUTOCOMPLETE_ENDPOINT}?partialName=${query}`);
if (!response.ok) {
throw new Error('Failed to fetch projects');
}
- const data = await response.json();
+ const data = (await response.json()) as ProjectInfo[];
return data.map((project: { name: string }) => project.name);
};
@@ -59,13 +67,24 @@ export const fetchProjectsFromPartialName = async (query: string): Promise | Error>}
*/
-export const fetchProjectFromSearchTerm = async (searchTerm: string, current: number, intervalSize: number) => {
- const response = await fetch(
+export const fetchProjectFromSearchTerm = async (
+ searchTerm: string,
+ current: number,
+ intervalSize: number,
+): Promise | Error> => {
+ const response = await AuthService.authFetch(
`${PROJECT_SEARCH_ENDPOINT}?page=${current + 1}&size=${intervalSize}&search=${searchTerm || ''}`,
);
- return await response.json();
+ if (!response.ok) {
+ const errorText = await response.text();
+ const errorData = JSON.parse(errorText) as Error;
+ throw new Error(`${errorData.message}`);
+ }
+
+ return (await response.json()) as PaginatedResponse;
};
/**
@@ -79,7 +98,7 @@ export const createProject = async (
): Promise => {
const apiUrl = `${PROJECT_ENDPOINT}`;
- const response = await fetch(apiUrl, {
+ const response = await AuthService.authFetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -89,8 +108,8 @@ export const createProject = async (
if (!response.ok) {
const errorText = await response.text();
- const errorData = JSON.parse(errorText);
+ const errorData = JSON.parse(errorText) as Error;
throw new Error(`${errorData.message}`);
}
- return await response.json();
+ return (await response.json()) as ProjectResponse;
};
diff --git a/src/shared/services/studyService.ts b/src/shared/services/studyService.ts
index ef00bd7..1cb3e09 100644
--- a/src/shared/services/studyService.ts
+++ b/src/shared/services/studyService.ts
@@ -8,6 +8,7 @@ import { PaginatedResponse, StudyDTO } from '@/shared/types';
import { STUDY_SEARCH_ENDPOINT } from '@/shared/const/apiEndPoint';
import { STUDY_ENDPOINT, STUDY_KEYWORDS_SEARCH_ENDPOINT } from '@/shared/const/apiEndPoint.ts';
import { notifyToast } from '@/shared/notification/notification.tsx';
+import { AuthService } from '@/shared/services/authService.ts';
/**
* Retrieve a list of studies from a term
@@ -18,15 +19,15 @@ import { notifyToast } from '@/shared/notification/notification.tsx';
* @param {number} intervalSize - Number of items per page
* @param {{ [key: string]: 'asc' | 'desc' })} sortBy - Object that describes the sorting type (ascending or descending) of a column
*
- * @returns {Promise>} - Promise object that represents a list of studies
+ * @return {Promise | Error>} - Promise object that represents a list of studies
*/
export const fetchSearchStudies = async (
- searchTerm = '',
- projectId = '',
- currentPage = 0,
- intervalSize = 0,
+ searchTerm: string = '',
+ projectId: string = '',
+ currentPage: number = 0,
+ intervalSize: number = 0,
sortBy?: { [key: string]: 'asc' | 'desc' },
-): Promise> => {
+): Promise | Error> => {
let entries: [string, 'asc' | 'desc'] | null = null;
if (sortBy && JSON.stringify(sortBy) !== '{}') {
entries = Object.entries(sortBy)[0];
@@ -34,11 +35,11 @@ export const fetchSearchStudies = async (
const apiUrl = `${STUDY_SEARCH_ENDPOINT}?page=${currentPage + 1}&size=${intervalSize}&projectId=${projectId}&search=${searchTerm}&sortColumn=${entries?.[0] ?? ''}&sortDirection=${entries?.[1] ?? ''}`;
- const response = await fetch(apiUrl);
+ const response = await AuthService.authFetch(apiUrl);
if (!response.ok) {
throw new Error('Failed to fetch user studies');
}
- const json: PaginatedResponse = await response.json();
+ const json = (await response.json()) as PaginatedResponse;
return { content: json.content, totalElements: json.totalElements };
};
@@ -47,15 +48,14 @@ export const fetchSearchStudies = async (
* Retrieve a list of suggested keywords from a partial name of a study
*
* @param {string} query - Partial name of a study
- *
- * @returns {Promise} - Promise object that represents a list of keywords
+ * @return {Promise} - Promise object that represents a list of keywords
*/
-export const fetchSuggestedKeywords = async (query: string): Promise => {
- const response = await fetch(`${STUDY_KEYWORDS_SEARCH_ENDPOINT}?partialName=${query}`);
+export const fetchSuggestedKeywords = async (query: string): Promise => {
+ const response = await AuthService.authFetch(`${STUDY_KEYWORDS_SEARCH_ENDPOINT}?partialName=${query}`);
if (!response.ok) {
throw new Error('Failed to fetch suggested keywords');
}
- return await response.json();
+ return (await response.json()) as string[];
};
/**
@@ -63,10 +63,11 @@ export const fetchSuggestedKeywords = async (query: string): Promise =
* Display toast if creation succeeds or fails
*
* @param {Omit} studyData - Partial study data
+ * @return {Promise}
*/
-export const saveStudy = async (studyData: Omit) => {
+export const saveStudy = async (studyData: Omit): Promise => {
try {
- const response = await fetch(`${STUDY_ENDPOINT}`, {
+ const response = await AuthService.authFetch(`${STUDY_ENDPOINT}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -75,7 +76,7 @@ export const saveStudy = async (studyData: Omit}
*/
-export const deleteStudy = async (id: number) => {
+export const deleteStudy = async (id: number): Promise => {
try {
- const response = await fetch(`${STUDY_ENDPOINT}/${id}`, {
+ const response = await AuthService.authFetch(`${STUDY_ENDPOINT}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
@@ -111,10 +113,10 @@ export const deleteStudy = async (id: number) => {
type: 'success',
message: 'Study deleted successfully',
});
- } catch (error: any) {
+ } catch (error: unknown) {
notifyToast({
type: 'error',
- message: `${error.message}`,
+ message: `${(error as Error).message}`,
});
}
};
diff --git a/src/shared/services/test/pinnedProjectService.test.tsx b/src/shared/services/test/pinnedProjectService.test.tsx
index 9a3026c..6f785e6 100644
--- a/src/shared/services/test/pinnedProjectService.test.tsx
+++ b/src/shared/services/test/pinnedProjectService.test.tsx
@@ -107,7 +107,7 @@ describe('fetchPinnedProjects', () => {
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(global.fetch).toHaveBeenCalledWith(`https://mockapi.com/v1/project/pinned?userId=${userId}`);
+ expect(global.fetch).toHaveBeenCalledWith(`https://mockapi.com/v1/project/pinned?userId=${userId}`, {});
expect(result).toEqual(mockResponse);
});
});
@@ -160,7 +160,7 @@ describe('unpinProject', () => {
// Failed fetch response moc
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
- text: async () => 'Error message',
+ text: async () => Promise.resolve('Error message'),
});
await expect(async () => unpinProject(userId, projectId)).rejects.toThrowError('Error message');
diff --git a/src/shared/services/test/projectService.test.tsx b/src/shared/services/test/projectService.test.tsx
index 8a49018..fffc07d 100644
--- a/src/shared/services/test/projectService.test.tsx
+++ b/src/shared/services/test/projectService.test.tsx
@@ -25,9 +25,7 @@ describe('deleteProjectById', () => {
global.fetch = vi.fn();
vi.clearAllMocks();
vi.stubGlobal('JSON', {
- parse: (text: string) => {
- return { message: text };
- },
+ parse: (text: string) => ({ message: text }),
stringify: (text: string) => text,
});
});
@@ -97,7 +95,7 @@ describe('fetchProjectDetails', () => {
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(global.fetch).toHaveBeenCalledWith(`https://mockapi.com/v1/project/${projectId}`);
+ expect(global.fetch).toHaveBeenCalledWith(`https://mockapi.com/v1/project/${projectId}`, {});
});
});
@@ -123,9 +121,7 @@ describe('fetchProjectsFromPartialName', () => {
global.fetch = vi.fn();
vi.clearAllMocks();
vi.stubGlobal('JSON', {
- parse: (text: string) => {
- return { message: text };
- },
+ parse: (text: string) => ({ message: text }),
stringify: (text: string) => text,
});
});
@@ -138,8 +134,8 @@ describe('fetchProjectsFromPartialName', () => {
it('should delete a pinned project from pinned project list', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
- json: () => {
- return Promise.resolve([
+ json: () =>
+ Promise.resolve([
{
id: '123',
name: 'Bilan prévisionnel 2023',
@@ -156,15 +152,14 @@ describe('fetchProjectsFromPartialName', () => {
creationDate: '2013-08-01',
tags: ['tag3', 'tag4'],
},
- ]);
- },
+ ]),
});
const result = await fetchProjectsFromPartialName('name');
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/project/autocomplete?partialName=name');
+ expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/project/autocomplete?partialName=name', {});
expect(result).toEqual(['Bilan prévisionnel 2023', 'Bilan prévisionnel 2019']);
});
});
@@ -188,9 +183,7 @@ describe('createProject', () => {
global.fetch = vi.fn();
vi.clearAllMocks();
vi.stubGlobal('JSON', {
- parse: (text: string) => {
- return { message: text };
- },
+ parse: (text: string) => ({ message: text }),
stringify: (text: string) => text,
});
});
diff --git a/src/shared/services/test/studyService.test.tsx b/src/shared/services/test/studyService.test.tsx
index 1a82ec1..fd0d00d 100644
--- a/src/shared/services/test/studyService.test.tsx
+++ b/src/shared/services/test/studyService.test.tsx
@@ -53,6 +53,7 @@ describe('fetchSearchStudies', () => {
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
`https://mockapi.com/v1/study/search?page=4&size=10&projectId=124&search=test&sortColumn=column&sortDirection=asc`,
+ {},
);
expect(result).toEqual(mockResponse);
});
@@ -90,7 +91,7 @@ describe('fetchSuggestedKeywords', () => {
const result = await fetchSuggestedKeywords('test');
expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/study/keywords/search?partialName=test');
+ expect(global.fetch).toHaveBeenCalledWith('https://mockapi.com/v1/study/keywords/search?partialName=test', {});
expect(result).toEqual(mockResponse);
});
diff --git a/src/shared/types/common/User.type.ts b/src/shared/types/common/User.type.ts
index c3af83e..b80b1af 100644
--- a/src/shared/types/common/User.type.ts
+++ b/src/shared/types/common/User.type.ts
@@ -3,11 +3,16 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
+import { User } from 'oidc-client-ts';
-export interface User {
+export interface UserInfo {
id: string;
nni: string;
fullname: string;
email: string;
isAdmin?: boolean;
}
+
+export interface UserState {
+ user: User | null;
+}
diff --git a/src/shared/utils/authUtils.ts b/src/shared/utils/authUtils.ts
new file mode 100644
index 0000000..e422c41
--- /dev/null
+++ b/src/shared/utils/authUtils.ts
@@ -0,0 +1,18 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { User } from 'oidc-client-ts';
+
+type ProfileWithRole = User & {
+ profile: {
+ realm_access: {
+ roles: string[];
+ };
+ };
+};
+
+export const hasUserRole = (role: string, user: ProfileWithRole): boolean =>
+ user?.profile.realm_access.roles?.includes(role);
diff --git a/src/store/contexts/ProjectContext.tsx b/src/store/contexts/ProjectContext.tsx
index c11b507..70b687f 100644
--- a/src/store/contexts/ProjectContext.tsx
+++ b/src/store/contexts/ProjectContext.tsx
@@ -8,9 +8,9 @@ import { createContext, Dispatch, ReactNode, useContext, useReducer } from 'reac
import { ProjectActionType, ProjectState } from '@/shared/types/pegase/Project.type';
import projectReducer from '@/store/reducers/projectReducer';
-const initialValue: ProjectState = { projects: [], pinnedProjects: [] };
+const initialState: ProjectState = { projects: [], pinnedProjects: [] };
-export const ProjectContext = createContext(initialValue);
+export const ProjectContext = createContext(initialState);
export const ProjectDispatchContext = createContext | null>(null);
export const useProject = () => useContext(ProjectContext);
diff --git a/src/store/contexts/UserContext.tsx b/src/store/contexts/UserContext.tsx
index 04687ea..e4e5e41 100644
--- a/src/store/contexts/UserContext.tsx
+++ b/src/store/contexts/UserContext.tsx
@@ -4,11 +4,11 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { THEME_COLOR } from '@/shared/types';
-import createFastContext from './createFastContext';
+import { createContext, useContext } from 'react';
+import { UserState } from '@/shared/types';
-export type UserContextStore = {
- theme: THEME_COLOR;
+const initialState: UserState = {
+ user: null,
};
-
-export const UserContext = createFastContext();
+export const UserContext = createContext(initialState);
+export const useUser = () => useContext(UserContext);
diff --git a/src/store/contexts/UserProvider.tsx b/src/store/contexts/UserProvider.tsx
new file mode 100644
index 0000000..0f12fd5
--- /dev/null
+++ b/src/store/contexts/UserProvider.tsx
@@ -0,0 +1,41 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { ReactNode, useEffect, useState } from 'react';
+import { UserState } from '@/shared/types';
+import { AuthService } from '@/shared/services/authService';
+import { UserContext } from './UserContext';
+
+export interface UserProviderProps {
+ children: ReactNode;
+ initialValue: UserState;
+}
+
+const UserProvider = ({ children, initialValue }: UserProviderProps) => {
+ const [user, setUser] = useState(initialValue);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const getUser = async () => {
+ const userInfo = await AuthService.getUser();
+ if (!userInfo) {
+ // Redirection automatique vers Keycloak pour l'authentification
+ await AuthService.login();
+ } else {
+ setUser({ user: userInfo });
+ setLoading(false);
+ }
+ };
+ void getUser();
+ }, []);
+
+ if (loading) {
+ return Loading...
; // Affiche un message de chargement pendant la vérification
+ }
+ return {children};
+};
+
+export default UserProvider;
diff --git a/src/store/contexts/UserSettingsContext.tsx b/src/store/contexts/UserSettingsContext.tsx
new file mode 100644
index 0000000..29fbc72
--- /dev/null
+++ b/src/store/contexts/UserSettingsContext.tsx
@@ -0,0 +1,14 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { THEME_COLOR } from '@/shared/types';
+import createFastContext from './createFastContext';
+
+export type UserSettingsContextStore = {
+ theme: THEME_COLOR;
+};
+
+export const UserSettingsContext = createFastContext();
diff --git a/vite-env.d.ts b/vite-env.d.ts
index bbe913a..9729546 100644
--- a/vite-env.d.ts
+++ b/vite-env.d.ts
@@ -1 +1,7 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
declare const APP_VERSION: string;