fix(auth): session no longer drops after access-token expiry
Access-токен жил 60 минут без обновления: фронт не слал response-interceptor на 401, refresh-токен лежал в localStorage неиспользуемым, WebSocket-сокеты (форма, статусы выгрузки) не переподключались после закрытия с auth-кодом, а после редиректа на /login пользователь терял исходную страницу. - tokenRefresh.js: чистые getTokenExp/msUntilRefresh/createSingleFlight (TDD). - auth.js: refreshTokens — обновление токена вне интерсепторов api (без рекурсии), централизованно чистит токены и шлёт auth:expired при неудаче. - client.js: response-interceptor повторяет запрос после обновления токена. - AuthProvider: при старте и по 'auth:expired'/'auth:refreshed' не выкидывает из системы, пока жив refresh; проактивный таймер обновления + visibilitychange. - Routes/LoginPage: PrivateRoute сохраняет исходный location, вход возвращает туда же вместо /tasks; убрана мёртвая ветка loading. - useWebSocket/exports: переподключение после 1008/4401 с одной попыткой refreshTokens() перед тем как сдаться. Issue: #248 (AURORA-1877, эпик AURORA-1876, Критичный приоритет) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2a287480b6
commit
edaed04a03
@ -1,4 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import { Config } from '../conf/config';
|
||||
import api from './client';
|
||||
import { clearAuthTokens } from './utils/token';
|
||||
import { createSingleFlight } from './utils/tokenRefresh';
|
||||
|
||||
export const AuthApi = {
|
||||
async login(username, password) {
|
||||
@ -14,10 +18,19 @@ export const AuthApi = {
|
||||
},
|
||||
async refresh() {
|
||||
const refresh_token = localStorage.getItem('refresh_token');
|
||||
if (!refresh_token) return null;
|
||||
const { data } = await api.post('/auth/refresh', { refresh_token });
|
||||
if (!refresh_token) {
|
||||
throw new Error('Нет refresh-токена');
|
||||
}
|
||||
// "Голый" axios, в обход клиента api и его интерсепторов — иначе
|
||||
// 401 на /auth/refresh сам же запустит повторное обновление токена.
|
||||
const { data } = await axios.post(`${Config.API_URL}/api/v1/auth/refresh`, { refresh_token });
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('auth:refreshed'));
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления токена', error);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
logout() {
|
||||
@ -30,3 +43,21 @@ export const AuthApi = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Пока обновление токена в полёте, все одновременные вызыватели получают
|
||||
// один и тот же Promise — иначе параллельные 401 запускают по refresh-запросу каждый.
|
||||
// Неудача означает, что сессию больше не восстановить: токены чистим и шлём
|
||||
// auth:expired централизованно здесь, чтобы не дублировать это в каждом вызывающем месте.
|
||||
export const refreshTokens = createSingleFlight(async () => {
|
||||
try {
|
||||
return await AuthApi.refresh();
|
||||
} catch (error) {
|
||||
clearAuthTokens();
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||
} catch (dispatchError) {
|
||||
console.error('Ошибка при оповещении об истечении сессии', dispatchError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@ -18,4 +18,29 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const config = error.config;
|
||||
const status = error.response?.status;
|
||||
const isAuthEndpoint = config?.url?.includes('/auth/login') || config?.url?.includes('/auth/refresh');
|
||||
|
||||
if (status !== 401 || !config || config._retry || isAuthEndpoint) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
config._retry = true;
|
||||
|
||||
try {
|
||||
// Динамический импорт разрывает цикл client.js <-> auth.js на этапе загрузки модулей.
|
||||
const { refreshTokens } = await import('./auth');
|
||||
const data = await refreshTokens();
|
||||
config.headers.Authorization = `Bearer ${data.access_token}`;
|
||||
return api(config);
|
||||
} catch {
|
||||
// Очистку токенов и auth:expired при неудаче уже выполнил refreshTokens.
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { refreshTokens } from './auth';
|
||||
import api from './client';
|
||||
|
||||
let jobs = [];
|
||||
@ -51,6 +52,7 @@ export function startExportUpdates() {
|
||||
let retry;
|
||||
let watchdog;
|
||||
let attempts = 0;
|
||||
let authRetried = false;
|
||||
const connect = () => {
|
||||
if (stopped) return;
|
||||
const url = new URL(`${api.defaults.baseURL}/ws/export`, window.location.origin);
|
||||
@ -63,6 +65,7 @@ export function startExportUpdates() {
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ token: localStorage.getItem('access_token') }));
|
||||
armWatchdog();
|
||||
authRetried = false;
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (stopped) return;
|
||||
@ -77,7 +80,20 @@ export function startExportUpdates() {
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
clearTimeout(watchdog);
|
||||
if (stopped || event.code === 4401) return;
|
||||
if (stopped) return;
|
||||
if (event.code === 4401) {
|
||||
if (authRetried) return;
|
||||
authRetried = true;
|
||||
// Токен в localStorage мог истечь к моменту подключения — обновляем один раз и пробуем снова.
|
||||
refreshTokens()
|
||||
.then(() => {
|
||||
if (!stopped) connect();
|
||||
})
|
||||
.catch(() => {
|
||||
/* auth:expired уже отправлен из refreshTokens. */
|
||||
});
|
||||
return;
|
||||
}
|
||||
retry = setTimeout(connect, Math.min(30000, 2000 * 2 ** attempts++));
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { getTokenExp } from './tokenRefresh.js';
|
||||
|
||||
export const saveAuthTokens = (data) => {
|
||||
const { access_token, refresh_token, expires_in } = data;
|
||||
|
||||
@ -16,41 +18,21 @@ export const getAuthToken = () => {
|
||||
};
|
||||
|
||||
export const isTokenExpired = () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: с бэка сейчас приходит expired_in: null. Если поправят, то это логика будет не нужна. Пока что оставляю так
|
||||
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
);
|
||||
|
||||
const payload = JSON.parse(jsonPayload);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// для проверки времени жизни токена
|
||||
const expirationDate = new Date(payload.exp * 1000);
|
||||
console.log('Токен истекает:', expirationDate.toLocaleString('ru-RU'));
|
||||
|
||||
return payload.exp < now;
|
||||
} catch (err) {
|
||||
console.error('Ошибка при разборе токена:', err.message);
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exp = getTokenExp(token);
|
||||
if (exp === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return exp < now;
|
||||
};
|
||||
|
||||
export const clearAuthTokens = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
};
|
||||
|
||||
// TODO: отсутствует функционал обновления токена. добавить логику когда сделают на бэке. пока просто очистка LS
|
||||
export const updateToken = () => {
|
||||
clearAuthTokens();
|
||||
};
|
||||
|
||||
32
web/src/api/utils/tokenRefresh.js
Normal file
32
web/src/api/utils/tokenRefresh.js
Normal file
@ -0,0 +1,32 @@
|
||||
export function getTokenExp(token) {
|
||||
try {
|
||||
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
);
|
||||
const payload = JSON.parse(jsonPayload);
|
||||
return typeof payload.exp === 'number' ? payload.exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function msUntilRefresh(exp, nowMs, skewSec = 120) {
|
||||
const refreshAtMs = (exp - skewSec) * 1000;
|
||||
return Math.max(0, refreshAtMs - nowMs);
|
||||
}
|
||||
|
||||
export function createSingleFlight(fn) {
|
||||
let inFlight = null;
|
||||
return (...args) => {
|
||||
if (!inFlight) {
|
||||
inFlight = fn(...args).finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
};
|
||||
}
|
||||
63
web/src/api/utils/tokenRefresh.test.js
Normal file
63
web/src/api/utils/tokenRefresh.test.js
Normal file
@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSingleFlight, getTokenExp, msUntilRefresh } from './tokenRefresh.js';
|
||||
|
||||
function makeToken(payload) {
|
||||
const base64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
return `${base64url({ alg: 'HS256', typ: 'JWT' })}.${base64url(payload)}.signature`;
|
||||
}
|
||||
|
||||
test('getTokenExp reads the exp claim from a valid token', () => {
|
||||
const token = makeToken({ sub: 'user', exp: 1700000000 });
|
||||
assert.equal(getTokenExp(token), 1700000000);
|
||||
});
|
||||
|
||||
test('getTokenExp returns null for a malformed token', () => {
|
||||
assert.equal(getTokenExp('not-a-jwt'), null);
|
||||
assert.equal(getTokenExp(''), null);
|
||||
assert.equal(getTokenExp(null), null);
|
||||
});
|
||||
|
||||
test('msUntilRefresh schedules a refresh skewSec before expiry', () => {
|
||||
const nowMs = 1700000000 * 1000;
|
||||
const exp = 1700000000 + 600; // expires in 10 minutes
|
||||
assert.equal(msUntilRefresh(exp, nowMs, 120), 480000); // (600 - 120) * 1000
|
||||
});
|
||||
|
||||
test('msUntilRefresh clamps to 0 for an already-expired token', () => {
|
||||
const nowMs = 1700000000 * 1000;
|
||||
const exp = 1700000000 - 60; // expired a minute ago
|
||||
assert.equal(msUntilRefresh(exp, nowMs, 120), 0);
|
||||
});
|
||||
|
||||
test('createSingleFlight shares one in-flight call across concurrent callers', async () => {
|
||||
let calls = 0;
|
||||
let resolveFn;
|
||||
const fn = () =>
|
||||
new Promise((resolve) => {
|
||||
calls++;
|
||||
resolveFn = resolve;
|
||||
});
|
||||
const singleFlight = createSingleFlight(fn);
|
||||
|
||||
const first = singleFlight();
|
||||
const second = singleFlight();
|
||||
resolveFn('result');
|
||||
|
||||
assert.equal(await first, 'result');
|
||||
assert.equal(await second, 'result');
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('createSingleFlight calls fn again after the previous call failed', async () => {
|
||||
let calls = 0;
|
||||
const fn = () => {
|
||||
calls++;
|
||||
return calls === 1 ? Promise.reject(new Error('fail')) : Promise.resolve('ok');
|
||||
};
|
||||
const singleFlight = createSingleFlight(fn);
|
||||
|
||||
await assert.rejects(singleFlight(), /fail/);
|
||||
assert.equal(await singleFlight(), 'ok');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@ -1,5 +1,4 @@
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import AuditLogsPage from '../pages/AdminPanel/AuditLogsPage/AuditLogsPage.jsx';
|
||||
import SspsPage from '../pages/AdminPanel/SppsPage/SppsPage';
|
||||
import UsersPage from '../pages/AdminPanel/UsersPage/UsersPage';
|
||||
@ -20,13 +19,10 @@ import { useAuth } from './context/AuthProvider';
|
||||
import { Layout } from './layout/Layout';
|
||||
|
||||
export const PrivateRoute = () => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
<CircularProgress />;
|
||||
}
|
||||
|
||||
return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace />;
|
||||
return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace state={{ from: location }} />;
|
||||
};
|
||||
|
||||
export const AppRoutes = () => {
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { AuthApi } from '../../api/auth';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { AuthApi, refreshTokens } from '../../api/auth';
|
||||
import { UsersApi } from '../../api/users';
|
||||
import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token';
|
||||
import { getTokenExp, msUntilRefresh } from '../../api/utils/tokenRefresh';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(() => {
|
||||
const token = getAuthToken();
|
||||
return !!token && !isTokenExpired();
|
||||
});
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const refreshTimerRef = useRef(null);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
@ -36,12 +36,37 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const clearRefreshTimer = useCallback(() => {
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleProactiveRefresh = useCallback(() => {
|
||||
clearRefreshTimer();
|
||||
const token = getAuthToken();
|
||||
const exp = token ? getTokenExp(token) : null;
|
||||
if (exp === null) {
|
||||
return;
|
||||
}
|
||||
refreshTimerRef.current = setTimeout(
|
||||
() => {
|
||||
refreshTokens().catch(() => {
|
||||
// неудачу обработает auth:expired, который шлёт response-interceptor клиента
|
||||
});
|
||||
},
|
||||
msUntilRefresh(exp, Date.now()),
|
||||
);
|
||||
}, [clearRefreshTimer]);
|
||||
|
||||
const login = async (username, password) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await AuthApi.login(username, password);
|
||||
saveAuthTokens(data);
|
||||
setIsAuthenticated(true);
|
||||
scheduleProactiveRefresh();
|
||||
|
||||
try {
|
||||
const userData = await UsersApi.me();
|
||||
@ -63,6 +88,7 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
clearRefreshTimer();
|
||||
AuthApi.logout();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
@ -72,8 +98,48 @@ export const AuthProvider = ({ children }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
let cancelled = false;
|
||||
|
||||
const initAuth = async () => {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTokenExpired()) {
|
||||
if (!cancelled) {
|
||||
setIsAuthenticated(true);
|
||||
scheduleProactiveRefresh();
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Access-токен просрочен, но раз есть refresh — пробуем обновить незаметно,
|
||||
// прежде чем решать, что пользователь не авторизован.
|
||||
try {
|
||||
await refreshTokens();
|
||||
if (!cancelled) {
|
||||
setIsAuthenticated(true);
|
||||
scheduleProactiveRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Не удалось обновить токен при загрузке:', error);
|
||||
// Очистку токенов и событие auth:expired (обработчик ниже выставит isAuthenticated=false) уже выполнил refreshTokens.
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scheduleProactiveRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
@ -86,19 +152,41 @@ export const AuthProvider = ({ children }) => {
|
||||
setIsAuthenticated(newIsAuthenticated);
|
||||
};
|
||||
|
||||
const handleAuthRefreshed = () => {
|
||||
setIsAuthenticated(true);
|
||||
scheduleProactiveRefresh();
|
||||
};
|
||||
|
||||
const handleAuthExpired = () => {
|
||||
clearRefreshTimer();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
toast.error('Сессия истекла, войдите снова');
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
const token = getAuthToken();
|
||||
if (token && !isTokenExpired()) {
|
||||
scheduleProactiveRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('auth:changed', handleAuthChange);
|
||||
window.addEventListener('auth:refreshed', handleAuthRefreshed);
|
||||
window.addEventListener('auth:expired', handleAuthExpired);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('auth:changed', handleAuthChange);
|
||||
window.removeEventListener('auth:refreshed', handleAuthRefreshed);
|
||||
window.removeEventListener('auth:expired', handleAuthExpired);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
clearRefreshTimer();
|
||||
};
|
||||
}, []);
|
||||
}, [clearRefreshTimer, scheduleProactiveRefresh]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, user, login, logout, loadUser }}>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { refreshTokens } from '../../../api/auth';
|
||||
|
||||
export const useWebSocket = (url, onMessage) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
@ -7,6 +8,7 @@ export const useWebSocket = (url, onMessage) => {
|
||||
const wsRef = useRef(null);
|
||||
const reconnectTimeoutRef = useRef(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const authRetryAttemptedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isConnectedRef.current = isConnected;
|
||||
@ -25,6 +27,7 @@ export const useWebSocket = (url, onMessage) => {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
reconnectAttempts.current = 0;
|
||||
authRetryAttemptedRef.current = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@ -47,9 +50,27 @@ export const useWebSocket = (url, onMessage) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason, 'Clean:', event.wasClean);
|
||||
setIsConnected(false);
|
||||
|
||||
// Не переподключаемся если закрыто intentionally или с ошибкой авторизации
|
||||
if (event.code === 1008 || event.code === 1000) {
|
||||
console.log('Connection closed intentionally or auth error, not reconnecting');
|
||||
// Не переподключаемся если закрыто intentionally (disconnect())
|
||||
if (event.code === 1000) {
|
||||
console.log('Connection closed intentionally, not reconnecting');
|
||||
return;
|
||||
}
|
||||
|
||||
// Сервер закрыл с ошибкой авторизации — токен в localStorage мог успеть истечь
|
||||
// к моменту (пере)подключения. Пробуем обновить его один раз и подключиться заново;
|
||||
// sendLogin на RealtimeContext сам возьмёт новый токен из localStorage.
|
||||
if (event.code === 1008) {
|
||||
if (authRetryAttemptedRef.current) {
|
||||
console.log('Auth error persists after token refresh, not reconnecting');
|
||||
return;
|
||||
}
|
||||
authRetryAttemptedRef.current = true;
|
||||
console.log('Auth error, refreshing token before reconnecting');
|
||||
refreshTokens()
|
||||
.then(() => connect())
|
||||
.catch((err) => {
|
||||
console.error('Token refresh failed, giving up on reconnect:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../app/context/AuthProvider';
|
||||
import { isTestMode } from '../conf/config';
|
||||
|
||||
@ -9,6 +9,7 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isTestMode && isAuthenticated) {
|
||||
@ -21,7 +22,8 @@ export default function LoginPage() {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate('/tasks');
|
||||
const from = location.state?.from;
|
||||
navigate(from ? `${from.pathname}${from.search ?? ''}` : '/tasks', { replace: true });
|
||||
} catch (err) {
|
||||
setError('Неверный логин или пароль');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user