Commit 0c058896 authored by BangNSK's avatar BangNSK

feat: implement frontend core features including authentication, protected...

feat: implement frontend core features including authentication, protected routes, and crawl job dashboard management
parent 9ae40825
# URL gốc của backend, không có dấu / ở cuối.
VITE_API_BASE_URL=http://localhost:3000/api/v1
......@@ -44,15 +44,13 @@ pnpm install
## ⚙️ Cấu hình môi trường
Hiện tại URL của backend được cấu hình trực tiếp trong file `src/services/api.ts`:
URL backend được cấu hình bằng biến môi trường `VITE_API_BASE_URL`. Tạo file `.env` từ `.env.example`:
```ts
const API_BASE_URL = 'http://localhost:3000/api/v1';
VITE_API_BASE_URL=http://localhost:3000/api/v1
```
Nếu backend của bạn chạy ở cổng hoặc host khác, hãy cập nhật giá trị này tương ứng.
> **Sắp tới:** Có thể chuyển sang dùng biến môi trường `.env` với `VITE_API_BASE_URL` để linh hoạt hơn khi deploy.
Nếu không khai báo, frontend giữ endpoint deploy hiện tại. Khi chạy backend local, hãy tạo `.env` và đặt `VITE_API_BASE_URL=http://localhost:3000/api/v1`.
---
......
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
interface GuestRouteProps {
children: React.ReactNode;
......
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import type { UserRole } from '../context/AuthContext';
import { useAuth, type UserRole } from '../context/auth';
interface ProtectedRouteProps {
children: React.ReactNode;
......
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { api } from '../services/api';
export type UserRole = 'ADMIN' | 'CRAWLER_USER' | 'VIEWER';
export interface User {
id: string;
email: string;
role: UserRole;
fullName?: string | null;
createdAt?: string;
updatedAt?: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string) => Promise<void>;
isAdmin: boolean;
isCrawler: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
import { AuthContext, type User } from './auth';
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
......@@ -86,11 +64,3 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
import { createContext, useContext } from 'react';
export type UserRole = 'ADMIN' | 'CRAWLER_USER' | 'VIEWER';
export interface User {
id: string;
email: string;
role: UserRole;
fullName?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string) => Promise<void>;
isAdmin: boolean;
isCrawler: boolean;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
};
import React from 'react';
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
import { Database, Users, History, User, LogOut, Compass } from 'lucide-react';
export const DashboardLayout: React.FC = () => {
......@@ -40,6 +40,11 @@ export const DashboardLayout: React.FC = () => {
},
];
const isPathActive = (path: string) =>
path === '/'
? location.pathname === '/' || location.pathname.startsWith('/jobs/')
: location.pathname === path;
return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800">
{/* Sidebar */}
......@@ -60,7 +65,7 @@ export const DashboardLayout: React.FC = () => {
{navItems
.filter((item) => item.allowed)
.map((item) => {
const isActive = location.pathname === item.path;
const isActive = isPathActive(item.path);
return (
<Link
key={item.name}
......@@ -130,8 +135,28 @@ export const DashboardLayout: React.FC = () => {
</div>
</header>
<nav className="fixed inset-x-0 bottom-0 z-40 grid grid-flow-col auto-cols-fr border-t border-slate-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-lg backdrop-blur md:hidden" aria-label="Điều hướng chính">
{navItems
.filter((item) => item.allowed)
.map((item) => {
const isActive = isPathActive(item.path);
return (
<Link
key={item.name}
to={item.path}
className={`flex min-w-0 flex-col items-center gap-1 rounded-lg px-1 py-1.5 text-[10px] font-medium ${
isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'
}`}
>
{item.icon}
<span className="max-w-full truncate">{item.name}</span>
</Link>
);
})}
</nav>
{/* Page Content */}
<main className="flex-1 overflow-y-auto p-4 md:p-8">
<main className="flex-1 overflow-y-auto p-4 pb-24 md:p-8">
<Outlet />
</main>
</div>
......
This diff is collapsed.
This diff is collapsed.
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
import { Database, Lock, Mail, Loader2 } from 'lucide-react';
export const Login: React.FC = () => {
......
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { api } from '../services/api';
import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react';
......@@ -37,6 +37,7 @@ export const Logs: React.FC = () => {
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [userIdFilter, setUserIdFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
......@@ -45,7 +46,7 @@ export const Logs: React.FC = () => {
const [expandedLogId, setExpandedLogId] = useState<string | null>(null);
const fetchLogs = async (page = 1, silent = false) => {
const fetchLogs = useCallback(async (page = 1, silent = false) => {
if (!silent) setLoading(true);
else setRefreshing(true);
try {
......@@ -60,18 +61,20 @@ export const Logs: React.FC = () => {
if (res.data.success) {
setLogs(res.data.data.logs || []);
setMeta(res.data.data.meta);
setLoadError('');
}
} catch (e) {
console.error('Failed to fetch audit logs', e);
setLoadError('Không thể tải nhật ký hệ thống. Vui lòng thử lại.');
} finally {
setLoading(false);
setRefreshing(false);
}
};
}, [actionFilter, userIdFilter]);
useEffect(() => {
fetchLogs(1);
}, [userIdFilter, actionFilter]);
}, [fetchLogs]);
const handleFilterSubmit = (e: React.FormEvent) => {
e.preventDefault();
......@@ -117,6 +120,13 @@ export const Logs: React.FC = () => {
</button>
</div>
{loadError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{loadError}</span>
<button type="button" onClick={() => void fetchLogs(meta.page)} className="font-semibold hover:underline">Thử lại</button>
</div>
)}
{/* Filters */}
<form onSubmit={handleFilterSubmit} className="flex flex-wrap gap-2 items-end">
<div className="relative">
......
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
import { Shield, Mail, UserCheck } from 'lucide-react';
export const Profile: React.FC = () => {
......@@ -8,13 +8,13 @@ export const Profile: React.FC = () => {
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Hồ sơ cá nhân</h1>
<p className="text-sm text-slate-500">Xem và quản lý thông tin tài khoản của bạn.</p>
<p className="text-sm text-slate-500">Xem thông tin tài khoản và quyền truy cập của bạn.</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-6 sm:p-8 space-y-6">
<div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-indigo-150 flex items-center justify-center text-indigo-700 font-bold uppercase text-2xl border border-indigo-200">
<div className="h-16 w-16 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-700 font-bold uppercase text-2xl border border-indigo-200">
{user?.email?.charAt(0) || 'U'}
</div>
<div>
......
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/auth';
import { Database, Lock, Mail, Loader2 } from 'lucide-react';
export const Register: React.FC = () => {
......
......@@ -55,6 +55,7 @@ export const Users: React.FC = () => {
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 10, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
......@@ -84,9 +85,11 @@ export const Users: React.FC = () => {
if (res.data.success) {
setUsers(res.data.data.users || []);
setMeta(res.data.data.meta);
setLoadError('');
}
} catch (e) {
console.error('Failed to fetch users', e);
setLoadError('Không thể tải danh sách người dùng. Vui lòng thử lại.');
} finally {
setLoading(false);
setRefreshing(false);
......@@ -201,6 +204,13 @@ export const Users: React.FC = () => {
</div>
</div>
{loadError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{loadError}</span>
<button type="button" onClick={() => void fetchUsers(meta.page, search)} className="font-semibold hover:underline">Thử lại</button>
</div>
)}
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1 max-w-sm">
......
import axios from 'axios';
const API_BASE_URL = 'http://171.247.68.96:4011/api/v1';
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://171.247.68.96:4011/api/v1')
.replace(/\/$/, '');
export const api = axios.create({
baseURL: API_BASE_URL,
......@@ -68,6 +69,8 @@ api.interceptors.response.use(
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
processQueue(error, null);
isRefreshing = false;
handleLogout();
return Promise.reject(error);
}
......
const ACTIVE_STATUSES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT']);
export interface JobProgressSource {
status: string;
processedPages?: number;
progressTotal?: number;
successPages: number;
failedPages: number;
totalPages: number;
maxPages: number;
}
export interface JobProgress {
completed: number;
total: number;
percent: number;
}
export function getJobProgress(job: JobProgressSource): JobProgress {
const hasLiveProgress =
ACTIVE_STATUSES.has(job.status) &&
typeof job.processedPages === 'number' &&
typeof job.progressTotal === 'number' &&
job.progressTotal > 0;
const completed = hasLiveProgress
? Math.max(0, job.processedPages ?? 0)
: Math.max(0, job.successPages + job.failedPages);
const total = hasLiveProgress
? Math.max(completed, job.progressTotal ?? 0)
: Math.max(completed, job.totalPages || job.progressTotal || job.maxPages || 0);
const percent = job.status === 'COMPLETED'
? 100
: total > 0
? Math.min(100, Math.round((completed / total) * 100))
: 0;
return { completed, total, percent };
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment