Merge pull request #16 from SlimusMinus/add_secure

added security and fix edit profile
This commit is contained in:
SlimusMinus
2026-06-18 00:25:53 +03:00
committed by GitHub
9 changed files with 259 additions and 76 deletions

View File

@@ -1,18 +1,21 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import RegisterPage from './components/Authentication'; import RegisterPage from './components/Authentication';
import MainFeed from './components/MainFeed'; // ← добавь импорт import MainFeed from './components/MainFeed';
function App() { function App() {
return ( return (
<BrowserRouter> <AuthProvider>
<Routes> <BrowserRouter>
<Route path="/" element={<Navigate to="/feed" replace />} /> <Routes>
<Route path="/" element={<Navigate to="/feed" replace />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/feed" element={<MainFeed />} /> <Route path="/feed" element={<MainFeed />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider>
); );
} }
export default App; export default App;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import "../styles/index.css"; import "../styles/index.css";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
@@ -7,6 +7,8 @@ import PostOnFeed from "./PostOnFeed";
import CreateUpdatePost from "./CreateUpdatePost"; import CreateUpdatePost from "./CreateUpdatePost";
import MenuIcon from "./icons/MenuIcon"; import MenuIcon from "./icons/MenuIcon";
import { ProfileModal } from "./profile"; import { ProfileModal } from "./profile";
import AuthModal from "./AuthModal";
import { useAuthContext } from "../context/AuthContext";
/** /**
* MainFeed — корневой компонент приложения. * MainFeed — корневой компонент приложения.
@@ -14,6 +16,8 @@ import { ProfileModal } from "./profile";
* остальные компоненты получают данные через пропсы. * остальные компоненты получают данные через пропсы.
*/ */
export default function MainFeed() { export default function MainFeed() {
const { user, token, isAuthenticated, logout } = useAuthContext();
// Открыт ли левый сайдбар (мобильный слайд-аут) // Открыт ли левый сайдбар (мобильный слайд-аут)
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
// Активный пункт навигации в сайдбаре // Активный пункт навигации в сайдбаре
@@ -26,21 +30,32 @@ export default function MainFeed() {
const [editAd, setEditAd] = useState(null); const [editAd, setEditAd] = useState(null);
// Открыто ли модальное окно редактирования профиля // Открыто ли модальное окно редактирования профиля
const [profileModalOpen, setProfileModalOpen] = useState(false); const [profileModalOpen, setProfileModalOpen] = useState(false);
// Данные профиля пользователя // Открыто ли модальное окно аутентификации (login / register)
const [profileData, setProfileData] = useState({ const [authModalOpen, setAuthModalOpen] = useState(false);
firstName: "Иван", // Массив объявлений из API (основная лента)
lastName: "Иванов",
email: "ivan@example.com",
phone: "+7 (999) 123-45-67",
});
// Массив объявлений из API
const [ads, setAds] = useState([]); const [ads, setAds] = useState([]);
// Объявления текущего пользователя (загружаются отдельно по userId)
const [myAds, setMyAds] = useState([]);
// Set id объявлений, которые пользователь лайкнул (только локально) // Set id объявлений, которые пользователь лайкнул (только локально)
const [liked, setLiked] = useState(new Set()); const [liked, setLiked] = useState(new Set());
// Состояния загрузки и ошибки для ленты // Состояния загрузки и ошибки для ленты
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
// Данные профиля — из контекста авторизации (или пустой объект для гостя)
const profileData = useMemo(() => {
if (!user) return {};
return {
login: user.login || "",
firstName: user.name || user.firstName || "",
lastName: user.lastName || "",
email: user.email || "",
phone: user.phone || "",
avatar: user.avatar || null,
id: user.id,
};
}, [user]);
/** Загружает ленту объявлений с бэкенда. */ /** Загружает ленту объявлений с бэкенда. */
const loadFeed = useCallback(async () => { const loadFeed = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -67,6 +82,33 @@ export default function MainFeed() {
// Первичная загрузка при монтировании компонента // Первичная загрузка при монтировании компонента
useEffect(() => { loadFeed(); }, [loadFeed]); useEffect(() => { loadFeed(); }, [loadFeed]);
/** Загружает объявления текущего пользователя по userId с бэкенда. */
const loadMyPosts = useCallback(async () => {
if (!user?.id) return;
setLoading(true);
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/posts/${user.id}`);
if (res.status === 404) {
// У пользователя пока нет объявлений — это нормальная ситуация
setMyAds([]);
return;
}
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
const data = await res.json();
const posts = Array.isArray(data) ? data : (data.items ?? []);
const normalizedPosts = posts.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setMyAds(normalizedPosts);
} catch (err) {
setError(err.message || "Не удалось загрузить ваши объявления");
} finally {
setLoading(false);
}
}, [user?.id]);
/** /**
* Переключает лайк на объявлении. * Переключает лайк на объявлении.
* Состояние хранится только на клиенте — без запроса к API. * Состояние хранится только на клиенте — без запроса к API.
@@ -81,20 +123,58 @@ export default function MainFeed() {
// После создания или редактирования объявления перезагружаем ленту, // После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД // чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => loadFeed(), [loadFeed]); const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
const handleEditSave = useCallback(() => loadFeed(), [loadFeed]); const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
/**
* Обработчик нажатия «Разместить объявление».
* Если пользователь не авторизован — открывает модалку входа.
*/
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setModalOpen(true);
}
}, [isAuthenticated]);
/**
* Обработчик редактирования — разрешён только автору объявления.
*/
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) {
setAuthModalOpen(true);
} else {
setEditAd(ad);
}
}, [isAuthenticated]);
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId
useEffect(() => {
if (activeNav === "Мои объявления" && isAuthenticated && user?.id) {
loadMyPosts();
}
}, [activeNav, isAuthenticated, user?.id, loadMyPosts]);
// Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа // Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа
const filteredAds = (() => { const filteredAds = (() => {
let result = activeNav === "Главная" let result;
? ads
: ads.filter(ad => if (activeNav === "Мои объявления") {
activeNav === "Вещи" // Используем объявления, загруженные с бэкенда по userId
? ["Вещь", "Другое"].includes(ad.category) result = myAds;
: activeNav === "Питомцы" } else if (activeNav === "Главная") {
? ad.category === "Питомец" result = ads;
: false } else {
); result = ads.filter(ad =>
activeNav === "Вещи"
? ["Вещь", "Другое"].includes(ad.category)
: activeNav === "Питомцы"
? ad.category === "Питомец"
: false
);
}
// Применяем фильтр по типу объявления // Применяем фильтр по типу объявления
if (typeFilter) { if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter); result = result.filter(ad => ad.type === typeFilter);
@@ -130,13 +210,17 @@ export default function MainFeed() {
); );
} }
if (!filteredAds.length) { if (!filteredAds.length) {
const emptyMsg = activeNav === "Мои объявления"
? { title: "У вас нет объявлений", sub: "Создайте первое — нажмите кнопку выше" }
: { title: "Объявлений пока нет", sub: "Будьте первым — разместите объявление<br />о потере или находке" };
return ( return (
<div className="f-center"> <div className="f-center">
<div className="f-center-icon">🔍</div> <div className="f-center-icon">🔍</div>
<div className="f-center-title">Объявлений пока нет</div> <div className="f-center-title">{emptyMsg.title}</div>
<div className="f-center-sub"> <div className="f-center-sub"
Будьте первым — разместите объявление<br />о потере или находке dangerouslySetInnerHTML={{ __html: emptyMsg.sub }}
</div> />
</div> </div>
); );
} }
@@ -144,9 +228,11 @@ export default function MainFeed() {
<PostOnFeed <PostOnFeed
key={ad.id} key={ad.id}
ad={ad} ad={ad}
liked={liked.has(ad.id)} // передаём локальное состояние лайка liked={liked.has(ad.id)}
onLike={toggleLike} // колбэк для переключения лайка onLike={toggleLike}
onEdit={setEditAd} // колбэк открывает модалку редактирования onEdit={handleEdit}
// На вкладке «Мои объявления» все посты принадлежат пользователю
isOwner={activeNav === "Мои объявления" || (isAuthenticated && ad.userId === user?.id)}
/> />
)); ));
}; };
@@ -159,7 +245,14 @@ export default function MainFeed() {
activeNav={activeNav} activeNav={activeNav}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
onNavClick={setActiveNav} onNavClick={setActiveNav}
onEditProfile={() => setProfileModalOpen(true)} onEditProfile={() => {
if (!isAuthenticated) { setAuthModalOpen(true); return; }
setProfileModalOpen(true);
}}
profileData={profileData}
isAuthenticated={isAuthenticated}
onLogout={logout}
onLoginClick={() => setAuthModalOpen(true)}
/> />
<div className="f-main"> <div className="f-main">
@@ -169,7 +262,7 @@ export default function MainFeed() {
<MenuIcon /> <MenuIcon />
</button> </button>
<div className="f-topbar-center"> <div className="f-topbar-center">
<button className="f-post-btn" onClick={() => setModalOpen(true)}> <button className="f-post-btn" onClick={handleCreatePost}>
<span style={{ fontSize: "1.15rem", fontWeight: 900, lineHeight: 1 }}>+</span> <span style={{ fontSize: "1.15rem", fontWeight: 900, lineHeight: 1 }}>+</span>
Разместить объявление Разместить объявление
</button> </button>
@@ -210,8 +303,8 @@ export default function MainFeed() {
</div> </div>
</div> </div>
{/* Модалка создания нового объявления */} {/* Модалка создания нового объявления (только для авторизованных) */}
{modalOpen && ( {modalOpen && isAuthenticated && (
<CreateUpdatePost <CreateUpdatePost
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
onSuccess={handleNewAd} onSuccess={handleNewAd}
@@ -219,7 +312,7 @@ export default function MainFeed() {
)} )}
{/* Модалка редактирования — открывается когда editAd !== null */} {/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && ( {editAd && isAuthenticated && (
<CreateUpdatePost <CreateUpdatePost
initialData={editAd} initialData={editAd}
onClose={() => setEditAd(null)} onClose={() => setEditAd(null)}
@@ -228,12 +321,19 @@ export default function MainFeed() {
)} )}
{/* Модальное окно редактирования профиля */} {/* Модальное окно редактирования профиля */}
{profileModalOpen && ( {profileModalOpen && isAuthenticated && (
<ProfileModal <ProfileModal
onClose={() => setProfileModalOpen(false)} onClose={() => setProfileModalOpen(false)}
initialData={profileData} initialData={profileData}
/> />
)} )}
{/* Модальное окно входа / регистрации */}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -5,7 +5,7 @@ import MapIcon from "./icons/MapIcon";
import HeartIcon from "./icons/HeartIcon"; import HeartIcon from "./icons/HeartIcon";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
export default function PostOnFeed({ ad, liked, onLike, onEdit }) { export default function PostOnFeed({ ad, liked, onLike, onEdit, isOwner }) {
const imageUrl = ad.images?.[0] ? `${API_URL}${ad.images[0]}` : null; const imageUrl = ad.images?.[0] ? `${API_URL}${ad.images[0]}` : null;
// ✅ Формируем строку адреса из city + district // ✅ Формируем строку адреса из city + district
@@ -53,7 +53,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit }) {
)} )}
</div> </div>
<div className="f-card-foot-right"> <div className="f-card-foot-right">
{onEdit && ( {onEdit && isOwner && (
<button className="f-like-btn" <button className="f-like-btn"
onClick={e => { e.stopPropagation(); onEdit(ad); }}> onClick={e => { e.stopPropagation(); onEdit(ad); }}>
✏️ ✏️
@@ -70,4 +70,4 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit }) {
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,17 +1,19 @@
import LogoIcon from "./icons/LogoIcon"; import LogoIcon from "./icons/LogoIcon";
import React, { useState } from "react";
import {NAV_BOTTOM, NAV_ITEMS} from "../config/feedConstants"; import {NAV_BOTTOM, NAV_ITEMS} from "../config/feedConstants";
import {NavIcons} from "./icons/NavIcons"; import {NavIcons} from "./icons/NavIcons";
import Profile from "./profile/Profile"; import Profile from "./profile/Profile";
export default function Sidebar({ open, activeNav, onClose, onNavClick, onEditProfile }) { export default function Sidebar({
const [profileData] = useState({ open,
firstName: "Иван", activeNav,
lastName: "Иванов", onClose,
email: "ivan@example.com", onNavClick,
phone: "+7 (999) 123-45-67", onEditProfile,
}); profileData = {},
isAuthenticated,
onLogout,
onLoginClick,
}) {
return ( return (
<> <>
<div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} /> <div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} />
@@ -23,11 +25,20 @@ export default function Sidebar({ open, activeNav, onClose, onNavClick, onEditPr
<nav className="f-sb-nav"> <nav className="f-sb-nav">
{NAV_ITEMS.map(({ icon, label }) => { {NAV_ITEMS.map(({ icon, label }) => {
const Ic = NavIcons[icon]; const Ic = NavIcons[icon];
const handleClick = () => {
// «Мои объявления» и «Чат» требуют авторизации
if (!isAuthenticated && (label === "Мои объявления" || label === "Чат")) {
onLoginClick();
} else {
onNavClick(label);
}
onClose();
};
return ( return (
<div <div
key={label} key={label}
className={`f-sb-item${activeNav === label ? " active" : ""}`} className={`f-sb-item${activeNav === label ? " active" : ""}`}
onClick={() => { onNavClick(label); onClose(); }} onClick={handleClick}
> >
<Ic /> {label} <Ic /> {label}
</div> </div>
@@ -48,7 +59,27 @@ export default function Sidebar({ open, activeNav, onClose, onNavClick, onEditPr
); );
})} })}
<div className="f-sb-divider" /> <div className="f-sb-divider" />
<Profile data={profileData} onEdit={onEditProfile} />
{isAuthenticated ? (
<>
<Profile data={profileData} onEdit={onEditProfile} />
<div
className="f-sb-item"
onClick={() => { onLogout(); onClose(); }}
style={{ color: "rgba(255,107,107,.7)", marginTop: 8 }}
>
<span style={{ fontSize: "1.1rem" }}>🚪</span> Выйти
</div>
</>
) : (
<div
className="f-sb-item"
onClick={() => { onLoginClick(); onClose(); }}
style={{ color: "#2de0c8" }}
>
<span style={{ fontSize: "1.1rem" }}>🔑</span> Войти
</div>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -60,6 +60,19 @@ function ImageUpload({
); );
} }
// ─── Login field ─────────────────────────────────────────────────────────────
function LoginField({ form, errors, onChange }) {
return (
<InputField
label="Логин"
placeholder="username"
value={form.login}
error={errors.login}
onChange={(e) => onChange("login", e.target.value)}
/>
);
}
// ─── Name fields row ───────────────────────────────────────────────────────── // ─── Name fields row ─────────────────────────────────────────────────────────
function NameFieldsRow({ form, errors, onChange }) { function NameFieldsRow({ form, errors, onChange }) {
return ( return (
@@ -134,4 +147,4 @@ function ModalHeader({ onClose }) {
); );
} }
export { InputField, ImageUpload, NameFieldsRow, ContactFields, SubmitButton, ModalHeader }; export { InputField, ImageUpload, LoginField, NameFieldsRow, ContactFields, SubmitButton, ModalHeader };

View File

@@ -1,9 +1,12 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { formatPhone } from "../../utils/formatPhone"; import { formatPhone } from "../../utils/formatPhone";
import { useImageUpload } from "../../hooks/useImageUpload"; import { useImageUpload } from "../../hooks/useImageUpload";
import { useUpdateProfile } from "../../hooks/useUpdateProfile";
import { useAuthContext } from "../../context/AuthContext";
import { validateProfileForm, initializeFormState } from "./profileValidation"; import { validateProfileForm, initializeFormState } from "./profileValidation";
import { import {
ImageUpload, ImageUpload,
LoginField,
NameFieldsRow, NameFieldsRow,
ContactFields, ContactFields,
SubmitButton, SubmitButton,
@@ -11,6 +14,8 @@ import {
} from "./ProfileFormFields"; } from "./ProfileFormFields";
export default function ProfileModal({ onClose, initialData = {} }) { export default function ProfileModal({ onClose, initialData = {} }) {
const { user, updateUser } = useAuthContext();
const { updateProfile, loading: saving, error: saveError } = useUpdateProfile();
const [form, setForm] = useState(initializeFormState(initialData)); const [form, setForm] = useState(initializeFormState(initialData));
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
@@ -47,15 +52,20 @@ export default function ProfileModal({ onClose, initialData = {} }) {
} }
const profileData = { const profileData = {
firstName: form.firstName.trim(), name: form.firstName.trim(),
lastName: form.lastName.trim(), login: form.login.trim(),
phone: form.phone.trim(), phone: form.phone.trim(),
email: form.email.trim(), email: form.email.trim(),
avatar: imageFile || existingImageUrl, avatar: imageFile || existingImageUrl,
}; };
console.log("Profile data to save:", profileData); const updatedUser = await updateProfile(user?.id, profileData);
onClose();
if (updatedUser) {
// Обновляем данные пользователя в контексте и localStorage
updateUser(updatedUser);
onClose();
}
}; };
return ( return (
@@ -72,6 +82,12 @@ export default function ProfileModal({ onClose, initialData = {} }) {
onRemove={removeImage} onRemove={removeImage}
/> />
<LoginField
form={form}
errors={errors}
onChange={handleFieldChange}
/>
<NameFieldsRow <NameFieldsRow
form={form} form={form}
errors={errors} errors={errors}
@@ -85,7 +101,9 @@ export default function ProfileModal({ onClose, initialData = {} }) {
onPhoneChange={handlePhoneChange} onPhoneChange={handlePhoneChange}
/> />
<SubmitButton loading={compressing} onSubmit={handleSubmit} /> {saveError && <p className="f-err-msg" style={{ marginBottom: 10 }}>{saveError}</p>}
<SubmitButton loading={compressing || saving} onSubmit={handleSubmit} />
</div> </div>
</div> </div>
); );

View File

@@ -24,9 +24,10 @@ export function validateProfileForm(form) {
// ─── Form state initialization ────────────────────────────────────────────── // ─── Form state initialization ──────────────────────────────────────────────
export function initializeFormState(initialData = {}) { export function initializeFormState(initialData = {}) {
return { return {
login: initialData.login || "",
firstName: initialData.firstName || "", firstName: initialData.firstName || "",
lastName: initialData.lastName || "", lastName: initialData.lastName || "",
phone: initialData.phone || "", phone: initialData.phone || "",
email: initialData.email || "", email: initialData.email || "",
}; };
} }

View File

@@ -1,14 +1,16 @@
import { useState } from "react"; 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";
export function useAuth() { export function useAuth() {
const navigate = useNavigate(); const navigate = useNavigate();
const { login: saveAuth } = useAuthContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const login = async (login, password) => { const login = async (login, password) => {
if (!login || !password) { setError("Введи login и пароль"); return; } if (!login || !password) { setError("Введите логин и пароль"); return; }
setLoading(true); setError(""); setLoading(true); setError("");
try { try {
@@ -20,13 +22,15 @@ export function useAuth() {
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.message || "Неверный login или пароль"); throw new Error(data.message || "Неверный логин или пароль");
} }
const { token, user } = await res.json(); const { token, user } = await res.json();
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user)); // Сохраняем в глобальный контекст + localStorage
navigate("/dashboard"); saveAuth(user, token);
navigate("/feed");
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -34,6 +38,5 @@ export function useAuth() {
} }
}; };
return { login, loading, error }; return { login, loading, error };
} }

View File

@@ -9,6 +9,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-токеном.
*
* @returns {{ * @returns {{
* save: (id: string|null, data: FormData) => Promise<object|null>, * save: (id: string|null, data: FormData) => Promise<object|null>,
* loading: boolean, * loading: boolean,
@@ -28,9 +30,21 @@ 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, { method, body: data }); const res = await fetch(url, {
method,
headers: {
"Authorization": `Bearer ${token}`,
},
body: data,
});
if (res.status === 401 || res.status === 403) {
throw new Error("Требуется авторизация. Войдите в аккаунт.");
}
if (!res.ok) throw new Error(`Ошибка сервера: ${res.status}`); if (!res.ok) throw new Error(`Ошибка сервера: ${res.status}`);
return await res.json(); return await res.json();
} catch (e) { } catch (e) {