Implement user authentication and account pages
Adds AuthContext with Orval API integration, user login/logout/register flows, and account settings page. Refactors navigation and layouts to use authentication context. Introduces new account-related pages (Login, Register, Logout, AccountSettings), updates PrivateRoute to use AuthContext, and improves error handling in Downloader. Removes obsolete context example and adds comprehensive usage documentation for AuthContext.
This commit is contained in:
@@ -9,33 +9,42 @@ import DroneServisSection from "./pages/home/components/Services/droneServis";
|
|||||||
|
|
||||||
import PrivateRoute from "./routes/PrivateRoute";
|
import PrivateRoute from "./routes/PrivateRoute";
|
||||||
|
|
||||||
//import { UserContextProvider } from "./context/UserContext";
|
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
||||||
import ContactPage from "./pages/contact/ContactPage";
|
import ContactPage from "./pages/contact/ContactPage";
|
||||||
import ScrollToTop from "./components/common/ScrollToTop";
|
import ScrollToTop from "./components/common/ScrollToTop";
|
||||||
|
|
||||||
|
|
||||||
|
import AuthLayout from "./layouts/AuthLayout";
|
||||||
|
import LogoutPage from "./pages/account/Logout";
|
||||||
|
import LoginPage from "./pages/account/Login";
|
||||||
|
import RegisterPage from "./pages/account/Register";
|
||||||
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
{/* <UserContextProvider> */}
|
<ScrollToTop />
|
||||||
<ScrollToTop />
|
<Routes>
|
||||||
<Routes>
|
{/* Public routes */}
|
||||||
{/* Public routes */}
|
<Route path="/" element={<HomeLayout />}>
|
||||||
<Route path="/" element={<HomeLayout />}>
|
<Route index element={<Home />} />
|
||||||
<Route index element={<Home />} />
|
<Route path="portfolio" element={<PortfolioPage />} />
|
||||||
<Route path="portfolio" element={<PortfolioPage />} />
|
<Route path="contact" element={<ContactPage />} />
|
||||||
<Route path="contact" element={<ContactPage />} />
|
|
||||||
|
|
||||||
{/* APPS */}
|
{/* APPS */}
|
||||||
<Route path="apps/downloader" element={<Downloader />} />
|
<Route path="apps/downloader" element={<Downloader />} />
|
||||||
|
|
||||||
{/* SERVICES */}
|
{/* SERVICES */}
|
||||||
<Route path="services/drone" element={< DroneServisSection />} />
|
<Route path="services/drone" element={< DroneServisSection />} />
|
||||||
<Route path="services/web" element={<Downloader />} />
|
<Route path="services/web" element={<Downloader />} />
|
||||||
|
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="auth/" element={<AuthLayout />}>
|
||||||
|
<Route path="login" element={<LoginPage />} />
|
||||||
|
<Route path="register" element={<RegisterPage />} />
|
||||||
|
<Route path="logout" element={<LogoutPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Example protected route group (kept for future use) */}
|
{/* Example protected route group (kept for future use) */}
|
||||||
@@ -43,10 +52,8 @@ export default function App() {
|
|||||||
<Route path="/" element={<ChatLayout />}>
|
<Route path="/" element={<ChatLayout />}>
|
||||||
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
{/* </UserContextProvider> */}
|
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
FaUserCircle,
|
FaUserCircle,
|
||||||
FaSignOutAlt,
|
FaSignOutAlt,
|
||||||
@@ -14,21 +15,18 @@ import {
|
|||||||
FaHandsHelping,
|
FaHandsHelping,
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import {FaClapperboard, FaCubes} from "react-icons/fa6";
|
import {FaClapperboard, FaCubes} from "react-icons/fa6";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import styles from "./navbar.module.css";
|
import styles from "./navbar.module.css";
|
||||||
|
|
||||||
export interface User {
|
export default function Navbar() {
|
||||||
username: string;
|
const { user, isAuthenticated, logout } = useAuth();
|
||||||
email?: string;
|
const navigate = useNavigate();
|
||||||
avatarUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavbarProps {
|
const handleLogin = () => navigate("/login");
|
||||||
user: User | null;
|
const handleLogout = async () => {
|
||||||
onLogin: () => void;
|
await logout();
|
||||||
onLogout: () => void;
|
navigate("/");
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
|
|
||||||
const [mobileMenu, setMobileMenu] = useState(false);
|
const [mobileMenu, setMobileMenu] = useState(false);
|
||||||
const navRef = useRef<HTMLElement | null>(null);
|
const navRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -114,8 +112,8 @@ export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
|
|||||||
<a className={styles.linkSimple} href="#contacts"><FaGlobe className={styles.iconSmall}/> Kontakt</a>
|
<a className={styles.linkSimple} href="#contacts"><FaGlobe className={styles.iconSmall}/> Kontakt</a>
|
||||||
|
|
||||||
{/* right: user area */}
|
{/* right: user area */}
|
||||||
{!user ? (
|
{!isAuthenticated ? (
|
||||||
<a className={styles.linkSimple} onClick={onLogin} aria-label="Přihlásit">
|
<a className={styles.linkSimple} onClick={handleLogin} aria-label="Přihlásit">
|
||||||
<FaSignInAlt className={styles.iconSmall} />
|
<FaSignInAlt className={styles.iconSmall} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -138,7 +136,7 @@ export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
|
|||||||
<a href="/me/settings" role="menuitem">Nastavení</a>
|
<a href="/me/settings" role="menuitem">Nastavení</a>
|
||||||
<a href="/me/billing" role="menuitem">Platby</a>
|
<a href="/me/billing" role="menuitem">Platby</a>
|
||||||
|
|
||||||
<button className={styles.logoutBtn} onClick={onLogout} role="menuitem">
|
<button className={styles.logoutBtn} onClick={handleLogout} role="menuitem">
|
||||||
<FaSignOutAlt className={styles.iconSmall} /> Odhlásit se
|
<FaSignOutAlt className={styles.iconSmall} /> Odhlásit se
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,66 +1,80 @@
|
|||||||
// import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
// import { createContext, useContext, useState, useEffect } from "react";
|
import { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
//TODO: připraveno pro použití jenom linknout funkce z vygenerovaného api klientan a logout() a currentUser()
|
// Import z Orval generovaného API
|
||||||
|
import {
|
||||||
|
apiAccountLoginCreate,
|
||||||
|
apiAccountLogoutCreate
|
||||||
|
} from "@/api/generated/public/account";
|
||||||
|
import { apiAccountUserMeRetrieve } from "@/api/generated/private/account/account";
|
||||||
|
|
||||||
//import { authLogin, authMe } from "../api/generated"; // your orval client
|
// Import typů z Orval
|
||||||
//import { LoginSchema } from "../api/generated/types";
|
import type { CustomTokenObtainPair } from "@/api/generated/public/models/customTokenObtainPair";
|
||||||
/*
|
import type { CustomUser } from "@/api/generated/private/models/customUser";
|
||||||
export interface User {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
avatar?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: CustomUser | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (payload: LoginSchema) => Promise<void>;
|
isLoading: boolean;
|
||||||
logout: () => void;
|
login: (payload: CustomTokenObtainPair) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<CustomUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const isAuthenticated = !!user;
|
const isAuthenticated = !!user;
|
||||||
|
|
||||||
// load user when app mounts
|
// Načíst uživatele při načtení aplikace (pokud má cookies)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("access");
|
refreshUser();
|
||||||
if (token) refreshUser();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function refreshUser() {
|
async function refreshUser() {
|
||||||
try {
|
try {
|
||||||
const { data } = await authMe(); // ORVAL HANDLES TYPING
|
const userData = await apiAccountUserMeRetrieve();
|
||||||
setUser(data);
|
setUser(userData);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.error || err.message;
|
||||||
|
console.error("Failed to refresh user:", errorMessage);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(payload: CustomTokenObtainPair) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Login endpoint automaticky nastaví HttpOnly cookies
|
||||||
|
await apiAccountLoginCreate(payload);
|
||||||
|
|
||||||
|
// Po úspěšném přihlášení načti informace o uživateli
|
||||||
|
await refreshUser();
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsLoading(false);
|
||||||
|
const errorMessage = err.response?.data?.error || err.message;
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
// Zavolej logout endpoint (smaže cookies na backendu)
|
||||||
|
await apiAccountLogoutCreate();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Logout error:", err);
|
||||||
|
} finally {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(payload: LoginSchema) {
|
|
||||||
const { data } = await authLogin(payload);
|
|
||||||
|
|
||||||
// example response: { access: "...", refresh: "..." }
|
|
||||||
localStorage.setItem("access", data.access);
|
|
||||||
localStorage.setItem("refresh", data.refresh);
|
|
||||||
|
|
||||||
await refreshUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
localStorage.removeItem("access");
|
|
||||||
localStorage.removeItem("refresh");
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isAuthenticated, login, logout, refreshUser }}>
|
<AuthContext.Provider value={{ user, isAuthenticated, isLoading, login, logout, refreshUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -71,28 +85,3 @@ export function useAuth() {
|
|||||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
EXAMPLES OF USE:
|
|
||||||
|
|
||||||
Login
|
|
||||||
const { data } = await authLogin(payload); // získám tokeny
|
|
||||||
localStorage.setItem("access", data.access);
|
|
||||||
localStorage.setItem("refresh", data.refresh);
|
|
||||||
|
|
||||||
await authMe(); // poté zjistím, kdo to je
|
|
||||||
|
|
||||||
Refresh
|
|
||||||
const { data } = await authRefresh({ refresh });
|
|
||||||
localStorage.setItem("access", data.access);
|
|
||||||
await authMe();
|
|
||||||
|
|
||||||
Me (load user)
|
|
||||||
const { data: user } = await authMe();
|
|
||||||
setUser(user);
|
|
||||||
|
|
||||||
|
|
||||||
*/
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/*
|
|
||||||
# EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
|
|
||||||
|
|
||||||
## Wrap your app tree with the provider (e.g., in App.tsx)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { UserContextProvider } from "../context/UserContext";
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<UserContextProvider>
|
|
||||||
<YourRoutes />
|
|
||||||
</UserContextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Consume in any child component
|
|
||||||
```tsx
|
|
||||||
import React, { useContext } from "react"
|
|
||||||
import { UserContext } from '../context/UserContext';
|
|
||||||
|
|
||||||
export default function ExampleComponent() {
|
|
||||||
const { user, setUser } = useContext(UserContext);
|
|
||||||
|
|
||||||
|
|
||||||
return ...;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
344
frontend/src/context/EXAMPLES.md
Normal file
344
frontend/src/context/EXAMPLES.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# AuthContext - Příklady použití
|
||||||
|
|
||||||
|
## 📦 Setup v aplikaci
|
||||||
|
|
||||||
|
Nejdříve obal celou aplikaci v `AuthProvider`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// main.tsx nebo App.tsx
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
{/* Tady tvoje routes a komponenty */}
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Login stránka
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, isLoading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Login automaticky nastaví cookies a načte user data
|
||||||
|
await login({ username, password });
|
||||||
|
navigate("/dashboard"); // Přesměruj po úspěšném přihlášení
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Přihlášení</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block mb-2 font-medium">Username nebo Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="username nebo email@example.com"
|
||||||
|
className="w-full p-2 border rounded"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block mb-2 font-medium">Heslo</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full p-2 border rounded"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 disabled:bg-gray-300"
|
||||||
|
>
|
||||||
|
{isLoading ? "Přihlašování..." : "Přihlásit se"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 Profilová stránka
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
||||||
|
|
||||||
|
// Redirect pokud není přihlášen
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
return <Navigate to="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8">Načítání...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Můj profil</h1>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded shadow">
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-medium">Username:</span> {user?.username}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-medium">Email:</span> {user?.email}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-medium">Jméno:</span> {user?.first_name} {user?.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-medium">Role:</span> {user?.role || "Uživatel"}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-medium">Email ověřen:</span> {user?.email_verified ? "✅ Ano" : "❌ Ne"}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="font-medium">Účet vytvořen:</span>{" "}
|
||||||
|
{user?.create_time ? new Date(user.create_time).toLocaleDateString("cs-CZ") : "N/A"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="bg-red-500 text-white px-6 py-2 rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Odhlásit se
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Protected Route (ochrana stránek)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8">Načítání...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Použití v routes:
|
||||||
|
// <Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Navigace s uživatelem
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const { user, isAuthenticated, logout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-gray-800 text-white p-4">
|
||||||
|
<div className="container mx-auto flex justify-between items-center">
|
||||||
|
<Link to="/" className="text-xl font-bold">
|
||||||
|
Vontor CZ
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<span>Ahoj, {user?.username}!</span>
|
||||||
|
<Link to="/profile" className="hover:underline">
|
||||||
|
Profil
|
||||||
|
</Link>
|
||||||
|
<button onClick={logout} className="hover:underline">
|
||||||
|
Odhlásit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link to="/login" className="hover:underline">
|
||||||
|
Přihlásit
|
||||||
|
</Link>
|
||||||
|
<Link to="/register" className="hover:underline">
|
||||||
|
Registrovat
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Manuální refresh uživatele
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { user, refreshUser } = useAuth();
|
||||||
|
|
||||||
|
// Po úpravě profilu obnov data
|
||||||
|
async function handleUpdateProfile() {
|
||||||
|
// ... ulož změny přes API
|
||||||
|
|
||||||
|
// Pak obnov context
|
||||||
|
await refreshUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Nastavení profilu</h1>
|
||||||
|
{/* formulář pro úpravu */}
|
||||||
|
<button onClick={handleUpdateProfile}>Uložit změny</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Podmíněné zobrazení podle role
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export function AdminPanel() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Zobraz jen pro adminy
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
return <div>Nemáte oprávnění k přístupu.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
{/* Admin obsah */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Dostupné API typy
|
||||||
|
|
||||||
|
### CustomTokenObtainPair (Login payload)
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
username: string; // nebo email
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CustomUser (Response type)
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
role?: "admin" | "user" | ...;
|
||||||
|
email_verified?: boolean;
|
||||||
|
phone_number?: string | null;
|
||||||
|
create_time: Date;
|
||||||
|
city?: string | null;
|
||||||
|
street?: string | null;
|
||||||
|
postal_code?: string | null;
|
||||||
|
gdpr: boolean;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ AuthContext API
|
||||||
|
|
||||||
|
### `user: CustomUser | null`
|
||||||
|
Aktuálně přihlášený uživatel nebo `null`
|
||||||
|
|
||||||
|
### `isAuthenticated: boolean`
|
||||||
|
`true` pokud je uživatel přihlášen
|
||||||
|
|
||||||
|
### `isLoading: boolean`
|
||||||
|
`true` během načítání uživatele
|
||||||
|
|
||||||
|
### `login(payload: CustomTokenObtainPair): Promise<void>`
|
||||||
|
Přihlásit uživatele (nastaví cookies a načte data)
|
||||||
|
|
||||||
|
### `logout(): Promise<void>`
|
||||||
|
Odhlásit uživatele (smaže cookies)
|
||||||
|
|
||||||
|
### `refreshUser(): Promise<void>`
|
||||||
|
Znovu načti aktuální uživatelská data z API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Best Practices
|
||||||
|
|
||||||
|
1. **Vždy používej `isLoading`** pro zobrazení loading stavu
|
||||||
|
2. **Kontroluj `isAuthenticated`** před přístupem k chráněnému obsahu
|
||||||
|
3. **Zachyť chyby** při login (try/catch)
|
||||||
|
4. **Po úpravě profilu** zavolej `refreshUser()`
|
||||||
|
5. **Používej `ProtectedRoute`** pro ochranu celých stránek
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Footer from "../components/Footer/footer";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
import SiteNav from "../components/navbar/SiteNav";
|
||||||
|
|
||||||
|
import styles from "./HomeLayout.module.css";
|
||||||
|
|
||||||
|
export default function AuthLayout(){
|
||||||
|
return(
|
||||||
|
<div className={styles.root}>
|
||||||
|
<SiteNav />
|
||||||
|
<Outlet />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
import Footer from "../components/Footer/footer";
|
import Footer from "../components/Footer/footer";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import SiteNav, { type User } from "../components/navbar/SiteNav";
|
import SiteNav from "../components/navbar/SiteNav";
|
||||||
|
|
||||||
|
|
||||||
const userexists: User = {
|
|
||||||
username: "Bruno",
|
|
||||||
email: "",
|
|
||||||
avatarUrl: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
import styles from "./HomeLayout.module.css";
|
import styles from "./HomeLayout.module.css";
|
||||||
|
|
||||||
export default function ChatLayout(){
|
export default function ChatLayout(){
|
||||||
return(
|
return(
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<SiteNav user={userexists} onLogin={() => {}} onLogout={() => {}} />
|
<SiteNav />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import Footer from "../components/Footer/footer";
|
import Footer from "../components/Footer/footer";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import SiteNav, { type User } from "../components/navbar/SiteNav";
|
import SiteNav from "../components/navbar/SiteNav";
|
||||||
|
|
||||||
|
|
||||||
const userexists: User = {
|
|
||||||
username: "Bruno",
|
|
||||||
email: "",
|
|
||||||
avatarUrl: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
import styles from "./HomeLayout.module.css";
|
import styles from "./HomeLayout.module.css";
|
||||||
|
|
||||||
export default function HomeLayout(){
|
export default function HomeLayout(){
|
||||||
return(
|
return(
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<SiteNav user={userexists} onLogin={() => {}} onLogout={() => {}} />
|
<SiteNav />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
224
frontend/src/pages/account/AccountSettings.tsx
Normal file
224
frontend/src/pages/account/AccountSettings.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { FaUser, FaEnvelope, FaPhone, FaMapMarkerAlt, FaCheckCircle, FaSpinner } from "react-icons/fa";
|
||||||
|
import { apiAccountUsersPartialUpdate } from "@/api/generated/private/account/account";
|
||||||
|
|
||||||
|
export default function AccountSettings() {
|
||||||
|
const { user, isAuthenticated, isLoading: authLoading, refreshUser } = useAuth();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
first_name: user?.first_name || "",
|
||||||
|
last_name: user?.last_name || "",
|
||||||
|
phone_number: user?.phone_number || "",
|
||||||
|
city: user?.city || "",
|
||||||
|
street: user?.street || "",
|
||||||
|
postal_code: user?.postal_code || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<FaSpinner className="text-blue-500 text-5xl animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!user?.id) throw new Error("User ID not found");
|
||||||
|
|
||||||
|
await apiAccountUsersPartialUpdate(user.id, formData);
|
||||||
|
await refreshUser();
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.error || err.message || "Nepodařilo se uložit změny";
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-12 px-4">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">Nastavení účtu</h1>
|
||||||
|
<p className="text-gray-600 mb-8">Upravte své osobní údaje</p>
|
||||||
|
|
||||||
|
{/* User info (read-only) */}
|
||||||
|
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h2 className="font-semibold text-gray-800 mb-2">Informace o účtu</h2>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><strong>Username:</strong> {user?.username}</p>
|
||||||
|
<p><strong>Email:</strong> {user?.email}</p>
|
||||||
|
<p><strong>Role:</strong> {user?.role || "user"}</p>
|
||||||
|
<p><strong>Email ověřen:</strong> {user?.email_verified ? "✅ Ano" : "❌ Ne"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg flex items-center gap-2">
|
||||||
|
<FaCheckCircle />
|
||||||
|
<span>Změny byly úspěšně uloženy!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||||
|
<strong>Chyba:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaUser className="text-gray-500" />
|
||||||
|
Jméno
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Jan"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={150}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaUser className="text-gray-500" />
|
||||||
|
Příjmení
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Novák"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={150}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaPhone className="text-gray-500" />
|
||||||
|
Telefon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="phone_number"
|
||||||
|
value={formData.phone_number}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="+420123456789"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
pattern="^\+?\d{9,15}$"
|
||||||
|
maxLength={16}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Formát: +420123456789</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
|
<FaMapMarkerAlt className="text-gray-500" />
|
||||||
|
Adresa
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700">Město</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="city"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Praha"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700">Ulice a číslo popisné</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="street"
|
||||||
|
value={formData.street}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Václavské náměstí 1"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700">PSČ</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="postal_code"
|
||||||
|
value={formData.postal_code}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="11000"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
pattern="^\d{5}$"
|
||||||
|
maxLength={5}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">5 číslic bez mezery</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" />
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Uložit změny"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
frontend/src/pages/account/Login.tsx
Normal file
108
frontend/src/pages/account/Login.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { FaEnvelope, FaLock, FaSpinner } from "react-icons/fa";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, isLoading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (!username.trim() || !password.trim()) {
|
||||||
|
setError("Vyplňte prosím všechna pole");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({ username, password });
|
||||||
|
navigate("/");
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.error || err.message;
|
||||||
|
setError(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
|
||||||
|
<h1 className="text-3xl font-bold text-center mb-2 text-gray-800">Přihlášení</h1>
|
||||||
|
<p className="text-center text-gray-600 mb-8">Vítejte zpět na vontor.cz</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||||
|
<strong>Chyba:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaEnvelope className="text-gray-500" />
|
||||||
|
Email nebo uživatelské jméno
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="email@example.com nebo username"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaLock className="text-gray-500" />
|
||||||
|
Heslo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<Link to="/password-reset" className="text-blue-600 hover:text-blue-800 hover:underline">
|
||||||
|
Zapomenuté heslo?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" />
|
||||||
|
Přihlašování...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Přihlásit se"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-gray-600">
|
||||||
|
Ještě nemáte účet?{" "}
|
||||||
|
<Link to="/register" className="text-blue-600 hover:text-blue-800 font-semibold hover:underline">
|
||||||
|
Zaregistrujte se
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/pages/account/Logout.tsx
Normal file
26
frontend/src/pages/account/Logout.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { FaSpinner } from "react-icons/fa";
|
||||||
|
|
||||||
|
export default function LogoutPage() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function performLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
performLogout();
|
||||||
|
}, [logout, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-8 text-center">
|
||||||
|
<FaSpinner className="text-blue-500 text-5xl mx-auto mb-4 animate-spin" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">Odhlašování...</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
frontend/src/pages/account/Register.tsx
Normal file
186
frontend/src/pages/account/Register.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { FaUser, FaEnvelope, FaLock, FaSpinner, FaCheckCircle } from "react-icons/fa";
|
||||||
|
import { apiAccountRegisterCreate } from "@/api/generated/public/account";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
password2: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!formData.username.trim() || !formData.email.trim() || !formData.password.trim()) {
|
||||||
|
setError("Vyplňte prosím všechna pole");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password !== formData.password2) {
|
||||||
|
setError("Hesla se neshodují");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 8) {
|
||||||
|
setError("Heslo musí mít alespoň 8 znaků");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await apiAccountRegisterCreate({
|
||||||
|
username: formData.username,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
password2: formData.password2,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => navigate("/login"), 2000);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.error || err.message || "Registrace se nezdařila";
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md text-center">
|
||||||
|
<FaCheckCircle className="text-green-500 text-6xl mx-auto mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-2">Registrace úspěšná!</h1>
|
||||||
|
<p className="text-gray-600 mb-4">Váš účet byl vytvořen. Přesměrování na přihlášení...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-100 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
|
||||||
|
<h1 className="text-3xl font-bold text-center mb-2 text-gray-800">Registrace</h1>
|
||||||
|
<p className="text-center text-gray-600 mb-8">Vytvořte si účet na vontor.cz</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||||
|
<strong>Chyba:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaUser className="text-gray-500" />
|
||||||
|
Uživatelské jméno
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="username"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaEnvelope className="text-gray-500" />
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaLock className="text-gray-500" />
|
||||||
|
Heslo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Minimálně 8 znaků</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<FaLock className="text-gray-500" />
|
||||||
|
Potvrďte heslo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password2"
|
||||||
|
value={formData.password2}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-purple-600 text-white py-3 rounded-lg font-semibold hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition flex items-center justify-center gap-2 mt-6"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" />
|
||||||
|
Vytváření účtu...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Zaregistrovat se"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-gray-600">
|
||||||
|
Již máte účet?{" "}
|
||||||
|
<Link to="/login" className="text-purple-600 hover:text-purple-800 font-semibold hover:underline">
|
||||||
|
Přihlaste se
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -95,7 +95,10 @@ export default function Downloader() {
|
|||||||
setSelectedVideos(info.videos.map((_, index) => index + 1));
|
setSelectedVideos(info.videos.map((_, index) => index + 1));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError({ error: err.message || 'Failed to retrieve video info' });
|
// Handle axios errors properly
|
||||||
|
const errorMessage = err.response?.data?.error || err.message
|
||||||
|
setError({ error: errorMessage });
|
||||||
|
console.error('Retrieve video info error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -154,7 +157,9 @@ export default function Downloader() {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError({ error: err.message || 'Failed to download video' });
|
const errorMessage = err.response?.data?.error || err.message || 'Failed to download video';
|
||||||
|
setError({ error: errorMessage });
|
||||||
|
console.error('Download error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
function getCookie(name: string): string | null {
|
import { FaSpinner } from "react-icons/fa";
|
||||||
const nameEQ = name + "=";
|
|
||||||
const ca = document.cookie.split(";").map((c) => c.trim());
|
|
||||||
for (const c of ca) {
|
|
||||||
if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCESS_COOKIE = "access_token";
|
|
||||||
|
|
||||||
export default function PrivateRoute() {
|
export default function PrivateRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoggedIn = !!getCookie(ACCESS_COOKIE);
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
// Zobraz loading během načítání uživatele
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<FaSpinner className="text-blue-500 text-5xl mx-auto mb-4 animate-spin" />
|
||||||
|
<p className="text-gray-600">Načítání...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pokud není přihlášen, redirect na login (ulož původní cestu)
|
||||||
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uživatel je přihlášen, renderuj chráněný obsah
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user