diff --git a/frontend/src/App.css b/frontend/src/App.css index 3fc8113..ba3535a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,7 +1,3 @@ #root { height: 100%; - box-shadow: 1px 1px 20px 1px rgba(68, 68, 68, 0.5); - border-radius: 40px; - border: 1px solid #444; - overflow: hidden; } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 411f6b1..5607d28 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,12 +2,16 @@ import './App.css' import { Route, Routes } from "react-router-dom"; import { LoginPage } from "./pages/LoginPage"; import { ForgotPasswordPage } from "./pages/ForgotPasswordPage"; +import { DashboardPage } from "./pages/DashboardPage"; +import { RequirePermission } from "./components/RequirePermission"; +import { RedirectIfAuthenticated } from "./components/RedirectIfAuthenticated"; function App() { return ( - } /> - } /> + } /> + } /> + } /> ); } diff --git a/frontend/src/api/LoginAPI/Auth.ts b/frontend/src/api/LoginAPI/Auth.ts index 97935c9..d4c87ea 100644 --- a/frontend/src/api/LoginAPI/Auth.ts +++ b/frontend/src/api/LoginAPI/Auth.ts @@ -1,14 +1,18 @@ -import { API_URL, setAccessToken } from "../token"; +import { API_URL, authFetch, setAccessToken } from "../token"; export async function login(email: string, password: string) { const body = new URLSearchParams({ username: email, password }); const res = await fetch(`${API_URL}/protected/token`, { method: "POST", - body, - credentials: "include", + body, + credentials: "include", }); if (!res.ok) throw new Error("Неверный email или пароль"); const data = await res.json(); setAccessToken(data.access_token); return data; } + +export async function logout() { + return authFetch("/protected/logout"); +} diff --git a/frontend/src/api/UserAPI/User.ts b/frontend/src/api/UserAPI/User.ts new file mode 100644 index 0000000..5b45847 --- /dev/null +++ b/frontend/src/api/UserAPI/User.ts @@ -0,0 +1,9 @@ +import type { UserOut } from "../../types/UserTypes"; +import { authFetch } from "../token"; + + +export async function fetchCurrentUser(): Promise { + const res = await authFetch("/protected/me"); + if (!res.ok) throw new Error("Не удалось получить данные пользователя"); + return res.json(); +} \ No newline at end of file diff --git a/frontend/src/api/token.ts b/frontend/src/api/token.ts index 14bcfce..439a0b1 100644 --- a/frontend/src/api/token.ts +++ b/frontend/src/api/token.ts @@ -10,8 +10,58 @@ export function getAccessToken() { return accessToken; } +async function requestRefresh(): Promise<{ access_token: string } | null> { + const res = await fetch(`${API_URL}/protected/refresh`, { + method: "POST", + credentials: "include", + }); + if (!res.ok) return null; + return res.json(); +} + +let refreshInFlight: Promise | null = null; + +export function refreshAccessToken(): Promise { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + try { + // refresh_token в cookie одноразовый (ротация): если другая вкладка + // или повторный вызов уже успели обменять его на новый ровно перед + // нашим запросом, наш "старый" токен уже отозван и сервер ответит + // 404/401, хотя сессия на самом деле жива. Одна короткая повторная + // попытка подхватит уже обновлённую браузером cookie и не даст + // разлогинить валидного пользователя из-за проигранной гонки. + let data = await requestRefresh(); + if (!data) { + await new Promise((resolve) => setTimeout(resolve, 300)); + data = await requestRefresh(); + } + if (!data) { + setAccessToken(null); + return false; + } + setAccessToken(data.access_token); + return true; + } finally { + refreshInFlight = null; + } + })(); + + return refreshInFlight; +} + export async function authFetch(path: string, init: RequestInit = {}) { const headers = new Headers(init.headers); if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); - return fetch(`${API_URL}${path}`, { ...init, headers, credentials: "include" }); + + const res = await fetch(`${API_URL}${path}`, { ...init, headers, credentials: "include" }); + if (res.status !== 401) return res; + + const refreshed = await refreshAccessToken(); + if (!refreshed) return res; + + const retryHeaders = new Headers(init.headers); + if (accessToken) retryHeaders.set("Authorization", `Bearer ${accessToken}`); + return fetch(`${API_URL}${path}`, { ...init, headers: retryHeaders, credentials: "include" }); } diff --git a/frontend/src/components/RedirectIfAuthenticated.tsx b/frontend/src/components/RedirectIfAuthenticated.tsx new file mode 100644 index 0000000..695cc82 --- /dev/null +++ b/frontend/src/components/RedirectIfAuthenticated.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export function RedirectIfAuthenticated({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + + if (loading) return null; + if (user) return ; + + return children; +} diff --git a/frontend/src/components/RequirePermission.tsx b/frontend/src/components/RequirePermission.tsx new file mode 100644 index 0000000..b023157 --- /dev/null +++ b/frontend/src/components/RequirePermission.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export function RequirePermission({ permission, children }: { permission?: string; children: ReactNode }) { + const { user, permissions, loading } = useAuth(); + + if (loading) return null; + if (!user) return ; + if (permission && !permissions.has(permission)) return ; + + return children; +} diff --git a/frontend/src/context/AuthContext.ts b/frontend/src/context/AuthContext.ts new file mode 100644 index 0000000..2137587 --- /dev/null +++ b/frontend/src/context/AuthContext.ts @@ -0,0 +1,12 @@ +import { createContext } from "react"; +import type { UserOut } from "../types/UserTypes"; + +export type AuthState = { + user: UserOut | null; + permissions: Set; + loading: boolean; + refreshUser: () => Promise>; + logout: () => Promise; +}; + +export const AuthContext = createContext(null); diff --git a/frontend/src/context/AuthProvider.tsx b/frontend/src/context/AuthProvider.tsx new file mode 100644 index 0000000..00b8e97 --- /dev/null +++ b/frontend/src/context/AuthProvider.tsx @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { fetchCurrentUser } from "../api/UserAPI/User"; +import { logout as apiLogout } from "../api/LoginAPI/Auth"; +import { refreshAccessToken, setAccessToken } from "../api/token"; +import { getEffectivePermissions } from "../utils/permissions/Permissions"; +import { getRememberMe, setRememberMe } from "../utils/rememberMe"; +import { AuthContext, type AuthState } from "./AuthContext"; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState>({ + user: null, + permissions: new Set(), + loading: true, + }); + + const refreshUser = useCallback(async () => { + setState((s) => ({ ...s, loading: true })); + try { + const user = await fetchCurrentUser(); + const permissions = getEffectivePermissions(user); + setState({ user, permissions, loading: false }); + return permissions; + } catch { + setState({ user: null, permissions: new Set(), loading: false }); + return new Set(); + } + }, []); + + const logout = useCallback(async () => { + try { + await apiLogout(); + } catch { + // ошибку сети/сервера игнорируем — состояние всё равно чистим ниже + } finally { + setAccessToken(null); + setRememberMe(false); + setState({ user: null, permissions: new Set(), loading: false }); + } + }, []); + + const started = useRef(false); + + useEffect(() => { + if (started.current) return; + started.current = true; + + if (!getRememberMe()) { + setState({ user: null, permissions: new Set(), loading: false }); + return; + } + + (async () => { + const ok = await refreshAccessToken(); + if (ok) { + await refreshUser(); + } else { + setState({ user: null, permissions: new Set(), loading: false }); + } + })(); + }, [refreshUser]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/features/auth/components/AuthComponent.tsx b/frontend/src/features/auth/components/AuthComponent.tsx index cfbd8f7..6942d5d 100644 --- a/frontend/src/features/auth/components/AuthComponent.tsx +++ b/frontend/src/features/auth/components/AuthComponent.tsx @@ -1,9 +1,13 @@ import { useState, type SubmitEvent, type ReactElement } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import styles from "./AuthComponent.module.css"; import { login } from "../../../api/LoginAPI/Auth"; +import { useAuth } from "../../../hooks/useAuth"; +import { setRememberMe as persistRememberMe } from "../../../utils/rememberMe"; export function Auth():ReactElement { + const { refreshUser } = useAuth(); + const navigate = useNavigate(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(false); @@ -26,7 +30,9 @@ export function Auth():ReactElement { try { await login(email, password); - window.location.href = "http://localhost:8000/docs"; + persistRememberMe(rememberMe); + await refreshUser(); + navigate("/dashboard"); } catch (err) { console.error(err); setErrors({ password: "Неверный email или пароль" }); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..504d97f --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,8 @@ +import { useContext } from "react"; +import { AuthContext } from "../context/AuthContext"; + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); + return ctx; +} diff --git a/frontend/src/layouts/AuthLayout.css b/frontend/src/layouts/AuthLayout.css index 1cfbfec..60bdf9e 100644 --- a/frontend/src/layouts/AuthLayout.css +++ b/frontend/src/layouts/AuthLayout.css @@ -5,6 +5,9 @@ width: 100%; overflow: hidden; height: 100%; + box-shadow: 1px 1px 20px 1px rgba(68, 68, 68, 0.5); + border-radius: 40px; + border: 1px solid #444; } .left_side { width: 60%; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ade9d64..24269be 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' +import { AuthProvider } from './context/AuthProvider.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..bce1fff --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,22 @@ +import { useAuth } from "../hooks/useAuth"; + +export function DashboardPage() { + const { permissions, logout } = useAuth(); + const isAdmin = permissions.has("admin"); + + return ( +
+
+

