Merge pull request #36 from SlimusMinus/fix-security
added refresh token
This commit is contained in:
85
src/api/apiClient.jsx
Normal file
85
src/api/apiClient.jsx
Normal 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 };
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import CloseIcon from "./icons/CloseIcon";
|
import CloseIcon from "./icons/CloseIcon";
|
||||||
import { useAuthContext } from "../context/AuthContext";
|
import { useAuthContext } from "../context/AuthContext";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { saveTokens } from "../api/apiClient";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuthModal — модальное окно входа и регистрации прямо на странице ленты.
|
* AuthModal — модальное окно входа и регистрации прямо на странице ленты.
|
||||||
@@ -67,7 +68,8 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
|
|||||||
throw new Error(data.message || t('auth.invalidCredentials'));
|
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);
|
saveAuth(user, token);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -185,7 +187,8 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
|
|||||||
|
|
||||||
if (!loginRes.ok) throw new Error("Аккаунт создан. Войдите вручную.");
|
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);
|
saveAuth(user, token);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
import { useAuthContext } from "../context/AuthContext";
|
import { useAuthContext } from "../context/AuthContext";
|
||||||
|
import { saveTokens } from "../api/apiClient";
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -14,7 +15,7 @@ export function useAuth() {
|
|||||||
setLoading(true); setError("");
|
setLoading(true); setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/v1/auth`, {
|
const res = await fetch(`${API_URL}/api/v1/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ login, password }),
|
body: JSON.stringify({ login, password }),
|
||||||
@@ -25,9 +26,16 @@ export function useAuth() {
|
|||||||
throw new Error(data.message || "Invalid login or password");
|
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);
|
saveAuth(user, token);
|
||||||
|
|
||||||
navigate("/feed");
|
navigate("/feed");
|
||||||
@@ -39,4 +47,4 @@ export function useAuth() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return { login, loading, error };
|
return { login, loading, error };
|
||||||
}
|
}
|
||||||
@@ -2,35 +2,56 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
|||||||
import { Client } from "@stomp/stompjs";
|
import { Client } from "@stomp/stompjs";
|
||||||
import SockJS from "sockjs-client";
|
import SockJS from "sockjs-client";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
|
||||||
|
|
||||||
export function useChatSocket(chatId, onMessage) {
|
export function useChatSocket(chatId, onMessage) {
|
||||||
const clientRef = useRef(null);
|
const clientRef = useRef(null);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
// Держим актуальный колбэк в ref, чтобы не пересоздавать соединение
|
||||||
|
// при каждом ре-рендере родителя из-за нового onMessage
|
||||||
|
useEffect(() => {
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
}, [onMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const socket = new SockJS(`${API_URL}/ws?token=${token}`);
|
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
webSocketFactory: () => socket,
|
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
|
beforeConnect: async () => {
|
||||||
|
// На каждую попытку коннекта — свежий токен из localStorage
|
||||||
|
client.connectHeaders = {
|
||||||
|
Authorization: `Bearer ${getAccessToken()}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
|
client.subscribe(`/topic/chat/${chatId}`, (msg) => {
|
||||||
const body = JSON.parse(msg.body);
|
const body = JSON.parse(msg.body);
|
||||||
onMessage(body);
|
onMessageRef.current(body);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDisconnect: () => setConnected(false),
|
onDisconnect: () => setConnected(false),
|
||||||
|
onStompError: async (frame) => {
|
||||||
|
// Если сервер отклонил CONNECT из-за протухшего токена — пробуем обновить и переподключиться
|
||||||
|
if (frame.headers?.message?.includes("Unauthorized")) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken();
|
||||||
|
} catch {
|
||||||
|
// refresh тоже не удался — дальше решает общий логин-флоу (redirect на /)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
client.activate();
|
client.activate();
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
return () => client.deactivate();
|
return () => client.deactivate();
|
||||||
}, [chatId, onMessage]);
|
}, [chatId]);
|
||||||
|
|
||||||
const sendMessage = useCallback((content) => {
|
const sendMessage = useCallback((content) => {
|
||||||
if (!clientRef.current?.connected) return;
|
if (!clientRef.current?.connected) return;
|
||||||
@@ -41,4 +62,4 @@ export function useChatSocket(chatId, onMessage) {
|
|||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
return { connected, sendMessage };
|
return { connected, sendMessage };
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { apiFetch } from "../api/apiClient";
|
||||||
import { cachePostsFromChats, setPostForChat } from "../stores/chatPostStore";
|
import { cachePostsFromChats, setPostForChat } from "../stores/chatPostStore";
|
||||||
|
|
||||||
function authHeaders() {
|
|
||||||
return {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChats() {
|
export function useChats() {
|
||||||
const [chats, setChats] = useState([]);
|
const [chats, setChats] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -16,7 +10,7 @@ export function useChats() {
|
|||||||
const fetchChats = useCallback(async () => {
|
const fetchChats = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
// API may return an array directly or a wrapper object like { items: [] } / { content: [] }
|
// API may return an array directly or a wrapper object like { items: [] } / { content: [] }
|
||||||
const list = Array.isArray(data) ? data : (data.items ?? data.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}`);
|
const url = new URL(`${API_URL}/api/v1/chats/with/${otherUserId}`);
|
||||||
if (postId) url.searchParams.set("postId", postId);
|
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("Не удалось создать чат");
|
if (!res.ok) throw new Error("Не удалось создать чат");
|
||||||
const chat = await res.json();
|
const chat = await res.json();
|
||||||
if (postInfo && chat.id) {
|
if (postInfo && chat.id) {
|
||||||
@@ -41,7 +35,7 @@ export function useChats() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchMessages = useCallback(async (chatId) => {
|
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();
|
const data = await res.json();
|
||||||
// Normalize: array or wrapper object
|
// Normalize: array or wrapper object
|
||||||
return Array.isArray(data) ? data : (data.items ?? data.content ?? []);
|
return Array.isArray(data) ? data : (data.items ?? data.content ?? []);
|
||||||
@@ -49,9 +43,8 @@ export function useChats() {
|
|||||||
|
|
||||||
const markAsRead = useCallback(async (chatId) => {
|
const markAsRead = useCallback(async (chatId) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_URL}/api/v1/chats/${chatId}/read`, {
|
await apiFetch(`${API_URL}/api/v1/chats/${chatId}/read`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to mark as read:", e);
|
console.error("Failed to mark as read:", e);
|
||||||
@@ -59,4 +52,4 @@ export function useChats() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { chats, loading, fetchChats, createOrGetChat, fetchMessages, markAsRead };
|
return { chats, loading, fetchChats, createOrGetChat, fetchMessages, markAsRead };
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { apiFetch } from "../api/apiClient";
|
||||||
|
|
||||||
export function useCreatePost() {
|
export function useCreatePost() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -11,18 +12,13 @@ export function useCreatePost() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token");
|
const res = await apiFetch(`${API_URL}/api/v1/posts`, {
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/api/v1/posts`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
// Content-Type НЕ указываем — браузер сам добавит multipart/form-data с boundary
|
||||||
// Content-Type НЕ указываем — браузер сам добавит multipart/form-data с boundary
|
|
||||||
"Authorization": `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData, // ← передаём FormData
|
body: formData, // ← передаём FormData
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json(); // ← объявляем ПОСЛЕ await res
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.message || "Error creating ad");
|
throw new Error(data.message || "Error creating ad");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { apiFetch } from "../api/apiClient";
|
||||||
|
|
||||||
export function useDeleteChat() {
|
export function useDeleteChat() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -9,12 +10,8 @@ export function useDeleteChat() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token");
|
const res = await apiFetch(`${API_URL}/api/v1/chats/${chatId}`, {
|
||||||
const res = await fetch(`${API_URL}/api/v1/chats/${chatId}`, {
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Ошибка ${res.status}`);
|
throw new Error(`Ошибка ${res.status}`);
|
||||||
@@ -29,4 +26,4 @@ export function useDeleteChat() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { deleteChat, loading, error };
|
return { deleteChat, loading, error };
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { apiFetch } from "../api/apiClient";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useDeletePost — хук для удаления объявления.
|
* useDeletePost — хук для удаления объявления.
|
||||||
*
|
*
|
||||||
* @returns {Object} { deletePost, loading, error }
|
* @returns {Object} { deletePost, loading, error }
|
||||||
*/
|
*/
|
||||||
export function useDeletePost() {
|
export function useDeletePost() {
|
||||||
@@ -13,14 +14,10 @@ export function useDeletePost() {
|
|||||||
const deletePost = useCallback(async (postId) => {
|
const deletePost = useCallback(async (postId) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token");
|
const res = await apiFetch(`${API_URL}/api/v1/posts/${postId}`, {
|
||||||
const res = await fetch(`${API_URL}/api/v1/posts/${postId}`, {
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -37,4 +34,4 @@ export function useDeletePost() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { deletePost, loading, error };
|
return { deletePost, loading, error };
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { API_URL } from '../config/authConstants';
|
import { API_URL } from '../config/authConstants';
|
||||||
|
import { apiFetch } from '../api/apiClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Хук для работы с лайками (избранное).
|
* Хук для работы с лайками (избранное).
|
||||||
@@ -9,21 +10,12 @@ export function useFavorites() {
|
|||||||
/**
|
/**
|
||||||
* Переключает лайк на посте через API.
|
* Переключает лайк на посте через API.
|
||||||
* @param {number} postId - ID поста
|
* @param {number} postId - ID поста
|
||||||
* @param {string} token - JWT токен авторизации
|
|
||||||
* @returns {Promise<boolean>} - true если операция успешна
|
* @returns {Promise<boolean>} - true если операция успешна
|
||||||
*/
|
*/
|
||||||
const toggleFavorite = useCallback(async (postId, token) => {
|
const toggleFavorite = useCallback(async (postId) => {
|
||||||
if (!token) {
|
|
||||||
console.warn('Cannot toggle favorite: no token');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -39,20 +31,11 @@ export function useFavorites() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Загружает список лайкнутых постов текущего пользователя.
|
* Загружает список лайкнутых постов текущего пользователя.
|
||||||
* @param {string} token - JWT токен авторизации
|
|
||||||
* @returns {Promise<Array>} - массив ID лайкнутых постов
|
* @returns {Promise<Array>} - массив ID лайкнутых постов
|
||||||
*/
|
*/
|
||||||
const loadFavorites = useCallback(async (token) => {
|
const loadFavorites = useCallback(async () => {
|
||||||
if (!token) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/api/v1/favorites`, {
|
const response = await apiFetch(`${API_URL}/api/v1/favorites`);
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load favorites: ${response.status}`);
|
throw new Error(`Failed to load favorites: ${response.status}`);
|
||||||
@@ -71,4 +54,4 @@ export function useFavorites() {
|
|||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
loadFavorites,
|
loadFavorites,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,17 @@ import { getImageUrl } from "../config/authConstants";
|
|||||||
const MAX_SIZE_MB = 5;
|
const MAX_SIZE_MB = 5;
|
||||||
export const ACCEPT = "image/jpeg,image/png,image/webp";
|
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 = []) {
|
export function useImageUpload(existingUrls = []) {
|
||||||
const [images, setImages] = useState([]);
|
const [images, setImages] = useState([]);
|
||||||
@@ -49,7 +58,7 @@ export function useImageUpload(existingUrls = []) {
|
|||||||
try {
|
try {
|
||||||
const compressedFiles = await Promise.all(files.map(f => compressImage(f)));
|
const compressedFiles = await Promise.all(files.map(f => compressImage(f)));
|
||||||
const newImages = compressedFiles.map(file => ({
|
const newImages = compressedFiles.map(file => ({
|
||||||
id: nextId(),
|
id: generateImageId(),
|
||||||
file,
|
file,
|
||||||
preview: URL.createObjectURL(file),
|
preview: URL.createObjectURL(file),
|
||||||
isExisting: false,
|
isExisting: false,
|
||||||
@@ -103,4 +112,4 @@ export function useImageUpload(existingUrls = []) {
|
|||||||
getFilesForUpload,
|
getFilesForUpload,
|
||||||
getExistingUrlsToKeep,
|
getExistingUrlsToKeep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { API_URL } from "../config/authConstants";
|
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 передан → PUT /api/v1/posts/:id (обновление)
|
||||||
* id не передан → POST /api/v1/posts (создание)
|
* id не передан → POST /api/v1/posts (создание)
|
||||||
*
|
*
|
||||||
* Автоматически добавляет заголовок Authorization с JWT-токеном.
|
* Авторизация (Authorization header + авто-обновление токена при истечении)
|
||||||
|
* обрабатывается внутри apiFetch.
|
||||||
*
|
*
|
||||||
* save() возвращает:
|
* save() возвращает:
|
||||||
* { data } — успешный ответ сервера
|
* { data } — успешный ответ сервера
|
||||||
@@ -29,21 +31,13 @@ export function usePostMutation() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const url = id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`;
|
const url = id ? `${API_URL}/api/v1/posts/${id}` : `${API_URL}/api/v1/posts`;
|
||||||
const method = id ? "PUT" : "POST";
|
const method = id ? "PUT" : "POST";
|
||||||
const res = await fetch(url, {
|
const res = await apiFetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: data,
|
body: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
throw new Error("Требуется авторизация. Войдите в аккаунт.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let body;
|
let body;
|
||||||
try { body = await res.json(); } catch { body = null; }
|
try { body = await res.json(); } catch { body = null; }
|
||||||
@@ -65,4 +59,4 @@ export function usePostMutation() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return { save, loading, error };
|
return { save, loading, error };
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { API_URL } from '../config/authConstants';
|
import { API_URL } from '../config/authConstants';
|
||||||
|
import { apiFetch } from '../api/apiClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Хук для изменения статуса поста (ACTIVE/CLOSED).
|
* Хук для изменения статуса поста (ACTIVE/CLOSED).
|
||||||
@@ -9,21 +10,13 @@ export function usePostStatus() {
|
|||||||
/**
|
/**
|
||||||
* Изменяет статус поста на CLOSED (вещь найдена).
|
* Изменяет статус поста на CLOSED (вещь найдена).
|
||||||
* @param {number} postId - ID поста
|
* @param {number} postId - ID поста
|
||||||
* @param {string} token - JWT токен авторизации
|
|
||||||
* @returns {Promise<boolean>} - true если операция успешна
|
* @returns {Promise<boolean>} - true если операция успешна
|
||||||
*/
|
*/
|
||||||
const markAsFound = useCallback(async (postId, token) => {
|
const markAsFound = useCallback(async (postId) => {
|
||||||
if (!token) {
|
|
||||||
console.warn('Cannot mark post as found: no token');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Используем PATCH endpoint для обновления статуса
|
const response = await apiFetch(`${API_URL}/api/v1/posts/${postId}/status`, {
|
||||||
const response = await fetch(`${API_URL}/api/v1/posts/${postId}/status`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ status: 'CLOSED' }),
|
body: JSON.stringify({ status: 'CLOSED' }),
|
||||||
@@ -43,20 +36,13 @@ export function usePostStatus() {
|
|||||||
/**
|
/**
|
||||||
* Возвращает статус поста в ACTIVE (если нужно reopen).
|
* Возвращает статус поста в ACTIVE (если нужно reopen).
|
||||||
* @param {number} postId - ID поста
|
* @param {number} postId - ID поста
|
||||||
* @param {string} token - JWT токен авторизации
|
|
||||||
* @returns {Promise<boolean>} - true если операция успешна
|
* @returns {Promise<boolean>} - true если операция успешна
|
||||||
*/
|
*/
|
||||||
const reopenPost = useCallback(async (postId, token) => {
|
const reopenPost = useCallback(async (postId) => {
|
||||||
if (!token) {
|
|
||||||
console.warn('Cannot reopen post: no token');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ status: 'ACTIVE' }),
|
body: JSON.stringify({ status: 'ACTIVE' }),
|
||||||
@@ -77,4 +63,4 @@ export function usePostStatus() {
|
|||||||
markAsFound,
|
markAsFound,
|
||||||
reopenPost,
|
reopenPost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
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) {
|
export function useCheckLogin(login) {
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
@@ -15,7 +16,7 @@ export function useCheckLogin(login) {
|
|||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
setChecking(true);
|
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();
|
const json = await res.json();
|
||||||
setExists(json.exists);
|
setExists(json.exists);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -31,8 +32,8 @@ export function useCheckLogin(login) {
|
|||||||
return { checking, exists };
|
return { checking, exists };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useRegister() {
|
export function useRegister() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
@@ -54,10 +55,9 @@ export function useRegister() {
|
|||||||
throw new Error(data.message || "Ошибка регистрации");
|
throw new Error(data.message || "Ошибка регистрации");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveTokens(data.token, data.refreshToken);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => navigate("/feed"), 1500);
|
||||||
window.location.href = "/register";
|
|
||||||
}, 2500);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SockJS from "sockjs-client";
|
|||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
import { useUnread } from "../context/UnreadContext";
|
import { useUnread } from "../context/UnreadContext";
|
||||||
import { getActiveChatId } from "../stores/activeChatStore";
|
import { getActiveChatId } from "../stores/activeChatStore";
|
||||||
|
import { getAccessToken, refreshAccessToken } from "../api/apiClient";
|
||||||
|
|
||||||
export function useUnreadSocket(userId) {
|
export function useUnreadSocket(userId) {
|
||||||
const { setChatUnread } = useUnread();
|
const { setChatUnread } = useUnread();
|
||||||
@@ -12,12 +13,14 @@ export function useUnreadSocket(userId) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const socket = new SockJS(`${API_URL}/ws?token=${token}`);
|
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
webSocketFactory: () => socket,
|
webSocketFactory: () => new SockJS(`${API_URL}/ws`),
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
|
beforeConnect: () => {
|
||||||
|
client.connectHeaders = {
|
||||||
|
Authorization: `Bearer ${getAccessToken()}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
client.subscribe(`/topic/user/${userId}/unread`, (msg) => {
|
client.subscribe(`/topic/user/${userId}/unread`, (msg) => {
|
||||||
const body = JSON.parse(msg.body);
|
const body = JSON.parse(msg.body);
|
||||||
@@ -25,6 +28,15 @@ export function useUnreadSocket(userId) {
|
|||||||
setChatUnread(body.chatId, body.unreadCount);
|
setChatUnread(body.chatId, body.unreadCount);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onStompError: async (frame) => {
|
||||||
|
if (frame.headers?.message?.includes("Unauthorized")) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken();
|
||||||
|
} catch {
|
||||||
|
// refresh не удался — общий логин-флоу разберётся при следующем apiFetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
client.activate();
|
client.activate();
|
||||||
@@ -32,4 +44,4 @@ export function useUnreadSocket(userId) {
|
|||||||
|
|
||||||
return () => client.deactivate();
|
return () => client.deactivate();
|
||||||
}, [userId, setChatUnread]);
|
}, [userId, setChatUnread]);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
import { apiFetch } from "../api/apiClient";
|
||||||
|
|
||||||
export function useUpdatePost() {
|
export function useUpdatePost() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -9,13 +10,8 @@ export function useUpdatePost() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token");
|
const res = await apiFetch(`${API_URL}/api/v1/posts/${id}`, {
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/api/v1/posts/${id}`, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData, // FormData
|
body: formData, // FormData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import {useState} from "react";
|
||||||
import { API_URL } from "../config/authConstants";
|
import {API_URL} from "../config/authConstants";
|
||||||
|
import {apiFetch} from "../api/apiClient";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useUpdateProfile — хук для обновления профиля пользователя.
|
* useUpdateProfile — хук для обновления профиля пользователя.
|
||||||
@@ -14,8 +15,6 @@ export function useUpdateProfile() {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
|
|
||||||
// Используем FormData — чтобы отправить файл аватара вместе с текстовыми полями
|
// Используем FormData — чтобы отправить файл аватара вместе с текстовыми полями
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("id", userId);
|
formData.append("id", userId);
|
||||||
@@ -30,11 +29,8 @@ export function useUpdateProfile() {
|
|||||||
formData.append("avatar", profileData.avatar);
|
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",
|
method: "PUT",
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,8 +39,7 @@ export function useUpdateProfile() {
|
|||||||
throw new Error(data.message || `Ошибка ${res.status}`);
|
throw new Error(data.message || `Ошибка ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await res.json();
|
return await res.json();
|
||||||
return updatedUser;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || "Failed to save profile");
|
setError(err.message || "Failed to save profile");
|
||||||
return null;
|
return null;
|
||||||
@@ -54,4 +49,4 @@ export function useUpdateProfile() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return { updateProfile, loading, error };
|
return { updateProfile, loading, error };
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user