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 (
+
+
+
+
+
+ {isAdmin && (
+
+ )}
+
+ );
+}
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