User

+
+ + + + {isAdmin && ( +
+

Admin

+
+ )} +
+ ); +} diff --git a/frontend/src/utils/permissions/Permissions.ts b/frontend/src/utils/permissions/Permissions.ts new file mode 100644 index 0000000..f2ae736 --- /dev/null +++ b/frontend/src/utils/permissions/Permissions.ts @@ -0,0 +1,8 @@ +import type { UserOut } from "../../types/UserTypes"; + + +export function getEffectivePermissions(user: UserOut): Set { + const direct = user.direct_permissions.map(p => p.permission); + const fromGroups = user.group.flatMap(g => g.permissions.map(p => p.permission)); + return new Set([...direct, ...fromGroups]); +} \ No newline at end of file diff --git a/frontend/src/utils/rememberMe.ts b/frontend/src/utils/rememberMe.ts new file mode 100644 index 0000000..fa8ef2f --- /dev/null +++ b/frontend/src/utils/rememberMe.ts @@ -0,0 +1,13 @@ +const REMEMBER_ME_KEY = "rememberMe"; + +export function setRememberMe(value: boolean) { + if (value) { + localStorage.setItem(REMEMBER_ME_KEY, "1"); + } else { + localStorage.removeItem(REMEMBER_ME_KEY); + } +} + +export function getRememberMe(): boolean { + return localStorage.getItem(REMEMBER_ME_KEY) === "1"; +} diff --git a/src/database/auth/refresh_tokens.py b/src/database/auth/refresh_tokens.py index b74f624..fadfc0e 100644 --- a/src/database/auth/refresh_tokens.py +++ b/src/database/auth/refresh_tokens.py @@ -36,20 +36,23 @@ class JwtCrudActions: async def create_and_update_token(self, data: dict, old_jti: UUID, new_jti: UUID) -> bool: async with self.Session() as session, session.begin(): - new_token = RefreshTokens(**data) - - query = ( + revoke_query = ( update(RefreshTokens) .where(RefreshTokens.id == old_jti, RefreshTokens.is_revoked.is_(False)) - .values(is_revoked=True, replaced_by=new_jti) + .values(is_revoked=True) .returning(RefreshTokens.id) ) - updated_id = (await session.execute(query)).scalar_one_or_none() + updated_id = (await session.execute(revoke_query)).scalar_one_or_none() if updated_id is None: return False - session.add(new_token) + session.add(RefreshTokens(**data)) + await session.flush() + + await session.execute( + update(RefreshTokens).where(RefreshTokens.id == old_jti).values(replaced_by=new_jti) + ) return True diff --git a/src/web/protected_routes/auth_routes.py b/src/web/protected_routes/auth_routes.py index 64c4809..7b7603e 100644 --- a/src/web/protected_routes/auth_routes.py +++ b/src/web/protected_routes/auth_routes.py @@ -93,6 +93,6 @@ async def reset(request: Request, return {"detail":"New password was sent"} -@router.get("") -async def protected(current_user:UserOut=Depends(require_permissions()))->dict: - return {"protected router": "Hello, this is a protected router"} +@router.get("/me") +async def protected(current_user:UserOut=Depends(require_permissions()))->UserOut: + return current_user