diff --git a/src/API/common.ts b/src/API/common.ts index 3ad575d..ca0abe6 100644 --- a/src/API/common.ts +++ b/src/API/common.ts @@ -1,187 +1,122 @@ -import {useEffect, useMemo, useState} from "react"; -import {DEBUG_MODE} from "../utils/common"; -import {ApiResponse, PageResponse} from "../types/api.ts"; -import {PageControl} from "../types/common.ts"; -import {Institution} from "../types/survey.ts"; - -export function hostUrl(url: string){ - return DEBUG_MODE? "http://localhost:8000"+url: url; -} - -export function apiUrl(url: string) { - return hostUrl("/api/v1"+url); -} +import {apiUrl, getTokensFromCookies} from "../utils/common.ts"; +import {ApiResponse} from "../types/api.ts"; -export async function getFetch(path:string) { +export async function getFetch(path: string): Promise { + const finalTokens = getTokensFromCookies(); - try{ - const response = await fetch( - apiUrl(path), { - method: "GET", - credentials: "include" - } - ); + try { + const response = await fetch(apiUrl(path), { + method: 'GET', + headers: { + ...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }), + }, + credentials: "include" + }); - if (!response.ok){ + if (!response.ok) { const body = await response.json(); - console.error('Error fetching:', 'Network response was not ok', body); - - return { - body: body, - success: false - } as ApiResponse; - - } - return { - body: await response.json(), - success: true, - } as ApiResponse - } - - catch { - return { - success: false - } as ApiResponse; - } - - - -} - - -const createQueryString = (params: Record): string => { - const filteredParams = Object.entries(params) - .filter(([_, value]) => value !== undefined && value !== null) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - return filteredParams.join('&'); -}; - -export function useDataPage(application:string, endpoint: string, params:{[key:string]:string|number}={}, needLoad:boolean=true):PageControl { - const [data, setData] = useState(null); - // const [previousUrl, setPreviousUrl] = useState(undefined) - // const [nextUrl, setNextUrl] = useState(undefined) - - - - - useEffect( - ()=>{ - if (data === null && needLoad){ - getFetch(`/${application}/${endpoint}/?${createQueryString(params)}`).then((r)=>{ - if (r.success){ - const pageData = r.body as PageResponse; - - setData(pageData.results); - - - } - else { - setData([]); - } - }) - } - }, - [data, application, endpoint, needLoad] - ) - - // function changePage(pageUrl: string|undefined) { - // if (pageUrl){ //TODO CHANGING PAGES - // getFetch(`/${application}/${endpoint}/`).then((r)=>{ - // if (r.success){ - // const pageData = r.body as PageResponse; - // - // setData(pageData.results); - // - // - // } - // }) - // } - // } - - function nextPage() { - // changePage(nextUrl) - } - - function previousPage() { - // changePage(previousUrl) - } - - - return { - items: data, - hasPrevious: false,//previousUrl !== undefined, - hasNext: false,//nextUrl !== undefined, - nextPage, - previousPage - } as PageControl - - -} - -export function useData(application:string, endpoint: string, id: string|number, needLoad:boolean=true): T|null|undefined { - - const [data, setData] = useState(null); - - useEffect( - ()=>{ - if (data === null && needLoad){ - getFetch(`/${application}/${endpoint}/${id}`).then((r)=>{ - if (r.success){ - const pageData = r.body as T; - setData(pageData); - } - else { - setData(undefined); - } - }) - } - }, - [data, application, endpoint, id, needLoad] - ) - - return data; - -} - -export function useCachedData(application: string, endpoint: string) { - const [cache, setCache] = useState>(new Map()); - - useEffect(() => { - // Очистка кеша или другие побочные эффекты при необходимости - return () => { - setCache(new Map()); - }; - }, []); - - return (id: string | number, needLoad: boolean = true): T | undefined | null => { - const cachedData = cache.has(id)?cache.get(id):null; - - if (cachedData !== null) { - return cachedData; + return { body, success: false }; } - if (needLoad) { - getFetch(`/${application}/${endpoint}/${id}`).then((r) => { - if (r.success) { - const data = r.body as T; - setCache((prevCache) => { - const newCache = new Map(prevCache); - newCache.set(id, data); - return newCache; - }); - } else { - setCache((prevCache) => { - const newCache = new Map(prevCache); - newCache.set(id, undefined); - return newCache; - }); - } - }); - } - - return null; - }; + return { body: await response.json(), success: true }; + } catch (error) { + console.error('Error fetching:', error); + return { success: false }; + } } +export async function postFetch( + path: string, + body: object | FormData +): Promise { + const finalTokens = getTokensFromCookies(); + const isFormData = body instanceof FormData; + + try { + + const headers = new Headers(); + + if (finalTokens?.access){ + headers.append("Authorization", `Bearer ${finalTokens.access}`) + } + if (!isFormData){ + headers.append('Content-Type','application/json') + } + const response = await fetch(apiUrl(path), { + method: 'POST', + headers: headers, + body: isFormData ? body as FormData : JSON.stringify(body), + credentials: 'include' + }); + + if (!response.ok) { + const responseBody = await response.json(); + console.error('Error posting:', 'Network response was not ok', responseBody); + return { body: responseBody, success: false }; + } + + return { body: await response.json(), success: true }; + } catch (error) { + console.error('Error posting:', error); + return { success: false }; + } +} + +export async function patchFetch( + path: string, + body: object | FormData +): Promise { + const finalTokens = getTokensFromCookies(); + const isFormData = body instanceof FormData; + + try { + const response = await fetch(apiUrl(path), { + method: 'PATCH', + headers: { + ...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }), + ...(!isFormData && { 'Content-Type': 'application/json' }), + }, + body: isFormData ? body as FormData : JSON.stringify(body), + credentials: 'include', + }); + + if (!response.ok) { + const responseBody = await response.json(); + console.error('Error patching:', 'Network response was not ok', responseBody); + return { body: responseBody, success: false }; + } + + return { body: await response.json(), success: true }; + } catch (error) { + console.error('Error patching:', error); + return { success: false }; + } +} + + +export async function deleteFetch(path: string): Promise { + const finalTokens = getTokensFromCookies(); + + try { + const response = await fetch(apiUrl(path), { + method: 'DELETE', + headers: { + ...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }), + }, + credentials: 'include', + }); + + if (!response.ok) { + const responseBody = await response.json(); + console.error('Error deleting:', 'Network response was not ok', responseBody); + return { body: responseBody, success: false }; + } + + return { body: await response.json(), success: true }; + } catch (error) { + console.error('Error deleting:', error); + return { success: false }; + } +} diff --git a/src/API/hooks.ts b/src/API/hooks.ts new file mode 100644 index 0000000..482ee6a --- /dev/null +++ b/src/API/hooks.ts @@ -0,0 +1,139 @@ +import {PageControl} from "../types/common.ts"; +import {useEffect, useState} from "react"; +import {ApiResponse, PageResponse} from "../types/api.ts"; +import {getFetch} from "./common.ts"; +import {createQueryString} from "../utils/common.ts"; + +export function useDataPage(application:string, endpoint: string, params:{[key:string]:string|number}={}, needLoad:boolean=true):PageControl { + const [data, setData] = useState(null); + // const [previousUrl, setPreviousUrl] = useState(undefined) + // const [nextUrl, setNextUrl] = useState(undefined) + + + + + useEffect( + ()=>{ + if (data === null && needLoad){ + getFetch(`/${application}/${endpoint}/?${createQueryString(params)}`).then((r)=>{ + if (r.success){ + const pageData = (r as ApiResponse).body as PageResponse; + + setData(pageData.results); + + + } + else { + setData([]); + } + }) + } + }, + [data, application, endpoint, needLoad] + ) + + // function changePage(pageUrl: string|undefined) { + // if (pageUrl){ //TODO CHANGING PAGES + // getFetch(`/${application}/${endpoint}/`).then((r)=>{ + // if (r.success){ + // const pageData = r.body as PageResponse; + // + // setData(pageData.results); + // + // + // } + // }) + // } + // } + + function nextPage() { + // changePage(nextUrl) + } + + function previousPage() { + // changePage(previousUrl) + } + + + return { + items: data, + hasPrevious: false,//previousUrl !== undefined, + hasNext: false,//nextUrl !== undefined, + nextPage, + previousPage + } as PageControl + + +} + +export function useData(application:string, endpoint: string, id: string|number, needLoad:boolean=true): T|null|undefined { + + const [data, setData] = useState(null); + + useEffect( + ()=>{ + if (data === null && needLoad){ + getFetch(`/${application}/${endpoint}/${id}`).then((r)=>{ + if (r.success){ + const pageData = r.body as T; + setData(pageData); + } + else { + setData(undefined); + } + }) + } + }, + [data, application, endpoint, id, needLoad] + ) + + return data; + +} + +interface CachedData{ + get(id: string | number, needLoad: boolean): T|undefined|null +} + +export function useCachedData(application: string, endpoint: string): CachedData { + const [cache, setCache] = useState>(new Map()); + + useEffect(() => { + // Очистка кеша или другие побочные эффекты при необходимости + return () => { + setCache(new Map()); + }; + }, []); + + return { + get: (id: string | number, needLoad: boolean = true): T|undefined|null => { + const cachedData = cache.has(id)?cache.get(id):null; + + if (cachedData !== null) { + return cachedData; + } + + if (needLoad) { + getFetch(`/${application}/${endpoint}/${id}`).then((r) => { + if (r.success) { + const data = r.body as T; + setCache((prevCache) => { + const newCache = new Map(prevCache); + newCache.set(id, data); + return newCache; + }); + } else { + setCache((prevCache) => { + const newCache = new Map(prevCache); + newCache.set(id, undefined); + return newCache; + }); + } + }); + } + + return null; + } + } +} + diff --git a/src/API/users.ts b/src/API/users.ts new file mode 100644 index 0000000..5ebdbef --- /dev/null +++ b/src/API/users.ts @@ -0,0 +1,46 @@ + +import { TokenObtainPair, TokenRefresh, TokenVerify, JwtTokenResponse } from '../types/users'; +import {apiUrl} from "../utils/common.ts"; + + +export async function login(data: TokenObtainPair): Promise { + const response = await fetch(apiUrl('/users/auth/jwt/create/'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (response.ok) { + return response.json(); + } + return null; +} + +export async function refreshToken(data: TokenRefresh): Promise { + const response = await fetch(apiUrl('/users/auth/jwt/refresh/'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (response.ok) { + return response.json(); + } + return null; +} + +export async function verifyToken(data: TokenVerify): Promise { + const response = await fetch(apiUrl('/users/auth/jwt/verify/'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + return response.ok; +} diff --git a/src/components/LoadingData.tsx b/src/components/LoadingData.tsx index e9c544a..de2aa96 100644 --- a/src/components/LoadingData.tsx +++ b/src/components/LoadingData.tsx @@ -7,7 +7,7 @@ export function LoadingData({data, children}: {data: object|null|undefined, chil return
Идёт загрузка...
} if (data === undefined){ - return 404 НИЧЕГО НЕ НАЙДЕНО + return 404 НЕ НАЙДЕНО } return children; diff --git a/src/components/LoadingList.tsx b/src/components/LoadingList.tsx index f1d9986..24bfa63 100644 --- a/src/components/LoadingList.tsx +++ b/src/components/LoadingList.tsx @@ -1,5 +1,4 @@ import {UniqueItem} from "../types/common.ts"; -import {JSX} from "react"; export function LoadingList({data, listElement}: {data: UniqueItem[]|null, listElement(e:UniqueItem, i:number):unknown}) { if (data === null){ diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 8d45150..49b5228 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,3 +1,66 @@ +import {useEffect, useState} from 'react'; +import {useUser} from "../utils/users/UseUser.ts"; +import {useNavigate} from "react-router-dom"; + export function LoginPage() { - return null; -} + const userContext = useUser(); + const user = userContext?.user; + const login = userContext?.login; + // Локальные стейты для формы + const [loginInput, setLoginInput] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + + const navigate = useNavigate(); + + useEffect(() => { + if (user != undefined){ + navigate("/dashboard/") + } + }, [user, navigate]); + + // Обработчик отправки формы + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (login && loginInput && password) { + try { + await login({ login: loginInput, password }); + setError(null); + } catch { + setError('Ошибка входа'); + } + } + }; + + return ( +
+
+

Войти в систему

+
+ + setLoginInput(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&
{error}
} + +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 011faad..789113e 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Link } from 'react-router-dom'; import '../css/MainPage.css'; -import {useDataPage} from "../API/common.ts"; import {Survey} from "../types/survey.ts"; import {LoadingList} from "../components/LoadingList.tsx"; +import {useDataPage} from "../API/hooks.ts"; export function MainPage() { diff --git a/src/pages/ProfessionPage.tsx b/src/pages/ProfessionPage.tsx index ff0c2a1..787cdf9 100644 --- a/src/pages/ProfessionPage.tsx +++ b/src/pages/ProfessionPage.tsx @@ -1,9 +1,9 @@ import {Link, useParams} from 'react-router-dom'; -import {useCachedData, useData, useDataPage} from '../API/common'; import { Institution, Profession, Specialty } from '../types/survey'; import ReactMarkdown from 'react-markdown'; import { LoadingData } from '../components/LoadingData'; import { LoadingList } from '../components/LoadingList'; +import {useCachedData, useData, useDataPage} from "../API/hooks.ts"; @@ -29,7 +29,7 @@ export function ProfessionPage() { )} /> diff --git a/src/pages/SurveyPage.tsx b/src/pages/SurveyPage.tsx index 60d2e12..2ce821d 100644 --- a/src/pages/SurveyPage.tsx +++ b/src/pages/SurveyPage.tsx @@ -1,16 +1,16 @@ import React, { useState, useEffect } from "react"; -import { Navigate, useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { Survey, Question, QuestionPair, AgreementQuestion, QuestionType, - ScoreVariable, + } from "../types/survey"; -import { useData, useDataPage } from "../API/common"; import { LoadingData } from "../components/LoadingData"; import { LoadingList } from "../components/LoadingList"; +import {useData, useDataPage} from "../API/hooks.ts"; export function SurveyPage() { const { surveyId } = useParams<{ surveyId: string }>(); diff --git a/src/pages/SurveyResultPage.tsx b/src/pages/SurveyResultPage.tsx index d397764..9affb35 100644 --- a/src/pages/SurveyResultPage.tsx +++ b/src/pages/SurveyResultPage.tsx @@ -1,9 +1,9 @@ import { Link, useParams } from 'react-router-dom'; -import { useData, useDataPage } from '../API/common'; import { Profession, ScoreVariable, Survey } from '../types/survey'; import ReactMarkdown from 'react-markdown'; import { LoadingData } from '../components/LoadingData'; import { LoadingList } from '../components/LoadingList'; +import {useData, useDataPage} from "../API/hooks.ts"; export function SurveyResultPage() { const { scoreVariableId } = useParams<{ scoreVariableId: string }>(); diff --git a/src/types/common.ts b/src/types/common.ts index 2631507..031906b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -11,6 +11,3 @@ export interface PageControl{ previousPage(): void; } -export interface CachedData{ - -} diff --git a/src/types/users.ts b/src/types/users.ts index 7f45343..4c8b327 100644 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -1,5 +1,10 @@ import {UniqueItem} from "./common.ts"; +export type UserContextType = { + user: Account | undefined; + login: (credentials: TokenObtainPair) => Promise; + logout: () => void; +}; export interface ContactData{ first_name: string; @@ -19,4 +24,23 @@ export interface Account extends UniqueItem, ContactData{ is_staff: boolean; is_active: boolean; +} + + +export interface TokenObtainPair { + login: string; + password: string; +} + +export interface TokenRefresh { + refresh: string; +} + +export interface TokenVerify { + token: string; +} + +export interface JwtTokenResponse { + access: string; + refresh: string; } \ No newline at end of file diff --git a/src/utils/checkdebug.ts b/src/utils/checkdebug.ts new file mode 100644 index 0000000..0f1b60a --- /dev/null +++ b/src/utils/checkdebug.ts @@ -0,0 +1,5 @@ +/* +`true` if debug mode enabled and `false` otherwise + */ + +export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development"; \ No newline at end of file diff --git a/src/utils/common.ts b/src/utils/common.ts index cddabf9..0b85e6d 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,4 +1,36 @@ -/* -`true` if debug mode enabled and `false` otherwise - */ -export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development"; \ No newline at end of file + + +import {JwtTokenResponse} from "../types/users.ts"; +import {DEBUG_MODE} from "./checkdebug.ts"; + +export function getTokensFromCookies(): JwtTokenResponse | undefined { + const cookies = document.cookie.split('; ').reduce((acc, cookie) => { + const [key, value] = cookie.split('='); + acc[key] = decodeURIComponent(value); + return acc; + }, {} as Record); + + const access = cookies['access_token']; + const refresh = cookies['refresh_token']; + + if (access && refresh) { + return { access, refresh }; + } + return undefined; +} + +export const createQueryString = (params: Record): string => { + const filteredParams = Object.entries(params) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + return filteredParams.join('&'); +}; + +export function hostUrl(url: string){ + return DEBUG_MODE? "http://localhost:8000"+url: url; +} + +export function apiUrl(url: string) { + return hostUrl("/api/v1"+url); +} \ No newline at end of file diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts new file mode 100644 index 0000000..2e1d203 --- /dev/null +++ b/src/utils/cookies.ts @@ -0,0 +1,16 @@ +export function setCookie(name: string, value: string, days: number) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; +} + +export function getCookie(name: string) { + return document.cookie.split('; ') + .reduce((prev: string | null, current: string) => { + const [key, value] = current.split('='); + return key === name ? decodeURIComponent(value) : prev; + }, null); +} + +export function deleteCookie(name: string) { + setCookie(name, '', -1); +} \ No newline at end of file diff --git a/src/utils/users/UseUser.ts b/src/utils/users/UseUser.ts index 22492a8..e918c88 100644 --- a/src/utils/users/UseUser.ts +++ b/src/utils/users/UseUser.ts @@ -1,13 +1,15 @@ import {UserContext} from "./UserContext.ts"; import {useContext} from "react"; +import {UserContextType} from "../../types/users.ts"; -export function useUser() { +export function useUser(): undefined| UserContextType { const context = useContext(UserContext); if (!context) { - throw new Error('useUser must be used within a UserProvider'); + console.error('useUser must be used within a UserProvider'); + return undefined; } return context; -} \ No newline at end of file +} diff --git a/src/utils/users/UserContext.ts b/src/utils/users/UserContext.ts index 99a856d..da5ec75 100644 --- a/src/utils/users/UserContext.ts +++ b/src/utils/users/UserContext.ts @@ -1,10 +1,5 @@ -import {Account} from "../../types/users.ts"; +import {UserContextType} from "../../types/users.ts"; import {createContext} from "react"; -export type UserContextType = { - user: Account | undefined; - login: (userData: Account) => void; - logout: () => void; -}; export const UserContext = createContext(undefined); diff --git a/src/utils/users/UserProvider.tsx b/src/utils/users/UserProvider.tsx index b34565b..cf81d3f 100644 --- a/src/utils/users/UserProvider.tsx +++ b/src/utils/users/UserProvider.tsx @@ -1,6 +1,9 @@ import React, {ReactNode, useEffect, useState} from 'react'; -import {Account} from '../../types/users.ts'; +import {Account, JwtTokenResponse, TokenObtainPair} from '../../types/users.ts'; import {UserContext} from "./UserContext.ts"; +import {refreshToken, verifyToken, login as apiLogin} from "../../API/users.ts"; +import {getFetch} from "../../API/common.ts"; +import {deleteCookie, getCookie, setCookie} from "../cookies.ts"; export function UserProvider({ children }: { children: ReactNode }) { @@ -8,27 +11,87 @@ export function UserProvider({ children }: { children: ReactNode }) { const storedUser = localStorage.getItem('user'); return storedUser ? JSON.parse(storedUser) : undefined; }); + const [tokens, setTokens] = useState(() => { + const accessToken = getCookie('access_token'); + const refreshToken = getCookie('refresh_token'); + return accessToken && refreshToken ? { access: accessToken, refresh: refreshToken } : undefined; + }); useEffect(() => { + if (tokens?.access) { + // Устанавливаем куки для токенов + setCookie('access_token', tokens.access, 7); + setCookie('refresh_token', tokens.refresh, 7); + fetchCurrentUser().then(userData => setUser(userData)); + } else { + // Удаляем куки, если токены отсутствуют + deleteCookie('access_token'); + deleteCookie('refresh_token'); + } + }, [tokens]); + + useEffect(() => { + console.log(user) if (user) { localStorage.setItem('user', JSON.stringify(user)); - document.cookie = `user=${encodeURIComponent(JSON.stringify(user))}; path=/;`; } else { localStorage.removeItem('user'); - document.cookie = 'user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;'; } }, [user]); - const login = (userData: Account) => { - setUser(userData); + + const fetchCurrentUser = async (): Promise => { + let response = await getFetch(`/users/auth/users/me/`); + if (!response.success){ + return undefined; + } + + response = await getFetch(`/users/auth/users/${(response.body as Account).id}/`); + + if (response.success){ + return response.body as Account + } + // if (response?.success && response.body) { + // return response.body as Account; // Возвращаем данные о пользователе + // } + return undefined; }; - const logout = () => { - setUser(undefined); + const handleLogin = async (credentials: TokenObtainPair) => { + const tokens = await apiLogin(credentials); + if (tokens) { + setTokens(tokens); + + } }; + const handleLogout = () => { + setUser(undefined); + setTokens(undefined); + }; + + const validateToken = async () => { + if (tokens?.access) { + const isValid = await verifyToken({ token: tokens.access }); + if (!isValid && tokens.refresh) { + const newTokens = await refreshToken({ refresh: tokens.refresh }); + if (newTokens) { + setTokens(newTokens); + } else { + handleLogout(); + } + } else if (!isValid) { + handleLogout(); + } + } + }; + + useEffect(() => { + validateToken().then(); + }, []); + return ( - + {children} );