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,9 +1,11 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import RegisterPage from './components/Authentication';
import MainFeed from './components/MainFeed'; // ← добавь импорт
import MainFeed from './components/MainFeed';
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/feed" replace />} />
@@ -12,6 +14,7 @@ function App() {
<Route path="/feed" element={<MainFeed />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import "../styles/index.css";
import { API_URL } from "../config/authConstants";
import Sidebar from "./Sidebar";
@@ -7,6 +7,8 @@ import PostOnFeed from "./PostOnFeed";
import CreateUpdatePost from "./CreateUpdatePost";
import MenuIcon from "./icons/MenuIcon";
import { ProfileModal } from "./profile";
import AuthModal from "./AuthModal";
import { useAuthContext } from "../context/AuthContext";
/**
* MainFeed — корневой компонент приложения.
@@ -14,6 +16,8 @@ import { ProfileModal } from "./profile";
* остальные компоненты получают данные через пропсы.
*/
export default function MainFeed() {
const { user, token, isAuthenticated, logout } = useAuthContext();
// Открыт ли левый сайдбар (мобильный слайд-аут)
const [sidebarOpen, setSidebarOpen] = useState(false);
// Активный пункт навигации в сайдбаре
@@ -26,21 +30,32 @@ export default function MainFeed() {
const [editAd, setEditAd] = useState(null);
// Открыто ли модальное окно редактирования профиля
const [profileModalOpen, setProfileModalOpen] = useState(false);
// Данные профиля пользователя
const [profileData, setProfileData] = useState({
firstName: "Иван",
lastName: "Иванов",
email: "ivan@example.com",
phone: "+7 (999) 123-45-67",
});
// Массив объявлений из API
// Открыто ли модальное окно аутентификации (login / register)
const [authModalOpen, setAuthModalOpen] = useState(false);
// Массив объявлений из API (основная лента)
const [ads, setAds] = useState([]);
// Объявления текущего пользователя (загружаются отдельно по userId)
const [myAds, setMyAds] = useState([]);
// Set id объявлений, которые пользователь лайкнул (только локально)
const [liked, setLiked] = useState(new Set());
// Состояния загрузки и ошибки для ленты
const [loading, setLoading] = useState(true);
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 () => {
setLoading(true);
@@ -67,6 +82,33 @@ export default function MainFeed() {
// Первичная загрузка при монтировании компонента
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.
@@ -81,20 +123,58 @@ export default function MainFeed() {
// После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД
const handleNewAd = useCallback(() => loadFeed(), [loadFeed]);
const handleEditSave = useCallback(() => loadFeed(), [loadFeed]);
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
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 = (() => {
let result = activeNav === "Главная"
? ads
: ads.filter(ad =>
let result;
if (activeNav === "Мои объявления") {
// Используем объявления, загруженные с бэкенда по userId
result = myAds;
} else if (activeNav === "Главная") {
result = ads;
} else {
result = ads.filter(ad =>
activeNav === "Вещи"
? ["Вещь", "Другое"].includes(ad.category)
: activeNav === "Питомцы"
? ad.category === "Питомец"
: false
);
}
// Применяем фильтр по типу объявления
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
@@ -130,13 +210,17 @@ export default function MainFeed() {
);
}
if (!filteredAds.length) {
const emptyMsg = activeNav === "Мои объявления"
? { title: "У вас нет объявлений", sub: "Создайте первое — нажмите кнопку выше" }
: { title: "Объявлений пока нет", sub: "Будьте первым — разместите объявление<br />о потере или находке" };
return (
<div className="f-center">
<div className="f-center-icon">🔍</div>
<div className="f-center-title">Объявлений пока нет</div>
<div className="f-center-sub">
Будьте первым — разместите объявление<br />о потере или находке
</div>
<div className="f-center-title">{emptyMsg.title}</div>
<div className="f-center-sub"
dangerouslySetInnerHTML={{ __html: emptyMsg.sub }}
/>
</div>
);
}
@@ -144,9 +228,11 @@ export default function MainFeed() {
<PostOnFeed
key={ad.id}
ad={ad}
liked={liked.has(ad.id)} // передаём локальное состояние лайка
onLike={toggleLike} // колбэк для переключения лайка
onEdit={setEditAd} // колбэк открывает модалку редактирования
liked={liked.has(ad.id)}
onLike={toggleLike}
onEdit={handleEdit}
// На вкладке «Мои объявления» все посты принадлежат пользователю
isOwner={activeNav === "Мои объявления" || (isAuthenticated && ad.userId === user?.id)}
/>
));
};
@@ -159,7 +245,14 @@ export default function MainFeed() {
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
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">
@@ -169,7 +262,7 @@ export default function MainFeed() {
<MenuIcon />
</button>
<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>
Разместить объявление
</button>
@@ -210,8 +303,8 @@ export default function MainFeed() {
</div>
</div>
{/* Модалка создания нового объявления */}
{modalOpen && (
{/* Модалка создания нового объявления (только для авторизованных) */}
{modalOpen && isAuthenticated && (
<CreateUpdatePost
onClose={() => setModalOpen(false)}
onSuccess={handleNewAd}
@@ -219,7 +312,7 @@ export default function MainFeed() {
)}
{/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && (
{editAd && isAuthenticated && (
<CreateUpdatePost
initialData={editAd}
onClose={() => setEditAd(null)}
@@ -228,12 +321,19 @@ export default function MainFeed() {
)}
{/* Модальное окно редактирования профиля */}
{profileModalOpen && (
{profileModalOpen && isAuthenticated && (
<ProfileModal
onClose={() => setProfileModalOpen(false)}
initialData={profileData}
/>
)}
{/* Модальное окно входа / регистрации */}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
/>
)}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import MapIcon from "./icons/MapIcon";
import HeartIcon from "./icons/HeartIcon";
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;
// ✅ Формируем строку адреса из city + district
@@ -53,7 +53,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit }) {
)}
</div>
<div className="f-card-foot-right">
{onEdit && (
{onEdit && isOwner && (
<button className="f-like-btn"
onClick={e => { e.stopPropagation(); onEdit(ad); }}>
✏️

View File

@@ -1,17 +1,19 @@
import LogoIcon from "./icons/LogoIcon";
import React, { useState } from "react";
import {NAV_BOTTOM, NAV_ITEMS} from "../config/feedConstants";
import {NavIcons} from "./icons/NavIcons";
import Profile from "./profile/Profile";
export default function Sidebar({ open, activeNav, onClose, onNavClick, onEditProfile }) {
const [profileData] = useState({
firstName: "Иван",
lastName: "Иванов",
email: "ivan@example.com",
phone: "+7 (999) 123-45-67",
});
export default function Sidebar({
open,
activeNav,
onClose,
onNavClick,
onEditProfile,
profileData = {},
isAuthenticated,
onLogout,
onLoginClick,
}) {
return (
<>
<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_ITEMS.map(({ icon, label }) => {
const Ic = NavIcons[icon];
const handleClick = () => {
// «Мои объявления» и «Чат» требуют авторизации
if (!isAuthenticated && (label === "Мои объявления" || label === "Чат")) {
onLoginClick();
} else {
onNavClick(label);
}
onClose();
};
return (
<div
key={label}
className={`f-sb-item${activeNav === label ? " active" : ""}`}
onClick={() => { onNavClick(label); onClose(); }}
onClick={handleClick}
>
<Ic /> {label}
</div>
@@ -48,7 +59,27 @@ export default function Sidebar({ open, activeNav, onClose, onNavClick, onEditPr
);
})}
<div className="f-sb-divider" />
{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>
</>

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import { API_URL } from "../config/authConstants";
* id передан → PUT /api/v1/posts/:id (обновление)
* id не передан → POST /api/v1/posts (создание)
*
* Автоматически добавляет заголовок Authorization с JWT-токеном.
*
* @returns {{
* save: (id: string|null, data: FormData) => Promise<object|null>,
* loading: boolean,
@@ -28,9 +30,21 @@ 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, { 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}`);
return await res.json();
} catch (e) {