[CLAUDE] Phase1: foundation - BE Clean Arch + Identity + JWT + 2 FE React + login E2E

Backend (.NET 10):
- Domain: BaseEntity/AuditableEntity, ContractType/Phase/ApprovalDecision enums, User/Role (Identity<Guid>), AppRoles (12 const)
- Application: IApplicationDbContext/ICurrentUser/IDateTime/IJwtTokenService, custom exceptions, ValidationBehavior (MediatR pipeline), Auth CQRS (Login/Refresh/Me), DependencyInjection
- Infrastructure: ApplicationDbContext (IdentityDbContext), AuditingInterceptor (auto audit + soft delete), DbInitializer (seed 12 role + admin), DesignTimeDbContextFactory, JwtTokenService, DateTimeService, DI
- Api: CurrentUserService, GlobalExceptionMiddleware (ProblemDetails), AuthController, Program.cs rewrite (Serilog + JWT + CORS + Swagger), appsettings + launchSettings (port 5443)
- Migration Init applied to SolutionErp_Dev LocalDB

Frontend (React 19 + Vite 8 + Tailwind 4):
- fe-admin (:8082 blue) + fe-user (:8080 emerald) - shared structure, khac menu + brand color
- Tailwind 4 via @tailwindcss/vite plugin, theme brand colors
- AuthContext (localStorage token), ProtectedRoute, Layout (sidebar + header)
- UI kit: Button/Input/Label (CVA + Tailwind)
- LoginPage voi toast error, DashboardPage/InboxPage placeholder
- Axios interceptor: auto Bearer + 401 redirect
- TanStack Query client, React Router 7, Sonner toast

Package downgrades (do .NET 10 / TS 6 compat):
- MediatR 14 -> 12.4.1 (v14 breaking changes)
- Swashbuckle 10 -> 6.9.0 (v10 khong tuong thich OpenApi 2)
- Removed Microsoft.AspNetCore.OpenApi (conflict voi Swashbuckle)

E2E verified: POST /api/auth/login qua Vite proxy ca 2 FE -> JWT + user info

Credentials seed: admin@solutionerp.local / Admin@123456

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 10:59:44 +07:00
parent 25dad7f36f
commit 702411fcc8
85 changed files with 10326 additions and 964 deletions

View File

@ -0,0 +1,63 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
import { LogOut, LayoutDashboard, FileText, Users, Building2, Settings } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
import { cn } from '@/lib/cn'
const menuItems = [
{ to: '/dashboard', label: 'Tổng quan', icon: LayoutDashboard },
{ to: '/contracts', label: 'Hợp đồng', icon: FileText },
{ to: '/suppliers', label: 'Nhà cung cấp', icon: Building2 },
{ to: '/users', label: 'Người dùng', icon: Users },
{ to: '/settings', label: 'Cài đặt', icon: Settings },
]
export function Layout() {
const { user, logout } = useAuth()
return (
<div className="flex h-screen">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
<div className="flex h-14 items-center border-b border-slate-200 px-6">
<Link to="/dashboard" className="text-base font-bold text-brand-700">
SOLUTION ERP · Admin
</Link>
</div>
<nav className="flex-1 space-y-1 p-3">
{menuItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-slate-200 p-3">
<div className="mb-2 px-3 text-xs text-slate-500">
<div className="font-medium text-slate-700">{user?.fullName}</div>
<div className="truncate">{user?.email}</div>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-slate-600 transition hover:bg-slate-100"
>
<LogOut className="h-4 w-4" />
Đăng xuất
</button>
</div>
</aside>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}

View File

@ -0,0 +1,16 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import type { ReactNode } from 'react'
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated, isBootstrapping } = useAuth()
const location = useLocation()
if (isBootstrapping) {
return <div className="flex h-screen items-center justify-center text-slate-500">Đang tải</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />
}
return <>{children}</>
}

View File

@ -0,0 +1,33 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-brand-500',
{
variants: {
variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700',
secondary: 'bg-slate-200 text-slate-900 hover:bg-slate-300',
outline: 'border border-slate-300 bg-white hover:bg-slate-50',
ghost: 'hover:bg-slate-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'h-8 px-3',
md: 'h-10 px-4',
lg: 'h-11 px-6 text-base',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
},
)
type Props = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
export const Button = forwardRef<HTMLButtonElement, Props>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
),
)
Button.displayName = 'Button'

View File

@ -0,0 +1,16 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
type Props = InputHTMLAttributes<HTMLInputElement>
export const Input = forwardRef<HTMLInputElement, Props>(({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
className,
)}
{...props}
/>
))
Input.displayName = 'Input'

View File

@ -0,0 +1,11 @@
import type { LabelHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn('text-sm font-medium text-slate-700', className)}
{...props}
/>
)
}