From c373d8213584e3dd97c3da1163c914c8d1019a77 Mon Sep 17 00:00:00 2001 From: Egor Pozharov Date: Thu, 16 Apr 2026 12:47:42 +0600 Subject: [PATCH] add authentication with login form and token management --- frontend/src/App.tsx | 51 ++++++++++-- frontend/src/api/auth.ts | 7 ++ frontend/src/api/client.ts | 23 ++++++ frontend/src/api/index.ts | 1 + frontend/src/components/auth/LoginForm.tsx | 91 ++++++++++++++++++++++ frontend/src/components/auth/index.ts | 1 + frontend/src/stores/authStore.ts | 79 +++++++++++++++++++ frontend/src/types/index.ts | 15 ++++ 8 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/components/auth/LoginForm.tsx create mode 100644 frontend/src/components/auth/index.ts create mode 100644 frontend/src/stores/authStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9929016..859b7d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,11 @@ import { useState, useEffect, lazy, Suspense } from 'react'; -import { Truck, Loader2 } from 'lucide-react'; +import { Truck, Loader2, LogOut } from 'lucide-react'; import { DeliveryForm } from './components/delivery/DeliveryForm'; +import { LoginForm } from './components/auth/LoginForm'; import { ToastContainer } from './components/ui/Toast'; +import { Button } from './components/ui/Button'; import { useDeliveryStore } from './stores/deliveryStore'; +import { useAuthStore } from './stores/authStore'; // Lazy load pages for code splitting const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -22,15 +25,21 @@ function App() { const [formDate, setFormDate] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const { isAuthenticated, isAuthChecking, restoreAuth, logout } = useAuthStore(); const addDelivery = useDeliveryStore(state => state.addDelivery); const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts); - // Refresh counts when form closes + // Restore auth on mount useEffect(() => { - if (!isFormOpen) { + restoreAuth(); + }, [restoreAuth]); + + // Refresh counts when form closes (only when authenticated) + useEffect(() => { + if (isAuthenticated && !isFormOpen) { fetchDeliveryCounts(); } - }, [isFormOpen, fetchDeliveryCounts]); + }, [isAuthenticated, isFormOpen, fetchDeliveryCounts]); const handleDateSelect = (date: string) => { setSelectedDate(date); @@ -67,6 +76,25 @@ function App() { } }; + // Show loading while checking auth + if (isAuthChecking) { + return ( +
+ +
+ ); + } + + // Show login form if not authenticated + if (!isAuthenticated) { + return ( + <> + + + + ); + } + return (
@@ -78,8 +106,19 @@ function App() {

Delivery Tracker

-
- {view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`} +
+
+ {view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`} +
+
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..971d9f5 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,7 @@ +import { api } from './client'; +import type { LoginRequest, LoginResponse } from '../types'; + +export const authApi = { + login: (credentials: LoginRequest): Promise => + api.post('/api/auth/login', credentials), +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f4cc6b3..3396ca2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,5 @@ +import { useToastStore } from '../stores/toastStore'; + const API_BASE_URL = import.meta.env.VITE_API_URL || ''; // Request deduplication cache @@ -5,6 +7,20 @@ const pendingRequests = new Map>(); // Abort controllers for cancelling requests const abortControllers = new Map(); +// Get token from localStorage +function getAuthToken(): string | null { + return localStorage.getItem('auth_token'); +} + +// Handle 401 unauthorized +function handleUnauthorized() { + localStorage.removeItem('auth_token'); + localStorage.removeItem('auth_user'); + useToastStore.getState().addToast('Сессия истекла, войдите снова', 'error'); + // Reload page to trigger auth check + window.location.reload(); +} + export class ApiError extends Error { status: number; details?: unknown; @@ -48,16 +64,23 @@ async function fetchApi( const requestPromise = (async (): Promise => { try { + const token = getAuthToken(); const response = await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', + ...(token && { 'Authorization': `Bearer ${token}` }), ...options?.headers, }, }); if (!response.ok) { + // Handle 401 unauthorized + if (response.status === 401) { + handleUnauthorized(); + throw new ApiError('Unauthorized', 401); + } const errorData = await response.json().catch(() => null); throw new ApiError( errorData?.error || `HTTP ${response.status}`, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6f4b478..7f1bff1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,3 +1,4 @@ export { api, ApiError, cancelAllRequests } from './client'; export { deliveriesApi } from './deliveries'; +export { authApi } from './auth'; export { frontendDateToBackend } from '../utils/date'; diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..f9fc316 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,91 @@ +import { useState, type FormEvent } from 'react'; +import { Lock, User, Loader2 } from 'lucide-react'; +import { Button } from '../ui/Button'; +import { Input } from '../ui/Input'; +import { useAuthStore } from '../../stores/authStore'; + +export const LoginForm = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const { login, isLoading } = useAuthStore(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password.trim()) return; + + try { + await login({ username: username.trim(), password }); + } catch { + // Error is handled by store (toast) + } + }; + + return ( +
+
+
+
+ +
+

+ Delivery Tracker +

+

+ Войдите в систему +

+
+ +
+
+
+ +
+ setUsername(e.target.value)} + required + minLength={3} + disabled={isLoading} + className="pl-10" + /> +
+ +
+
+ +
+ setPassword(e.target.value)} + required + minLength={6} + disabled={isLoading} + className="pl-10" + /> +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/auth/index.ts b/frontend/src/components/auth/index.ts new file mode 100644 index 0000000..ff006f5 --- /dev/null +++ b/frontend/src/components/auth/index.ts @@ -0,0 +1 @@ +export { LoginForm } from './LoginForm'; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..843c243 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; +import { authApi } from '../api/auth'; +import { useToastStore } from './toastStore'; +import type { User, LoginRequest } from '../types'; + +interface AuthState { + token: string | null; + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + isAuthChecking: boolean; + login: (credentials: LoginRequest) => Promise; + logout: () => void; + restoreAuth: () => void; +} + +const TOKEN_KEY = 'auth_token'; +const USER_KEY = 'auth_user'; + +export const useAuthStore = create((set) => ({ + token: null, + user: null, + isAuthenticated: false, + isLoading: false, + isAuthChecking: true, + + login: async (credentials: LoginRequest) => { + set({ isLoading: true }); + try { + const response = await authApi.login(credentials); + const token = response.token; + + // Extract user info from token payload (JWT) + const payload = JSON.parse(atob(token.split('.')[1])); + const user: User = { + id: payload.sub || '', + username: credentials.username, + }; + + // Save to localStorage + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + + set({ token, user, isAuthenticated: true, isLoading: false }); + useToastStore.getState().addToast('Вход выполнен успешно', 'success'); + } catch (error) { + set({ isLoading: false }); + const message = error instanceof Error ? error.message : 'Ошибка входа'; + useToastStore.getState().addToast(message, 'error'); + throw error; + } + }, + + logout: () => { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + set({ token: null, user: null, isAuthenticated: false }); + useToastStore.getState().addToast('Вы вышли из системы', 'info'); + }, + + restoreAuth: () => { + const token = localStorage.getItem(TOKEN_KEY); + const userJson = localStorage.getItem(USER_KEY); + + if (token && userJson) { + try { + const user = JSON.parse(userJson) as User; + set({ token, user, isAuthenticated: true, isAuthChecking: false }); + } catch { + // Invalid stored data, clear it + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + set({ isAuthChecking: false }); + } + } else { + set({ isAuthChecking: false }); + } + }, +})); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6b7d03b..dbe2453 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -28,3 +28,18 @@ export const statusLabels: Record = { new: 'Новое', delivered: 'Доставлено', }; + +// Auth types +export interface LoginRequest { + username: string; + password: string; +} + +export interface LoginResponse { + token: string; +} + +export interface User { + id: string; + username: string; +}