add authentication with login form and token management
This commit is contained in:
@@ -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<string>('');
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#fbf8fb]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login form if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<LoginForm />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fbf8fb]">
|
||||
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
|
||||
@@ -78,8 +106,19 @@ function App() {
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
|
||||
</div>
|
||||
<div className="text-sm text-white/70">
|
||||
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-white/70">
|
||||
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
className="text-white hover:bg-white/10"
|
||||
>
|
||||
<LogOut size={18} className="mr-1" />
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
7
frontend/src/api/auth.ts
Normal file
7
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { api } from './client';
|
||||
import type { LoginRequest, LoginResponse } from '../types';
|
||||
|
||||
export const authApi = {
|
||||
login: (credentials: LoginRequest): Promise<LoginResponse> =>
|
||||
api.post<LoginResponse>('/api/auth/login', credentials),
|
||||
};
|
||||
@@ -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<string, Promise<unknown>>();
|
||||
// Abort controllers for cancelling requests
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
// 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<T>(
|
||||
|
||||
const requestPromise = (async (): Promise<T> => {
|
||||
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}`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { api, ApiError, cancelAllRequests } from './client';
|
||||
export { deliveriesApi } from './deliveries';
|
||||
export { authApi } from './auth';
|
||||
export { frontendDateToBackend } from '../utils/date';
|
||||
|
||||
91
frontend/src/components/auth/LoginForm.tsx
Normal file
91
frontend/src/components/auth/LoginForm.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#fbf8fb] p-4">
|
||||
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[#1B263B] rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[#1b1b1d]">
|
||||
Delivery Tracker
|
||||
</h1>
|
||||
<p className="text-[#75777d] mt-2">
|
||||
Войдите в систему
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#75777d]">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Имя пользователя"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#75777d]">
|
||||
<Lock size={20} />
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
'Войти'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
frontend/src/components/auth/index.ts
Normal file
1
frontend/src/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LoginForm } from './LoginForm';
|
||||
79
frontend/src/stores/authStore.ts
Normal file
79
frontend/src/stores/authStore.ts
Normal file
@@ -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<void>;
|
||||
logout: () => void;
|
||||
restoreAuth: () => void;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const USER_KEY = 'auth_user';
|
||||
|
||||
export const useAuthStore = create<AuthState>((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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -28,3 +28,18 @@ export const statusLabels: Record<DeliveryStatus, string> = {
|
||||
new: 'Новое',
|
||||
delivered: 'Доставлено',
|
||||
};
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user