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 { 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 { DeliveryForm } from './components/delivery/DeliveryForm';
|
||||||
|
import { LoginForm } from './components/auth/LoginForm';
|
||||||
import { ToastContainer } from './components/ui/Toast';
|
import { ToastContainer } from './components/ui/Toast';
|
||||||
|
import { Button } from './components/ui/Button';
|
||||||
import { useDeliveryStore } from './stores/deliveryStore';
|
import { useDeliveryStore } from './stores/deliveryStore';
|
||||||
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
|
||||||
// Lazy load pages for code splitting
|
// Lazy load pages for code splitting
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -22,15 +25,21 @@ function App() {
|
|||||||
const [formDate, setFormDate] = useState<string>('');
|
const [formDate, setFormDate] = useState<string>('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { isAuthenticated, isAuthChecking, restoreAuth, logout } = useAuthStore();
|
||||||
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
||||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
||||||
|
|
||||||
// Refresh counts when form closes
|
// Restore auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFormOpen) {
|
restoreAuth();
|
||||||
|
}, [restoreAuth]);
|
||||||
|
|
||||||
|
// Refresh counts when form closes (only when authenticated)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && !isFormOpen) {
|
||||||
fetchDeliveryCounts();
|
fetchDeliveryCounts();
|
||||||
}
|
}
|
||||||
}, [isFormOpen, fetchDeliveryCounts]);
|
}, [isAuthenticated, isFormOpen, fetchDeliveryCounts]);
|
||||||
|
|
||||||
const handleDateSelect = (date: string) => {
|
const handleDateSelect = (date: string) => {
|
||||||
setSelectedDate(date);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#fbf8fb]">
|
<div className="min-h-screen bg-[#fbf8fb]">
|
||||||
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
|
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
|
||||||
@@ -78,8 +106,19 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
|
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-white/70">
|
<div className="flex items-center gap-4">
|
||||||
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
|
<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>
|
</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 || '';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
// Request deduplication cache
|
// Request deduplication cache
|
||||||
@@ -5,6 +7,20 @@ const pendingRequests = new Map<string, Promise<unknown>>();
|
|||||||
// Abort controllers for cancelling requests
|
// Abort controllers for cancelling requests
|
||||||
const abortControllers = new Map<string, AbortController>();
|
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 {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
details?: unknown;
|
details?: unknown;
|
||||||
@@ -48,16 +64,23 @@ async function fetchApi<T>(
|
|||||||
|
|
||||||
const requestPromise = (async (): Promise<T> => {
|
const requestPromise = (async (): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Handle 401 unauthorized
|
||||||
|
if (response.status === 401) {
|
||||||
|
handleUnauthorized();
|
||||||
|
throw new ApiError('Unauthorized', 401);
|
||||||
|
}
|
||||||
const errorData = await response.json().catch(() => null);
|
const errorData = await response.json().catch(() => null);
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
errorData?.error || `HTTP ${response.status}`,
|
errorData?.error || `HTTP ${response.status}`,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { api, ApiError, cancelAllRequests } from './client';
|
export { api, ApiError, cancelAllRequests } from './client';
|
||||||
export { deliveriesApi } from './deliveries';
|
export { deliveriesApi } from './deliveries';
|
||||||
|
export { authApi } from './auth';
|
||||||
export { frontendDateToBackend } from '../utils/date';
|
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: 'Новое',
|
new: 'Новое',
|
||||||
delivered: 'Доставлено',
|
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