added refresh token

This commit is contained in:
SlimusMinus
2026-08-03 02:44:57 +03:00
parent b6c61c72d9
commit abbcc330e7
16 changed files with 210 additions and 135 deletions

85
src/api/apiClient.jsx Normal file
View File

@@ -0,0 +1,85 @@
import { API_URL } from "../config/authConstants";
function getAccessToken() {
return localStorage.getItem("token");
}
function getRefreshToken() {
return localStorage.getItem("refreshToken");
}
function saveTokens(token, refreshToken) {
localStorage.setItem("token", token);
localStorage.setItem("refreshToken", refreshToken);
}
function clearTokens() {
localStorage.removeItem("token");
localStorage.removeItem("refreshToken");
localStorage.removeItem("user");
}
let refreshPromise = null;
export async function refreshAccessToken() {
// Если несколько запросов одновременно словили 401 — не долбим /refresh параллельно,
// а ждём один и тот же промис
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("No refresh token");
const res = await fetch(`${API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) throw new Error("Refresh failed");
const data = await res.json();
saveTokens(data.token, data.refreshToken);
return data.token;
})();
try {
return await refreshPromise;
} finally {
refreshPromise = null;
}
}
/**
* Обёртка над fetch с авто-подстановкой Authorization и авто-обновлением
* access-токена при 401. Использовать вместо голого fetch для защищённых эндпоинтов.
*/
export async function apiFetch(url, options = {}) {
const token = getAccessToken();
const doFetch = (accessToken) =>
fetch(url, {
...options,
headers: {
...options.headers,
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
});
let res = await doFetch(token);
if (res.status === 401 && getRefreshToken()) {
try {
const newToken = await refreshAccessToken();
res = await doFetch(newToken);
} catch {
clearTokens();
window.location.href = "/";
throw new Error("Session expired");
}
}
return res;
}
export { getAccessToken, getRefreshToken, saveTokens, clearTokens };

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import CloseIcon from "./icons/CloseIcon";
import { useAuthContext } from "../context/AuthContext";
import { API_URL } from "../config/authConstants";
import { saveTokens } from "../api/apiClient";
/**
* AuthModal — модальное окно входа и регистрации прямо на странице ленты.
@@ -67,7 +68,8 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
throw new Error(data.message || t('auth.invalidCredentials'));
}
const { token, user } = await res.json();
const { token, refreshToken, user } = await res.json();
saveTokens(token, refreshToken);
saveAuth(user, token);
onClose();
} catch (err) {
@@ -185,7 +187,8 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
if (!loginRes.ok) throw new Error("Аккаунт создан. Войдите вручную.");
const { token, user } = await loginRes.json();
const { token, refreshToken, user } = await loginRes.json();
saveTokens(token, refreshToken);
saveAuth(user, token);
onClose();
} catch (err) {

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL } from "../config/authConstants";
import { useAuthContext } from "../context/AuthContext";
import { saveTokens } from "../api/apiClient";
export function useAuth() {
const navigate = useNavigate();
@@ -14,7 +15,7 @@ export function useAuth() {
setLoading(true); setError("");
try {
const res = await fetch(`${API_URL}/api/v1/auth`, {
const res = await fetch(`${API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login, password }),
@@ -25,9 +26,16 @@ export function useAuth() {
throw new Error(data.message || "Invalid login or password");
}
const { token, user } = await res.json();
const data = await res.json();
// Сохраняем в глобальный контекст + localStorage
console.log("LOGIN RESPONSE", data);
const { token, refreshToken, user } = data;
console.log("ACCESS", token);
console.log("REFRESH", refreshToken);
saveTokens(token, refreshToken);
saveAuth(user, token);
navigate("/feed");

View File

@@ -2,35 +2,56 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { Client } from "@stomp/stompjs";
import SockJS from "sockjs-client";
import { API_URL } from "../config/authConstants";
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
export function useChatSocket(chatId, onMessage) {
const clientRef = useRef(null);
const onMessageRef = useRef(onMessage);
const [connected, setConnected] = useState(false);
// Держим актуальный колбэк в ref, чтобы не пересоздавать соединение
// при каждом ре-рендере родителя из-за нового onMessage
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
if (!chatId) return;
const token = localStorage.getItem("token");
const socket = new SockJS(`${API_URL}/ws?token=${token}`);
const client = new Client({
webSocketFactory: () => socket,
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
reconnectDelay: 3000,
beforeConnect: async () => {
// На каждую попытку коннекта — свежий токен из localStorage
client.connectHeaders = {
Authorization: `Bearer ${getAccessToken()}`,
};
},
onConnect: () => {
setConnected(true);
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
const body = JSON.parse(msg.body);
onMessage(body);
onMessageRef.current(body);
});
},
onDisconnect: () => setConnected(false),
onStompError: async (frame) => {
// Если сервер отклонил CONNECT из-за протухшего токена — пробуем обновить и переподключиться
if (frame.headers?.message?.includes("Unauthorized")) {
try {
await refreshAccessToken();
} catch {
// refresh тоже не удался — дальше решает общий логин-флоу (redirect на /)
}
}
},
});
client.activate();
clientRef.current = client;
return () => client.deactivate();
}, [chatId, onMessage]);
}, [chatId]);
const sendMessage = useCallback((content) => {
if (!clientRef.current?.connected) return;

View File

@@ -1,14 +1,8 @@
import { useState, useCallback } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
import { cachePostsFromChats, setPostForChat } from "../stores/chatPostStore";
function authHeaders() {
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${localStorage.getItem("token")}`,
};
}
export function useChats() {
const [chats, setChats] = useState([]);
const [loading, setLoading] = useState(false);
@@ -16,7 +10,7 @@ export function useChats() {
const fetchChats = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_URL}/api/v1/chats`, { headers: authHeaders() });
const res = await apiFetch(`${API_URL}/api/v1/chats`);
const data = await res.json();
// API may return an array directly or a wrapper object like { items: [] } / { content: [] }
const list = Array.isArray(data) ? data : (data.items ?? data.content ?? []);
@@ -31,7 +25,7 @@ export function useChats() {
const url = new URL(`${API_URL}/api/v1/chats/with/${otherUserId}`);
if (postId) url.searchParams.set("postId", postId);
const res = await fetch(url, { method: "POST", headers: authHeaders() });
const res = await apiFetch(url, { method: "POST" });
if (!res.ok) throw new Error("Не удалось создать чат");
const chat = await res.json();
if (postInfo && chat.id) {
@@ -41,7 +35,7 @@ export function useChats() {
}, []);
const fetchMessages = useCallback(async (chatId) => {
const res = await fetch(`${API_URL}/api/v1/chats/${chatId}/messages`, { headers: authHeaders() });
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}/messages`);
const data = await res.json();
// Normalize: array or wrapper object
return Array.isArray(data) ? data : (data.items ?? data.content ?? []);
@@ -49,9 +43,8 @@ export function useChats() {
const markAsRead = useCallback(async (chatId) => {
try {
await fetch(`${API_URL}/api/v1/chats/${chatId}/read`, {
await apiFetch(`${API_URL}/api/v1/chats/${chatId}/read`, {
method: "POST",
headers: authHeaders(),
});
} catch (e) {
console.error("Failed to mark as read:", e);

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
export function useCreatePost() {
const [loading, setLoading] = useState(false);
@@ -11,18 +12,13 @@ export function useCreatePost() {
setError(null);
try {
const token = localStorage.getItem("token");
const res = await fetch(`${API_URL}/api/v1/posts`, {
const res = await apiFetch(`${API_URL}/api/v1/posts`, {
method: "POST",
headers: {
// Content-Type НЕ указываем — браузер сам добавит multipart/form-data с boundary
"Authorization": `Bearer ${token}`,
},
body: formData, // ← передаём FormData
});
const data = await res.json(); // ← объявляем ПОСЛЕ await res
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Error creating ad");

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
export function useDeleteChat() {
const [loading, setLoading] = useState(false);
@@ -9,12 +10,8 @@ export function useDeleteChat() {
setLoading(true);
setError("");
try {
const token = localStorage.getItem("token");
const res = await fetch(`${API_URL}/api/v1/chats/${chatId}`, {
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}`, {
method: "DELETE",
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) {
throw new Error(`Ошибка ${res.status}`);

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
/**
* useDeletePost — хук для удаления объявления.
@@ -15,12 +16,8 @@ export function useDeletePost() {
setError("");
try {
const token = localStorage.getItem("token");
const res = await fetch(`${API_URL}/api/v1/posts/${postId}`, {
const res = await apiFetch(`${API_URL}/api/v1/posts/${postId}`, {
method: "DELETE",
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) {

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { API_URL } from '../config/authConstants';
import { apiFetch } from '../api/apiClient';
/**
* Хук для работы с лайками (избранное).
@@ -9,21 +10,12 @@ export function useFavorites() {
/**
* Переключает лайк на посте через API.
* @param {number} postId - ID поста
* @param {string} token - JWT токен авторизации
* @returns {Promise<boolean>} - true если операция успешна
*/
const toggleFavorite = useCallback(async (postId, token) => {
if (!token) {
console.warn('Cannot toggle favorite: no token');
return false;
}
const toggleFavorite = useCallback(async (postId) => {
try {
const response = await fetch(`${API_URL}/api/v1/favorites/${postId}/toggle`, {
const response = await apiFetch(`${API_URL}/api/v1/favorites/${postId}/toggle`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
@@ -39,20 +31,11 @@ export function useFavorites() {
/**
* Загружает список лайкнутых постов текущего пользователя.
* @param {string} token - JWT токен авторизации
* @returns {Promise<Array>} - массив ID лайкнутых постов
*/
const loadFavorites = useCallback(async (token) => {
if (!token) {
return [];
}
const loadFavorites = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/api/v1/favorites`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const response = await apiFetch(`${API_URL}/api/v1/favorites`);
if (!response.ok) {
throw new Error(`Failed to load favorites: ${response.status}`);

View File

@@ -5,8 +5,17 @@ import { getImageUrl } from "../config/authConstants";
const MAX_SIZE_MB = 5;
export const ACCEPT = "image/jpeg,image/png,image/webp";
let idCounter = 0;
const nextId = () => `img-${++idCounter}-${Date.now()}`;
/**
* Генератор уникального id для превью изображения.
* crypto.randomUUID доступен во всех современных браузерах;
* для страховки — fallback на timestamp + random.
*/
function generateImageId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return `img-${crypto.randomUUID()}`;
}
return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function useImageUpload(existingUrls = []) {
const [images, setImages] = useState([]);
@@ -49,7 +58,7 @@ export function useImageUpload(existingUrls = []) {
try {
const compressedFiles = await Promise.all(files.map(f => compressImage(f)));
const newImages = compressedFiles.map(file => ({
id: nextId(),
id: generateImageId(),
file,
preview: URL.createObjectURL(file),
isExisting: false,

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
/**
* Универсальная мутация для создания и обновления объявления.
@@ -9,7 +10,8 @@ import { API_URL } from "../config/authConstants";
* id передан → PUT /api/v1/posts/:id (обновление)
* id не передан → POST /api/v1/posts (создание)
*
* Автоматически добавляет заголовок Authorization с JWT-токеном.
* Авторизация (Authorization header + авто-обновление токена при истечении)
* обрабатывается внутри apiFetch.
*
* save() возвращает:
* { data } — успешный ответ сервера
@@ -29,21 +31,13 @@ export function usePostMutation() {
setLoading(true);
setError("");
try {
const token = localStorage.getItem("token");
const url = id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`;
const method = id ? "PUT" : "POST";
const res = await fetch(url, {
const res = await apiFetch(url, {
method,
headers: {
"Authorization": `Bearer ${token}`,
},
body: data,
});
if (res.status === 401 || res.status === 403) {
throw new Error("Требуется авторизация. Войдите в аккаунт.");
}
if (!res.ok) {
let body;
try { body = await res.json(); } catch { body = null; }

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { API_URL } from '../config/authConstants';
import { apiFetch } from '../api/apiClient';
/**
* Хук для изменения статуса поста (ACTIVE/CLOSED).
@@ -9,21 +10,13 @@ export function usePostStatus() {
/**
* Изменяет статус поста на CLOSED (вещь найдена).
* @param {number} postId - ID поста
* @param {string} token - JWT токен авторизации
* @returns {Promise<boolean>} - true если операция успешна
*/
const markAsFound = useCallback(async (postId, token) => {
if (!token) {
console.warn('Cannot mark post as found: no token');
return false;
}
const markAsFound = useCallback(async (postId) => {
try {
// Используем PATCH endpoint для обновления статуса
const response = await fetch(`${API_URL}/api/v1/posts/${postId}/status`, {
const response = await apiFetch(`${API_URL}/api/v1/posts/${postId}/status`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'CLOSED' }),
@@ -43,20 +36,13 @@ export function usePostStatus() {
/**
* Возвращает статус поста в ACTIVE (если нужно reopen).
* @param {number} postId - ID поста
* @param {string} token - JWT токен авторизации
* @returns {Promise<boolean>} - true если операция успешна
*/
const reopenPost = useCallback(async (postId, token) => {
if (!token) {
console.warn('Cannot reopen post: no token');
return false;
}
const reopenPost = useCallback(async (postId) => {
try {
const response = await fetch(`${API_URL}/api/v1/posts/${postId}/status`, {
const response = await apiFetch(`${API_URL}/api/v1/posts/${postId}/status`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'ACTIVE' }),

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import {API_URL} from "../config/authConstants";
import { useNavigate } from "react-router-dom";
import { API_URL } from "../config/authConstants";
import { saveTokens } from "../api/apiClient";
export function useCheckLogin(login) {
const [checking, setChecking] = useState(false);
@@ -15,7 +16,7 @@ export function useCheckLogin(login) {
const timer = setTimeout(async () => {
try {
setChecking(true);
const res = await fetch(`${API_URL}/api/v1/auth/check-login?login=${login}`);
const res = await fetch(`${API_URL}/api/v1/auth/check-login?login=${encodeURIComponent(login)}`);
const json = await res.json();
setExists(json.exists);
} catch (e) {
@@ -31,8 +32,8 @@ export function useCheckLogin(login) {
return { checking, exists };
}
export function useRegister() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
@@ -54,10 +55,9 @@ export function useRegister() {
throw new Error(data.message || "Ошибка регистрации");
}
saveTokens(data.token, data.refreshToken);
setSuccess(true);
setTimeout(() => {
window.location.href = "/register";
}, 2500);
setTimeout(() => navigate("/feed"), 1500);
} catch (e) {
setError(e.message);
} finally {

View File

@@ -4,6 +4,7 @@ import SockJS from "sockjs-client";
import { API_URL } from "../config/authConstants";
import { useUnread } from "../context/UnreadContext";
import { getActiveChatId } from "../stores/activeChatStore";
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
export function useUnreadSocket(userId) {
const { setChatUnread } = useUnread();
@@ -12,12 +13,14 @@ export function useUnreadSocket(userId) {
useEffect(() => {
if (!userId) return;
const token = localStorage.getItem("token");
const socket = new SockJS(`${API_URL}/ws?token=${token}`);
const client = new Client({
webSocketFactory: () => socket,
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
reconnectDelay: 3000,
beforeConnect: () => {
client.connectHeaders = {
Authorization: `Bearer ${getAccessToken()}`,
};
},
onConnect: () => {
client.subscribe(`/topic/user/${userId}/unread`, (msg) => {
const body = JSON.parse(msg.body);
@@ -25,6 +28,15 @@ export function useUnreadSocket(userId) {
setChatUnread(body.chatId, body.unreadCount);
});
},
onStompError: async (frame) => {
if (frame.headers?.message?.includes("Unauthorized")) {
try {
await refreshAccessToken();
} catch {
// refresh не удался — общий логин-флоу разберётся при следующем apiFetch
}
}
},
});
client.activate();

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { API_URL } from "../config/authConstants";
import { apiFetch } from "../api/apiClient";
export function useUpdatePost() {
const [loading, setLoading] = useState(false);
@@ -9,13 +10,8 @@ export function useUpdatePost() {
setLoading(true);
setError("");
try {
const token = localStorage.getItem("token");
const res = await fetch(`${API_URL}/api/v1/posts/${id}`, {
const res = await apiFetch(`${API_URL}/api/v1/posts/${id}`, {
method: "PUT",
headers: {
"Authorization": `Bearer ${token}`,
},
body: formData, // FormData
});

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { API_URL } from "../config/authConstants";
import {useState} from "react";
import {API_URL} from "../config/authConstants";
import {apiFetch} from "../api/apiClient";
/**
* useUpdateProfile — хук для обновления профиля пользователя.
@@ -14,8 +15,6 @@ export function useUpdateProfile() {
setError("");
try {
const token = localStorage.getItem("token");
// Используем FormData — чтобы отправить файл аватара вместе с текстовыми полями
const formData = new FormData();
formData.append("id", userId);
@@ -30,11 +29,8 @@ export function useUpdateProfile() {
formData.append("avatar", profileData.avatar);
}
const res = await fetch(`${API_URL}/api/v1/users/${userId}`, {
const res = await apiFetch(`${API_URL}/api/v1/users/${userId}`, {
method: "PUT",
headers: {
"Authorization": `Bearer ${token}`,
},
body: formData,
});
@@ -43,8 +39,7 @@ export function useUpdateProfile() {
throw new Error(data.message || `Ошибка ${res.status}`);
}
const updatedUser = await res.json();
return updatedUser;
return await res.json();
} catch (err) {
setError(err.message || "Failed to save profile");
return null;