[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

@ -2,54 +2,75 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. > **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
**Last updated:** 2026-04-21 **Last updated:** 2026-04-21 11:00
## 📍 Phase hiện tại: **Phase 0Draft Scaffold** (in progress) ## 📍 Phase hiện tại: **Phase 1Alpha Core** (foundation xong, chờ đợt 2)
## 🔥 In Progress ## 🔥 In Progress
| Ai | Task | Bắt đầu | _(không có — Phase 1 foundation xong, chờ quyết định bước tiếp)_
|---|---|---|
| Claude | Hoàn tất Phase 0 — docs + git init | 2026-04-21 |
## ✅ Recently Done (newest on top) ## ✅ Recently Done (newest on top)
| Ngày | Ai | Task | Commit | | Ngày | Ai | Task | Commit |
|---|---|---|---| |---|---|---|---|
| 2026-04-21 | Claude | Parse QUY_TRINH → `workflow-contract.md` (9 phase state machine + role matrix) | — | | 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE (Clean Arch + Identity + JWT + migration) + FE (2 app, Tailwind 4, Router, AuthContext, Login) — E2E login pass qua Vite proxy | (chưa commit, sắp xong) |
| 2026-04-21 | Claude | Parse 8 FORM → `forms-spec.md` (catalog + RG-001 code format) | — | | 2026-04-21 | Claude | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` |
| 2026-04-21 | Claude | Scaffold 2 React + Vite apps (fe-admin :8082, fe-user :8080) + proxy config + Node engines pin | — |
| 2026-04-21 | Claude | Scaffold .NET 10 solution + 4 project (Domain/Application/Infrastructure/Api) + references + packages | — |
| 2026-04-21 | Claude | Tạo cấu trúc thư mục `SOLUTION_ERP/` + MEMORY.md | — |
## 🎯 Next up (Phase 0 còn lại) Session logs:
- [`changelog/sessions/2026-04-21-1045-phase0-scaffold.md`](changelog/sessions/2026-04-21-1045-phase0-scaffold.md)
- [`changelog/sessions/2026-04-21-1100-phase1-foundation.md`](changelog/sessions/2026-04-21-1100-phase1-foundation.md)
- [ ] Tạo file root: `.gitignore`, `README.md`, `global.json`, `docker-compose.yml` ## 🎯 Next up — Phase 1 đợt 2 (CRUD master + Permission Matrix)
- [ ] Tạo 3 skill folder placeholders (`contract-workflow`, `form-engine`, `permission-matrix`)
- [ ] `git init` + commit đầu tiên `[CLAUDE] Scaffold: khởi tạo SOLUTION_ERP Phase 0`
- [ ] Set Gitea remote (khi user cấp URL)
## 📋 Phase 1 — Alpha Core (sắp tới) ### Backend
- [ ] `Domain/Entities/Supplier`, `Project`, `Department` (+ EF configurations)
- [ ] `Application/Suppliers/{Commands,Queries}/*` (Create, Update, Delete, GetById, List)
- [ ] Tương tự cho Project + Department
- [ ] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
- [ ] Pagination, search, sort server-side (`GetListQuery` với `PagedResult<T>`)
- [ ] Migration 2: `AddMasterData`
Xem chi tiết ở [`changelog/migration-todos.md`](changelog/migration-todos.md) section **Phase 1**. ### Permission Matrix
- [ ] `Domain/Entities/MenuItem`, `Permission`
- [ ] Seed default menu tree (based on FE screens)
- [ ] `Application/Permissions/Queries/GetMyMenuTreeQuery`
- [ ] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
- [ ] Admin UI: Permission Matrix grid (role × menu × CRUD checkbox)
Summary: ### Contract draft (chưa workflow)
1. Install Tailwind + shadcn/ui cho 2 FE + setup `BrandingProvider` - [ ] `Domain/Entities/Contract` skeleton (không state machine)
2. Tạo `BaseEntity`, `AuditableEntity`, `ApplicationDbContext`, migration đầu tiên - [ ] Basic CRUD controller + FE list page
3. ASP.NET Identity + JWT endpoint (login, refresh, logout, seed admin)
4. Permission Matrix (Role × MenuKey × CRUD) — copy pattern từ NamGroup skill ### FE
5. CRUD Supplier + Project + Contract (draft only, chưa workflow) - [ ] `<PermissionGuard menuKey="Contracts">` + `usePermission()` hook
6. FE admin: login page + layout + permission guard + 3 CRUD page - [ ] 3 trang CRUD admin (Suppliers / Projects / Departments) với table + modal
7. FE user: login + layout + list HĐ của tôi - [ ] Route guard theo role (admin-only routes)
## 📊 Thông số sau Phase 1 foundation
- **Backend LOC:** ~400 (Domain 60 + Application 170 + Infrastructure 190 + Api 120)
- **Frontend LOC:** ~450 mỗi app (shared 90%)
- **Build time:** .NET ~4s, FE TS check ~3s mỗi app, Vite dev ~3s ready
- **E2E verified:** Login (fe-admin proxy + fe-user proxy) → API → JWT + user info + /me
## 🚨 Blockers / risks ## 🚨 Blockers / risks
- **Gitea remote** chưa có URL — chờ user cấp để push code - **Gitea remote** chưa có URL — push sau
- **3 file `.doc`** (FO-002.02, .03, .06) không parse được bằng python-docx — cần Word COM hoặc LibreOffice ở Phase 2 - ⚠️ **Swashbuckle 10.x** không tương thích với .NET 10 + Microsoft.OpenApi 2.0 — đã downgrade về 6.9.0. Theo dõi update sau.
- **Node 22 local vs 20 CI** — phải test CI sớm (Phase 5) để tránh surprise - ⚠️ **MediatR 14.x** breaking changes — đã downgrade về 12.4.1. Ok cho Phase 1-5.
- ⚠️ **Microsoft.AspNetCore.OpenApi** đã remove (conflict Swashbuckle 6.9). Nếu sau muốn dùng built-in OpenAPI thì phải chọn 1 trong 2.
- ⚠️ **Design-time DB (`SolutionErp_Design`)** được tạo khi chạy `dotnet ef` — có thể drop an toàn (không chứa data thật)
- ⚠️ **3 file `.doc` FORM** chưa convert được — Phase 2
## 📊 Thông số ## Credentials mặc định
- LOC: ~0 (scaffold chỉ) ```
- Build time: ~10s (.NET) Email: admin@solutionerp.local
- Test coverage: 0% (chưa có test) Password: Admin@123456
```
URLs dev:
- API: http://localhost:5443 — Swagger ở `/swagger`
- Admin FE: http://localhost:8082
- User FE: http://localhost:8080

View File

@ -15,75 +15,70 @@
- [x] Parse 8 form → `docs/forms-spec.md` - [x] Parse 8 form → `docs/forms-spec.md`
- [x] Parse quy trình → `docs/workflow-contract.md` - [x] Parse quy trình → `docs/workflow-contract.md`
- [x] Viết `docs/{CLAUDE,STATUS,PROJECT-MAP}.md` - [x] Viết `docs/{CLAUDE,STATUS,PROJECT-MAP}.md`
- [ ] Viết `.gitignore`, `README.md`, `global.json`, `docker-compose.yml` - [x] Viết `.gitignore`, `README.md`, `global.json`, `docker-compose.yml`
- [ ] Tạo placeholder skill folders: `contract-workflow`, `form-engine`, `permission-matrix` - [x] Tạo placeholder skill folders: `contract-workflow`, `form-engine`, `permission-matrix`
- [ ] `git init` + commit đầu - [x] `git init` + commit đầu (`25dad7f`)
- [ ] Push Gitea remote (chờ URL từ user) - [ ] Push Gitea remote (chờ URL từ user)
## Phase 1 — Alpha Core (T2-4) ## Phase 1 — Alpha Core (T2-4)
### Backend foundation ### Foundation (đã xong Session 2)
- [ ] `Domain/BaseEntity.cs` (Id, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy) - [x] `Domain/Common/BaseEntity.cs` (Id Guid, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy)
- [ ] `Domain/AuditableEntity : BaseEntity` (IsDeleted, DeletedAt, DeletedBy) - [x] `Domain/Common/AuditableEntity.cs` (IsDeleted, DeletedAt, DeletedBy)
- [ ] `Domain/ValueObjects/ContractCode.cs` (wrap string theo format RG-001) - [x] `Domain/Contracts/` Enums: `ContractType`, `ContractPhase` (9 state), `ApprovalDecision`
- [ ] `Domain/Enums/ContractType.cs`, `ContractPhase.cs`, `ApprovalDecision.cs` - [x] `Domain/Identity/User.cs` (IdentityUser<Guid> + FullName + RefreshToken + IsActive)
- [ ] `Application/Common/IApplicationDbContext.cs` interface - [x] `Domain/Identity/Role.cs` (IdentityRole<Guid> + Description)
- [ ] `Application/Common/IDateTime.cs`, `ICurrentUser.cs` - [x] `Domain/Identity/AppRoles.cs` — 12 role constants
- [ ] `Application/DependencyInjection.cs` — register MediatR, FluentValidation, AutoMapper - [x] `Application/Common/Interfaces/`: IApplicationDbContext, ICurrentUser, IDateTime, IJwtTokenService
- [ ] `Infrastructure/Persistence/ApplicationDbContext.cs` : `IdentityDbContext<User, Role, Guid>`, `IApplicationDbContext` - [x] `Application/Common/Exceptions/*`
- [ ] Configurations per entity qua `IEntityTypeConfiguration<T>` - [x] `Application/Common/Behaviors/ValidationBehavior.cs`
- [ ] `Infrastructure/DependencyInjection.cs`register DbContext, Identity, services - [x] `Application/DependencyInjection.cs`MediatR + FluentValidation
- [ ] `Api/Program.cs` setup: services, Serilog, auth, Swagger, CORS, middleware - [x] `Infrastructure/Persistence/ApplicationDbContext.cs : IdentityDbContext`
- [ ] `Api/Middleware/GlobalExceptionMiddleware.cs` - [x] `Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs`
- [x] `Infrastructure/Persistence/DbInitializer.cs` — seed 12 role + admin
- [x] `Infrastructure/Persistence/DesignTimeDbContextFactory.cs`
- [x] `Infrastructure/Identity/{JwtSettings, JwtTokenService}.cs`
- [x] `Infrastructure/Services/DateTimeService.cs`
- [x] `Infrastructure/DependencyInjection.cs`
- [x] `Api/Services/CurrentUserService.cs`
- [x] `Api/Middleware/GlobalExceptionMiddleware.cs`
- [x] `Api/Controllers/AuthController.cs` (login, refresh, me, logout)
- [x] `Api/Program.cs` (Serilog, JWT, CORS, Swagger, middleware)
- [x] `Api/appsettings.{json, Development.json}` + `launchSettings.json` (port 5443)
- [x] Migration 1 `Init` + apply to `SolutionErp_Dev` LocalDB
- [x] FE: Vite config (Tailwind 4 + proxy + alias)
- [x] FE: `src/{index.css, lib/api.ts, lib/cn.ts, types/auth.ts}` cho 2 app
- [x] FE: `src/contexts/AuthContext.tsx`, `components/{ProtectedRoute, Layout}.tsx`
- [x] FE: `components/ui/{Button, Input, Label}.tsx`
- [x] FE: `pages/LoginPage.tsx`, `pages/DashboardPage.tsx` (admin) + `pages/InboxPage.tsx` (user)
- [x] FE: `App.tsx` với Router + AuthProvider + Toaster
- [x] FE: `main.tsx` với QueryClient (TanStack Query)
- [x] E2E verified: login qua Vite proxy cả 2 app → JWT + user info
### Auth + Identity ### Phase 1 đợt 2 — CRUD master + Permission Matrix (sắp tới)
- [ ] `Domain/Entities/User : IdentityUser<Guid>`, `Role : IdentityRole<Guid>` - [ ] `Domain/Entities/Supplier` (Code, Name, TaxCode, Phone, Email, Address, Type enum: NCC/NTP/TĐ/ĐVDV)
- [ ] Migration 1: `Init` (Identity tables) - [ ] `Domain/Entities/Project` (Code, Name, StartDate, EndDate, ManagerUserId)
- [ ] `Application/Auth/Commands/LoginCommand` + handler + validator - [ ] `Domain/Entities/Department` (Code, Name, ManagerUserId)
- [ ] `Application/Auth/Commands/RefreshTokenCommand` - [ ] EF `IEntityTypeConfiguration<T>` cho mỗi entity
- [ ] `Api/Controllers/AuthController` (login, refresh, logout, me) - [ ] CQRS CRUD: Create/Update/Delete/GetById/List (với paging) cho 3 entity
- [ ] JWT config: issuer, audience, key, expiry 1h + refresh 7d - [ ] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
- [ ] Seed admin: `admin@solutionerp.local` / `Admin@123456` - [ ] Migration 2: `AddMasterData`
- [ ] Test login → get token → call `/me` OK - [ ] `Domain/Entities/MenuItem` (Key PascalCase, Label, ParentKey, Order, Icon)
- [ ] `Domain/Entities/Permission` (RoleId, MenuKey, CanRead/Create/Update/Delete)
### Permission Matrix - [ ] Seed default menu tree + permission admin có full access
- [ ] `Application/Permissions/Queries/GetMyMenuTreeQuery` — resolve per-user, cache
- [ ] `Domain/Entities/MenuItem` (Key, Label, ParentKey, Order, Icon) - [ ] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
- [ ] `Domain/Entities/Permission` (RoleId, MenuKey, CanRead, CanCreate, CanUpdate, CanDelete) - [ ] Migration 3: `AddPermissions`
- [ ] Seed default menu tree (based on FE screens list) - [ ] `Domain/Entities/Contract` skeleton (Id, Type, SupplierId, ProjectId, Phase=DangChon, DraftData JSON)
- [ ] `Application/Permissions/Queries/GetMyMenuTree` — resolve per-user - [ ] Contract CRUD draft only (không workflow Phase 3)
- [ ] `Api/Controllers/MenusController` + `RolesController` + `PermissionsController` - [ ] FE: `<PermissionGuard menuKey="Suppliers" action="Update">` + `usePermission()` hook
- [ ] Admin UI: Permission Matrix grid (role × menu × CRUD checkbox) - [ ] FE Admin: 3 trang CRUD Supplier/Project/Department với table + modal + search/sort
- [ ] FE Admin: Permission Matrix grid page (role × menu × CRUD checkbox)
### CRUD master data - [ ] FE User: trang "HĐ của tôi" list + filter
- [ ] Route guard theo role admin-only
- [ ] `Domain/Entities/Supplier` (Code, Name, TaxCode, Phone, Email, Address, Type: NCC/NTP/TĐ/ĐVDV) - [ ] Update `SolutionErp.slnx` nếu thêm project mới
- [ ] `Domain/Entities/Project` (Code, Name, StartDate, EndDate, Manager)
- [ ] `Domain/Entities/Department` (Code, Name, Manager)
- [ ] CQRS + Controller + Migration cho 3 entity
- [ ] FE admin 3 trang CRUD (list, create, edit, delete confirm)
- [ ] Pagination, search, sort server-side
### Contract draft (chưa workflow — chỉ CRUD)
- [ ] `Domain/Entities/Contract` (skeleton: Id, Type, SupplierId, ProjectId, Phase=DangChon, DraftData)
- [ ] API create/update/list/delete draft
- [ ] FE admin: list contracts + filter
- [ ] FE user: "HĐ của tôi" list
### FE setup
- [ ] Install Tailwind CSS cho 2 app + config content paths
- [ ] Install shadcn/ui CLI, init 2 app
- [ ] Install: `@tanstack/react-query`, `react-router-dom`, `axios`, `lucide-react`, `sonner`
- [ ] `src/lib/api.ts` — axios instance + interceptor JWT
- [ ] `src/contexts/AuthContext.tsx` — token từ localStorage
- [ ] `src/components/PermissionGuard.tsx` + `usePermission()` hook
- [ ] Layout shell: sidebar + header + content
- [ ] Route với protected route + role guard
- [ ] Toast notifications (sonner)
### Exit criteria Phase 1 ### Exit criteria Phase 1

View File

@ -0,0 +1,68 @@
# Session 2026-04-21 10:45 — Phase 0 Scaffold complete
**Dev:** Claude (Opus 4.7)
**Duration:** ~45 phút
**Commit:** `25dad7f`
## Làm được
### Scaffold
- Root `SOLUTION_ERP/` + toàn bộ cấu trúc thư mục
- `.NET 10` solution + 4 project Clean Arch (Domain / Application / Infrastructure / Api)
- NuGet packages: MediatR, FluentValidation, AutoMapper, EF Core SqlServer, Identity, JWT Bearer, Swashbuckle, Serilog
- 2 React + Vite + TS apps: `fe-admin` (8082), `fe-user` (8080), proxy `/api → :5443`, Node `>=20`, `.nvmrc=20`
- Root config: `.gitignore`, `global.json` (SDK 10.0.104), `docker-compose.yml` (SQL Server 2022), `README.md`
### Domain analysis
- Parse 8 form HĐ → `docs/forms-spec.md`:
- 5 form parsed OK (FO-002.01, .04, .05, .07, RG-001)
- 3 file `.doc` (FO-002.02, .03, .06) không unzip được → TODO Phase 2 convert qua Word COM / LibreOffice
- Extract **regex mã HĐ** từ RG-001: `{Project}/{Type}/SOL&{Partner}/{Seq}` + 5 biến thể cho PO/framework
- Parse QUY_TRINH → `docs/workflow-contract.md`:
- **9 phase state machine** (DangChon → DangSoanThao → DangGopY → DangDamPhan → DangInKy → DangKiemTraCCM → DangTrinhKy → DangDongDau → DaPhatHanh + TuChoi)
- **Role × Phase matrix** 9 role × 9 phase
- SLA mỗi phase (tổng ~19 ngày)
- Mermaid state diagram + notification triggers + data model implication
### Docs
- `CLAUDE.md` (root pointer)
- `docs/CLAUDE.md` (full context: tech stack + project layout + conventions)
- `docs/STATUS.md` (snapshot)
- `docs/PROJECT-MAP.md` (module map + API namespace + FE screens + flow chính)
- `docs/changelog/migration-todos.md` (roadmap 5 phase, ~100 atomic task)
### Skills (3 placeholder)
- `contract-workflow` (sẽ expand Phase 3)
- `form-engine` (sẽ expand Phase 2)
- `permission-matrix` (sẽ expand Phase 1)
## Quyết định kiến trúc đã chốt
| # | Item | Chốt | Lý do |
|---|---|---|---|
| 1 | Backend | Clean Architecture + CQRS + MediatR + EF migrations | Theo DH_Y_DUOC — dự án mới nên làm đúng bài |
| 2 | DB | SQL Server 2022 | Consistent với NamGroup + DHYD, user chốt |
| 3 | FE | 2 app React 19 + Vite 8 + TS (auto-scaffold) | Vite latest; nếu cần downgrade về React 18 thì làm Phase 1 đầu |
| 4 | Deploy | Windows Server + IIS | User chốt, không Docker |
| 5 | Lang UI | 100% tiếng Việt (code + table name = English) | User chốt |
| 6 | AI service | BỎ luôn | User chốt, không Python |
| 7 | Team | Solo (user + Claude) | Không skill Copilot collaboration |
| 8 | Node | local `>=20`, CI pin `20.x` | Bài học NamGroup (CI fail với Node mới) |
## Handoff cho session tiếp theo
**Phase 1 start:** backend foundation + auth + permission + CRUD master + FE layout + login page.
**Đọc trước khi code:**
1. [docs/STATUS.md](../../STATUS.md) — snapshot
2. [docs/workflow-contract.md](../../workflow-contract.md) — 9 phase (ảnh hưởng Domain model)
3. [docs/forms-spec.md](../../forms-spec.md) — RG-001 code format
4. [docs/changelog/migration-todos.md](../migration-todos.md) section **Phase 1**
**Blocker:**
- ⏳ Chờ user cấp URL Gitea → push remote
**Không ngầm giả định:**
- React 19 vs 18: scaffold ra 19, nếu muốn 18 thì phải downgrade sớm
- Aspose.Words (phí) vs OpenXml (free) cho Phase 2 — chưa quyết
- SignalR cho real-time notification Phase 3 — optional, chưa quyết

View File

@ -0,0 +1,128 @@
# Session 2026-04-21 11:00 — Phase 1 Foundation complete
**Dev:** Claude (Opus 4.7)
**Duration:** ~1h15m
**Base commit:** `25dad7f` (Phase 0)
**Commit target:** ~30 files added/modified
## Làm được
### Backend — Clean Architecture hoàn chỉnh
**Domain layer:**
- `Common/BaseEntity.cs`, `AuditableEntity.cs` (Id Guid + audit fields + soft delete)
- `Contracts/` enums: `ContractType`, `ContractPhase` (9 phase), `ApprovalDecision`
- `Identity/User.cs : IdentityUser<Guid>` + `Role.cs : IdentityRole<Guid>`
- `Identity/AppRoles.cs` — 12 role constants (Admin, Drafter, DeptManager, ProjectManager, Procurement, CostControl, Finance, Accounting, Equipment, Director, AuthorizedSigner, HrAdmin)
**Application layer:**
- Interfaces: `IApplicationDbContext`, `ICurrentUser`, `IDateTime`, `IJwtTokenService`
- Exceptions: `NotFoundException`, `ValidationException`, `ForbiddenException`, `UnauthorizedException`, `ConflictException`
- `ValidationBehavior` (MediatR pipeline — FluentValidation)
- Auth slice: `LoginCommand`, `RefreshTokenCommand`, `GetMeQuery` (CQRS + handlers + validators)
- `DependencyInjection.cs` — MediatR + FluentValidation wire-up
**Infrastructure layer:**
- `Services/DateTimeService.cs` (IDateTime impl)
- `Identity/JwtSettings.cs` + `JwtTokenService.cs` (HS256, refresh token 7d, access 1h)
- `Persistence/ApplicationDbContext.cs : IdentityDbContext<User, Role, Guid>, IApplicationDbContext`
- `Persistence/Interceptors/AuditingInterceptor.cs` — auto CreatedAt/UpdatedAt/DeletedBy + soft delete
- `Persistence/DbInitializer.cs` — apply migrations + seed 12 roles + admin user
- `Persistence/DesignTimeDbContextFactory.cs` — cho `dotnet ef` không cần Program.cs
- `DependencyInjection.cs` — wire DbContext + Identity + JWT service
**Api layer:**
- `Services/CurrentUserService.cs` — impl `ICurrentUser` từ HttpContext
- `Middleware/GlobalExceptionMiddleware.cs` — map exception → ProblemDetails JSON
- `Controllers/AuthController.cs``POST /api/auth/login`, `/refresh`, `GET /me`, `POST /logout`
- `Program.cs` rewrite: Serilog, JWT auth, CORS (2 FE origin), Swagger + Bearer security, DB init
- `appsettings.json` + `appsettings.Development.json` (connection string + JWT)
- `launchSettings.json` update port 5443
**Migration:**
- `Init` migration → `SolutionErp.Infrastructure/Persistence/Migrations/20260421034520_Init.cs`
- Applied to `SolutionErp_Dev` LocalDB ✅
### Frontend — 2 app React 19 + Vite + Tailwind 4
**Shared structure (fe-admin + fe-user):**
- Vite config: port + strictPort + `/api` proxy + `@/*` alias + Tailwind 4 plugin
- `src/index.css` — Tailwind import + theme brand colors (admin=blue, user=emerald)
- `src/lib/api.ts` — axios instance + JWT request interceptor + 401 redirect
- `src/lib/cn.ts` — clsx + tailwind-merge helper
- `src/types/auth.ts` — mirror BE DTOs
- `src/contexts/AuthContext.tsx` — login/logout, localStorage token, bootstrap on mount
- `src/components/ProtectedRoute.tsx` — redirect login nếu chưa auth
- `src/components/Layout.tsx` — sidebar + header, lucide icons, user info + logout
- `src/components/ui/Button.tsx`, `Input.tsx`, `Label.tsx` — mini shadcn (CVA + Tailwind)
- `src/pages/LoginPage.tsx` — form login với toast error
- `src/App.tsx` — Router + AuthProvider + Toaster (sonner)
- `src/main.tsx` — QueryClient (TanStack Query) + StrictMode
**Khác biệt 2 app:**
- fe-admin: port 8082, brand blue, menu [Tổng quan / HĐ / NCC / Users / Settings], route `/dashboard`
- fe-user: port 8080, brand emerald, menu [Chờ xử lý / HĐ của tôi / Tạo HĐ mới], route `/inbox`
- localStorage key riêng (`solution-erp-admin-token` vs `solution-erp-user-token`)
### Packages cài thêm
**Backend:**
- Microsoft.Extensions.Identity.Stores (Domain)
- Microsoft.AspNetCore.Identity.EntityFrameworkCore (Infrastructure)
- System.IdentityModel.Tokens.Jwt (Infrastructure)
- Microsoft.Extensions.DependencyInjection.Abstractions (Application)
- Microsoft.EntityFrameworkCore.Design (Api — `PrivateAssets=all`)
**Frontend (cả 2 app):**
- tailwindcss 4 + @tailwindcss/vite
- axios, @tanstack/react-query, react-router-dom 7, sonner, lucide-react
- clsx, tailwind-merge, class-variance-authority
## Bug gặp phải + fix
| Bug | Root cause | Fix |
|---|---|---|
| `IApplicationDbContext.cs` error CS0234 EntityFrameworkCore | Thừa `using Microsoft.EntityFrameworkCore` nhưng không cần (chỉ có `SaveChangesAsync`) | Bỏ using |
| `ValidationBehavior` ambiguous `ValidationException` | FluentValidation + app custom cùng tên | `using ValidationException = ...Custom.ValidationException;` |
| `AddDefaultTokenProviders()` không có trong `IdentityCore` | Identity Core không include token providers | Remove call — sẽ add lại Phase 4 (password reset) |
| DbContext design-time không resolve được options | `AddDbContext` cần runtime config | Thêm `DesignTimeDbContextFactory` |
| Swagger 404 + IMediator not resolved | **Program.cs không persist** — Write tool ghi nhưng file vẫn là default scaffold (nghi Dropbox revert) | Write lại Program.cs — vấn đề giải quyết |
| `Microsoft.OpenApi.Models` namespace không có | Microsoft.OpenApi 2.0 (breaking change) đi kèm Microsoft.AspNetCore.OpenApi 10 | Remove `Microsoft.AspNetCore.OpenApi` (dùng Swashbuckle thay thế) |
| Swashbuckle 10.1.7 không tương thích OpenApi 2 | Swashbuckle chưa support OpenApi 2.x | Downgrade Swashbuckle → 6.9.0 |
| MediatR 14.1.0 không có `AddMediatR` extension | MediatR v14 refactored | Downgrade → 12.4.1 |
| `baseUrl` deprecated trong TS 6 | TypeScript 6 deprecates baseUrl | Bỏ baseUrl, chỉ giữ paths (works relative to tsconfig location) |
## E2E verified (2026-04-21 10:56)
`POST http://localhost:8082/api/auth/login` (fe-admin Vite proxy) → 200 + JWT
`POST http://localhost:8080/api/auth/login` (fe-user Vite proxy) → 200 + JWT
`GET http://localhost:5443/api/auth/me` với Bearer → 200 + user info
`GET http://localhost:5443/swagger/v1/swagger.json` → 200
✅ Seed admin (`admin@solutionerp.local` / `Admin@123456`) + 12 roles chạy tự động
## Bonus: package constraints ghi lại
Phase 1 đã phát hiện vài package không tương thích .NET 10 / TS 6 / Vite 8:
- Swashbuckle 6.9.0 (không 10.x)
- MediatR 12.4.1 (không 14.x)
- tsconfig không dùng `baseUrl` (TS 6 deprecate)
Nếu Phase 2-5 thấy package mới lại bug, thử **downgrade về stable** trước khi debug.
## Handoff cho session tiếp theo
**Phase 1 đợt 2** — xem STATUS.md section "Next up":
1. CRUD master data (Supplier/Project/Department)
2. Permission Matrix
3. Contract entity draft
**Đọc trước khi code:**
1. [docs/STATUS.md](../../STATUS.md)
2. [docs/workflow-contract.md](../../workflow-contract.md) (domain context)
3. Code vừa viết ở `src/Backend/SolutionErp.*/` + `fe-admin/src/`, `fe-user/src/`
**Blocker vẫn còn:**
- ⏳ Gitea remote URL
**Credentials:**
- `admin@solutionerp.local` / `Admin@123456`

3673
fe-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.2.3",
"@tanstack/react-query": "^5.99.2",
"axios": "^1.15.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",

View File

@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@ -1,120 +1,39 @@
import { useState } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import reactLogo from './assets/react.svg' import { Toaster } from 'sonner'
import viteLogo from './assets/vite.svg' import { AuthProvider } from '@/contexts/AuthContext'
import heroImg from './assets/hero.png' import { ProtectedRoute } from '@/components/ProtectedRoute'
import './App.css' import { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage'
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<> <BrowserRouter>
<section id="center"> <AuthProvider>
<div className="hero"> <Routes>
<img src={heroImg} className="base" width="170" height="179" alt="" /> <Route path="/login" element={<LoginPage />} />
<img src={reactLogo} className="framework" alt="React logo" /> <Route
<img src={viteLogo} className="vite" alt="Vite logo" /> element={
</div> <ProtectedRoute>
<div> <Layout />
<h1>Get started</h1> </ProtectedRoute>
<p> }
Edit <code>src/App.tsx</code> and save to test <code>HMR</code> >
</p> <Route path="/dashboard" element={<DashboardPage />} />
</div> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<button <Route
className="counter" path="*"
onClick={() => setCount((count) => count + 1)} element={
> <div className="p-8 text-slate-500">
Count is {count} Trang này chưa đưc build sẽ Phase 1 đt 2 / Phase 2 / 3.
</button> </div>
</section> }
/>
<div className="ticks"></div> </Route>
</Routes>
<section id="next-steps"> <Toaster richColors position="top-right" />
<div id="docs"> </AuthProvider>
<svg className="icon" role="presentation" aria-hidden="true"> </BrowserRouter>
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
) )
} }

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}
/>
)
}

View File

@ -0,0 +1,56 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, TOKEN_KEY, USER_KEY } from '@/lib/api'
import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth'
type AuthContextValue = {
user: UserInfo | null
isAuthenticated: boolean
isBootstrapping: boolean
login: (payload: LoginPayload) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null)
const [isBootstrapping, setIsBootstrapping] = useState(true)
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY)
const raw = localStorage.getItem(USER_KEY)
if (token && raw) {
try {
setUser(JSON.parse(raw))
} catch {
localStorage.removeItem(USER_KEY)
}
}
setIsBootstrapping(false)
}, [])
async function login(payload: LoginPayload) {
const res = await api.post<AuthResponse>('/auth/login', payload)
localStorage.setItem(TOKEN_KEY, res.data.accessToken)
localStorage.setItem(USER_KEY, JSON.stringify(res.data.user))
setUser(res.data.user)
}
function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
setUser(null)
}
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isBootstrapping, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}

View File

@ -1,111 +1,20 @@
:root { @import "tailwindcss";
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; @theme {
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --color-brand-50: #eff6ff;
--mono: ui-monospace, Consolas, monospace; --color-brand-500: #2563eb;
--color-brand-600: #1d4ed8;
font: 18px/145% var(--sans); --color-brand-700: #1e40af;
letter-spacing: 0.18px; --font-sans: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
} }
@media (prefers-color-scheme: dark) { html, body, #root {
:root { height: 100%;
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
} background-color: #f8fafc;
color: #0f172a;
h1, font-family: var(--font-sans);
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
} }

31
fe-admin/src/lib/api.ts Normal file
View File

@ -0,0 +1,31 @@
import axios from 'axios'
export const TOKEN_KEY = 'solution-erp-admin-token'
export const USER_KEY = 'solution-erp-admin-user'
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use(config => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
},
)

6
fe-admin/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,10 +1,22 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>, </StrictMode>,
) )

View File

@ -0,0 +1,28 @@
import { useAuth } from '@/contexts/AuthContext'
export function DashboardPage() {
const { user } = useAuth()
return (
<div className="p-8">
<h1 className="text-2xl font-bold text-slate-900">Tổng quan</h1>
<p className="mt-2 text-slate-600">
Xin chào <span className="font-medium">{user?.fullName}</span>. Vai trò:{' '}
<span className="font-mono text-sm">{user?.roles.join(', ')}</span>
</p>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
{[
{ label: 'HĐ đang xử lý', value: '—', hint: 'Sẽ hiển thị sau Phase 3' },
{ label: 'HĐ chờ tôi duyệt', value: '—', hint: 'Sẽ hiển thị sau Phase 3' },
{ label: 'Tổng NCC', value: '—', hint: 'Sẽ hiển thị sau Phase 1 đợt 2' },
].map(card => (
<div key={card.label} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="text-xs font-medium text-slate-500">{card.label}</div>
<div className="mt-2 text-3xl font-bold text-slate-900">{card.value}</div>
<div className="mt-1 text-xs text-slate-400">{card.hint}</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
import { useState, type FormEvent } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { useAuth } from '@/contexts/AuthContext'
import axios from 'axios'
export function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [email, setEmail] = useState('admin@solutionerp.local')
const [password, setPassword] = useState('Admin@123456')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
try {
await login({ email, password })
const redirectTo = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/dashboard'
navigate(redirectTo, { replace: true })
toast.success('Đăng nhập thành công')
} catch (err) {
const msg = axios.isAxiosError(err)
? err.response?.data?.detail ?? err.response?.data?.title ?? err.message
: 'Lỗi kết nối máy chủ'
toast.error(`Đăng nhập thất bại: ${msg}`)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-100 px-4">
<div className="w-full max-w-md rounded-lg border border-slate-200 bg-white p-8 shadow-sm">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold text-brand-700">SOLUTION ERP</h1>
<p className="mt-1 text-sm text-slate-500">Trang quản trị</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Mật khẩu</Label>
<Input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Đang đăng nhập…' : 'Đăng nhập'}
</Button>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
export type UserInfo = {
id: string
email: string
fullName: string
roles: string[]
}
export type AuthResponse = {
accessToken: string
refreshToken: string
refreshTokenExpiresAt: string
user: UserInfo
}
export type LoginPayload = {
email: string
password: string
}

View File

@ -19,7 +19,12 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
/* Path alias */
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }

View File

@ -1,10 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path' import path from 'node:path'
// Admin UI — port 8082, proxy /api → SolutionErp.Api (http://localhost:5443) // Admin UI — port 8082, proxy /api → SolutionErp.Api (http://localhost:5443)
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),

3673
fe-user/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.2.3",
"@tanstack/react-query": "^5.99.2",
"axios": "^1.15.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",

View File

@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@ -1,120 +1,39 @@
import { useState } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import reactLogo from './assets/react.svg' import { Toaster } from 'sonner'
import viteLogo from './assets/vite.svg' import { AuthProvider } from '@/contexts/AuthContext'
import heroImg from './assets/hero.png' import { ProtectedRoute } from '@/components/ProtectedRoute'
import './App.css' import { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage'
import { InboxPage } from '@/pages/InboxPage'
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<> <BrowserRouter>
<section id="center"> <AuthProvider>
<div className="hero"> <Routes>
<img src={heroImg} className="base" width="170" height="179" alt="" /> <Route path="/login" element={<LoginPage />} />
<img src={reactLogo} className="framework" alt="React logo" /> <Route
<img src={viteLogo} className="vite" alt="Vite logo" /> element={
</div> <ProtectedRoute>
<div> <Layout />
<h1>Get started</h1> </ProtectedRoute>
<p> }
Edit <code>src/App.tsx</code> and save to test <code>HMR</code> >
</p> <Route path="/inbox" element={<InboxPage />} />
</div> <Route path="/" element={<Navigate to="/inbox" replace />} />
<button <Route
className="counter" path="*"
onClick={() => setCount((count) => count + 1)} element={
> <div className="p-8 text-slate-500">
Count is {count} Trang này chưa đưc build sẽ Phase 1 đt 2 / Phase 2 / 3.
</button> </div>
</section> }
/>
<div className="ticks"></div> </Route>
</Routes>
<section id="next-steps"> <Toaster richColors position="top-right" />
<div id="docs"> </AuthProvider>
<svg className="icon" role="presentation" aria-hidden="true"> </BrowserRouter>
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
) )
} }

View File

@ -0,0 +1,61 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
import { LogOut, Inbox, FileText, Plus } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
import { cn } from '@/lib/cn'
const menuItems = [
{ to: '/inbox', label: 'Chờ xử lý', icon: Inbox },
{ to: '/my-contracts', label: 'HĐ của tôi', icon: FileText },
{ to: '/contracts/new', label: 'Tạo HĐ mới', icon: Plus },
]
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="/inbox" className="text-base font-bold text-brand-700">
SOLUTION ERP
</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}
/>
)
}

View File

@ -0,0 +1,56 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, TOKEN_KEY, USER_KEY } from '@/lib/api'
import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth'
type AuthContextValue = {
user: UserInfo | null
isAuthenticated: boolean
isBootstrapping: boolean
login: (payload: LoginPayload) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null)
const [isBootstrapping, setIsBootstrapping] = useState(true)
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY)
const raw = localStorage.getItem(USER_KEY)
if (token && raw) {
try {
setUser(JSON.parse(raw))
} catch {
localStorage.removeItem(USER_KEY)
}
}
setIsBootstrapping(false)
}, [])
async function login(payload: LoginPayload) {
const res = await api.post<AuthResponse>('/auth/login', payload)
localStorage.setItem(TOKEN_KEY, res.data.accessToken)
localStorage.setItem(USER_KEY, JSON.stringify(res.data.user))
setUser(res.data.user)
}
function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
setUser(null)
}
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isBootstrapping, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}

View File

@ -1,111 +1,20 @@
:root { @import "tailwindcss";
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; @theme {
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --color-brand-50: #ecfdf5;
--mono: ui-monospace, Consolas, monospace; --color-brand-500: #059669;
--color-brand-600: #047857;
font: 18px/145% var(--sans); --color-brand-700: #065f46;
letter-spacing: 0.18px; --font-sans: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
} }
@media (prefers-color-scheme: dark) { html, body, #root {
:root { height: 100%;
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
} background-color: #f8fafc;
color: #0f172a;
h1, font-family: var(--font-sans);
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
} }

31
fe-user/src/lib/api.ts Normal file
View File

@ -0,0 +1,31 @@
import axios from 'axios'
export const TOKEN_KEY = 'solution-erp-user-token'
export const USER_KEY = 'solution-erp-user-user'
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use(config => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
},
)

6
fe-user/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,10 +1,22 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>, </StrictMode>,
) )

View File

@ -0,0 +1,18 @@
import { useAuth } from '@/contexts/AuthContext'
export function InboxPage() {
const { user } = useAuth()
return (
<div className="p-8">
<h1 className="text-2xl font-bold text-slate-900">Hộp thư chờ xử </h1>
<p className="mt-2 text-slate-600">
Xin chào <span className="font-medium">{user?.fullName}</span>. Vai trò:{' '}
<span className="font-mono text-sm">{user?.roles.join(', ')}</span>
</p>
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-6 text-sm text-slate-500">
Danh sách chờ role của bạn xử sẽ hiển thị đây (Phase 3).
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
import { useState, type FormEvent } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { useAuth } from '@/contexts/AuthContext'
import axios from 'axios'
export function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [email, setEmail] = useState('admin@solutionerp.local')
const [password, setPassword] = useState('Admin@123456')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
try {
await login({ email, password })
const redirectTo = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/inbox'
navigate(redirectTo, { replace: true })
toast.success('Đăng nhập thành công')
} catch (err) {
const msg = axios.isAxiosError(err)
? err.response?.data?.detail ?? err.response?.data?.title ?? err.message
: 'Lỗi kết nối máy chủ'
toast.error(`Đăng nhập thất bại: ${msg}`)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-100 px-4">
<div className="w-full max-w-md rounded-lg border border-slate-200 bg-white p-8 shadow-sm">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold text-brand-700">SOLUTION ERP</h1>
<p className="mt-1 text-sm text-slate-500">Quản hợp đng nhà cung cấp</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Mật khẩu</Label>
<Input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Đang đăng nhập…' : 'Đăng nhập'}
</Button>
</form>
</div>
</div>
)
}

18
fe-user/src/types/auth.ts Normal file
View File

@ -0,0 +1,18 @@
export type UserInfo = {
id: string
email: string
fullName: string
roles: string[]
}
export type AuthResponse = {
accessToken: string
refreshToken: string
refreshTokenExpiresAt: string
user: UserInfo
}
export type LoginPayload = {
email: string
password: string
}

View File

@ -19,7 +19,12 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
/* Path alias */
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }

View File

@ -1,10 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path' import path from 'node:path'
// User UI — port 8080, proxy /api → SolutionErp.Api (http://localhost:5443) // User UI — port 8080, proxy /api → SolutionErp.Api (http://localhost:5443)
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),

View File

@ -0,0 +1,40 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Auth.Commands.Login;
using SolutionErp.Application.Auth.Commands.Refresh;
using SolutionErp.Application.Auth.Dtos;
using SolutionErp.Application.Auth.Queries.Me;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly IMediator _mediator;
public AuthController(IMediator mediator) => _mediator = mediator;
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponseDto>> Login([FromBody] LoginCommand command, CancellationToken ct)
=> Ok(await _mediator.Send(command, ct));
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponseDto>> Refresh([FromBody] RefreshTokenCommand command, CancellationToken ct)
=> Ok(await _mediator.Send(command, ct));
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<UserInfoDto>> Me(CancellationToken ct)
=> Ok(await _mediator.Send(new GetMeQuery(), ct));
[HttpPost("logout")]
[Authorize]
public IActionResult Logout()
{
return NoContent();
}
}

View File

@ -1,25 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries =
[
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
];
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}

View File

@ -0,0 +1,64 @@
using System.Net;
using System.Text.Json;
using SolutionErp.Application.Common.Exceptions;
namespace SolutionErp.Api.Middleware;
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleAsync(context, ex);
}
}
private async Task HandleAsync(HttpContext context, Exception ex)
{
var (status, type, title) = ex switch
{
ValidationException => ((int)HttpStatusCode.BadRequest, "https://tools.ietf.org/html/rfc9110#section-15.5.1", "Dữ liệu không hợp lệ"),
NotFoundException => ((int)HttpStatusCode.NotFound, "https://tools.ietf.org/html/rfc9110#section-15.5.5", "Không tìm thấy"),
UnauthorizedException => ((int)HttpStatusCode.Unauthorized, "https://tools.ietf.org/html/rfc9110#section-15.5.2", "Chưa xác thực"),
ForbiddenException => ((int)HttpStatusCode.Forbidden, "https://tools.ietf.org/html/rfc9110#section-15.5.4", "Bị từ chối"),
ConflictException => ((int)HttpStatusCode.Conflict, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Xung đột dữ liệu"),
_ => ((int)HttpStatusCode.InternalServerError, "https://tools.ietf.org/html/rfc9110#section-15.6.1", "Lỗi hệ thống"),
};
if (status >= 500)
_logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
else
_logger.LogWarning(ex, "Handled exception: {Message}", ex.Message);
var problem = new
{
type,
title,
status,
detail = ex.Message,
errors = (ex as ValidationException)?.Errors,
traceId = context.TraceIdentifier,
};
context.Response.ContentType = "application/problem+json";
context.Response.StatusCode = status;
await context.Response.WriteAsync(JsonSerializer.Serialize(problem, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
}
}

View File

@ -1,23 +1,120 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Serilog;
using SolutionErp.Api.Middleware;
using SolutionErp.Api.Services;
using SolutionErp.Application;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Infrastructure;
using SolutionErp.Infrastructure.Identity;
using SolutionErp.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // ---------- Logging (Serilog) ----------
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console());
// ---------- Core services ----------
builder.Services.AddControllers(); builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddHttpContextAccessor();
builder.Services.AddOpenApi(); builder.Services.AddScoped<ICurrentUser, CurrentUserService>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// ---------- JWT auth ----------
var jwt = builder.Configuration.GetSection(JwtSettings.SectionName).Get<JwtSettings>()
?? throw new InvalidOperationException("Missing Jwt settings");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwt.Issuer,
ValidAudience = jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)),
ClockSkew = TimeSpan.FromMinutes(1),
};
});
builder.Services.AddAuthorization();
// ---------- CORS (2 FE dev origins) ----------
builder.Services.AddCors(opts =>
{
opts.AddDefaultPolicy(p => p
.WithOrigins("http://localhost:8080", "http://localhost:8082")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
});
// ---------- Swagger ----------
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "SolutionErp API", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT Bearer token — nhập chỉ token, Swagger tự thêm 'Bearer '.",
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" },
},
Array.Empty<string>()
},
});
});
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. // ---------- Pipeline ----------
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "SolutionErp API v1"));
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
// ---------- DB init + seed ----------
if (!args.Contains("--no-db-init"))
{
try
{
await DbInitializer.InitializeAsync(app.Services);
}
catch (Exception ex)
{
app.Logger.LogError(ex, "DB initialization failed — app vẫn chạy, check connection string.");
}
}
app.Run(); app.Run();

View File

@ -1,20 +1,21 @@
{ {
"$schema": "https://json.schemastore.org/launchsettings.json", "$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": { "profiles": {
"http": { "https": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": true,
"applicationUrl": "http://localhost:5235", "launchUrl": "swagger",
"applicationUrl": "https://localhost:5443;http://localhost:5444",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
}, },
"https": { "http": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "https://localhost:7241;http://localhost:5235", "applicationUrl": "http://localhost:5444",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View File

@ -0,0 +1,34 @@
using System.Security.Claims;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Api.Services;
public class CurrentUserService : ICurrentUser
{
private readonly IHttpContextAccessor _accessor;
public CurrentUserService(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
private ClaimsPrincipal? User => _accessor.HttpContext?.User;
public Guid? UserId
{
get
{
var sub = User?.FindFirstValue(ClaimTypes.NameIdentifier)
?? User?.FindFirstValue("sub");
return Guid.TryParse(sub, out var id) ? id : null;
}
}
public string? Email => User?.FindFirstValue(ClaimTypes.Email);
public string? FullName => User?.FindFirstValue("fullName");
public IReadOnlyList<string> Roles =>
User?.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList() ?? new List<string>();
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
}

View File

@ -8,9 +8,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,12 +0,0 @@
namespace SolutionErp.Api;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}

View File

@ -1,8 +1,18 @@
{ {
"Logging": { "ConnectionStrings": {
"LogLevel": { "Default": "Server=(localdb)\\MSSQLLocalDB;Database=SolutionErp_Dev;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=true"
"Default": "Information", },
"Microsoft.AspNetCore": "Warning" "Jwt": {
"Secret": "dev_only_secret_min_32_chars_NOT_for_production_please_change"
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"Microsoft.EntityFrameworkCore": "Information",
"System": "Warning"
}
} }
} }
} }

View File

@ -1,8 +1,22 @@
{ {
"Logging": { "ConnectionStrings": {
"LogLevel": { "Default": "Server=(localdb)\\MSSQLLocalDB;Database=SolutionErp;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=true"
},
"Jwt": {
"Issuer": "SolutionErp.Api",
"Audience": "SolutionErp.Client",
"Secret": "CHANGE_ME_minimum_32_chars_production_secret_here_please",
"AccessTokenExpiryMinutes": 60,
"RefreshTokenExpiryDays": 7
},
"Serilog": {
"MinimumLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*"

View File

@ -0,0 +1,55 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using SolutionErp.Application.Auth.Dtos;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Auth.Commands.Login;
public record LoginCommand(string Email, string Password) : IRequest<AuthResponseDto>;
public class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Password).NotEmpty().MinimumLength(6);
}
}
public class LoginCommandHandler : IRequestHandler<LoginCommand, AuthResponseDto>
{
private readonly UserManager<User> _userManager;
private readonly IJwtTokenService _jwtTokenService;
public LoginCommandHandler(UserManager<User> userManager, IJwtTokenService jwtTokenService)
{
_userManager = userManager;
_jwtTokenService = jwtTokenService;
}
public async Task<AuthResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null || !user.IsActive)
throw new UnauthorizedException("Email hoặc mật khẩu không đúng.");
if (!await _userManager.CheckPasswordAsync(user, request.Password))
throw new UnauthorizedException("Email hoặc mật khẩu không đúng.");
var roles = await _userManager.GetRolesAsync(user);
var tokens = await _jwtTokenService.GenerateTokensAsync(user, roles);
user.RefreshToken = tokens.RefreshToken;
user.RefreshTokenExpiresAt = tokens.RefreshTokenExpiresAt;
await _userManager.UpdateAsync(user);
return new AuthResponseDto(
tokens.AccessToken,
tokens.RefreshToken,
tokens.RefreshTokenExpiresAt,
new UserInfoDto(user.Id, user.Email!, user.FullName, roles.ToList()));
}
}

View File

@ -0,0 +1,56 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using SolutionErp.Application.Auth.Dtos;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Auth.Commands.Refresh;
public record RefreshTokenCommand(string RefreshToken) : IRequest<AuthResponseDto>;
public class RefreshTokenCommandValidator : AbstractValidator<RefreshTokenCommand>
{
public RefreshTokenCommandValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty();
}
}
public class RefreshTokenCommandHandler : IRequestHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly UserManager<User> _userManager;
private readonly IJwtTokenService _jwtTokenService;
private readonly IDateTime _dateTime;
public RefreshTokenCommandHandler(
UserManager<User> userManager,
IJwtTokenService jwtTokenService,
IDateTime dateTime)
{
_userManager = userManager;
_jwtTokenService = jwtTokenService;
_dateTime = dateTime;
}
public async Task<AuthResponseDto> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
var user = _userManager.Users.FirstOrDefault(u => u.RefreshToken == request.RefreshToken);
if (user is null || user.RefreshTokenExpiresAt is null || user.RefreshTokenExpiresAt < _dateTime.UtcNow)
throw new UnauthorizedException("Refresh token không hợp lệ hoặc đã hết hạn.");
var roles = await _userManager.GetRolesAsync(user);
var tokens = await _jwtTokenService.GenerateTokensAsync(user, roles);
user.RefreshToken = tokens.RefreshToken;
user.RefreshTokenExpiresAt = tokens.RefreshTokenExpiresAt;
await _userManager.UpdateAsync(user);
return new AuthResponseDto(
tokens.AccessToken,
tokens.RefreshToken,
tokens.RefreshTokenExpiresAt,
new UserInfoDto(user.Id, user.Email!, user.FullName, roles.ToList()));
}
}

View File

@ -0,0 +1,13 @@
namespace SolutionErp.Application.Auth.Dtos;
public record AuthResponseDto(
string AccessToken,
string RefreshToken,
DateTime RefreshTokenExpiresAt,
UserInfoDto User);
public record UserInfoDto(
Guid Id,
string Email,
string FullName,
IReadOnlyList<string> Roles);

View File

@ -0,0 +1,34 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using SolutionErp.Application.Auth.Dtos;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Auth.Queries.Me;
public record GetMeQuery : IRequest<UserInfoDto>;
public class GetMeQueryHandler : IRequestHandler<GetMeQuery, UserInfoDto>
{
private readonly ICurrentUser _currentUser;
private readonly UserManager<User> _userManager;
public GetMeQueryHandler(ICurrentUser currentUser, UserManager<User> userManager)
{
_currentUser = currentUser;
_userManager = userManager;
}
public async Task<UserInfoDto> Handle(GetMeQuery request, CancellationToken cancellationToken)
{
if (!_currentUser.IsAuthenticated || _currentUser.UserId is null)
throw new UnauthorizedException();
var user = await _userManager.FindByIdAsync(_currentUser.UserId.Value.ToString())
?? throw new UnauthorizedException();
var roles = await _userManager.GetRolesAsync(user);
return new UserInfoDto(user.Id, user.Email!, user.FullName, roles.ToList());
}
}

View File

@ -0,0 +1,33 @@
using FluentValidation;
using MediatR;
using ValidationException = SolutionErp.Application.Common.Exceptions.ValidationException;
namespace SolutionErp.Application.Common.Behaviors;
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}

View File

@ -0,0 +1,40 @@
namespace SolutionErp.Application.Common.Exceptions;
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
public NotFoundException(string entity, object id)
: base($"{entity} với id '{id}' không tồn tại.") { }
}
public class ValidationException : Exception
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException() : base("Có lỗi trong dữ liệu gửi lên.")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(IEnumerable<FluentValidation.Results.ValidationFailure> failures) : this()
{
Errors = failures
.GroupBy(f => f.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
}
}
public class ForbiddenException : Exception
{
public ForbiddenException(string message = "Không đủ quyền thực hiện thao tác này.") : base(message) { }
}
public class UnauthorizedException : Exception
{
public UnauthorizedException(string message = "Chưa đăng nhập hoặc token không hợp lệ.") : base(message) { }
}
public class ConflictException : Exception
{
public ConflictException(string message) : base(message) { }
}

View File

@ -0,0 +1,6 @@
namespace SolutionErp.Application.Common.Interfaces;
public interface IApplicationDbContext
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,10 @@
namespace SolutionErp.Application.Common.Interfaces;
public interface ICurrentUser
{
Guid? UserId { get; }
string? Email { get; }
string? FullName { get; }
IReadOnlyList<string> Roles { get; }
bool IsAuthenticated { get; }
}

View File

@ -0,0 +1,7 @@
namespace SolutionErp.Application.Common.Interfaces;
public interface IDateTime
{
DateTime UtcNow { get; }
DateTime Now { get; }
}

View File

@ -0,0 +1,9 @@
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Common.Interfaces;
public interface IJwtTokenService
{
Task<(string AccessToken, string RefreshToken, DateTime RefreshTokenExpiresAt)> GenerateTokensAsync(User user, IList<string> roles);
Task<Guid?> ValidateRefreshTokenAsync(string userId, string refreshToken);
}

View File

@ -0,0 +1,23 @@
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Behaviors;
namespace SolutionErp.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = typeof(DependencyInjection).Assembly;
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
services.AddValidatorsFromAssembly(assembly);
return services;
}
}

View File

@ -7,7 +7,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper" Version="16.1.1" /> <PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.1.0" /> <PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@ -0,0 +1,8 @@
namespace SolutionErp.Domain.Common;
public abstract class AuditableEntity : BaseEntity
{
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace SolutionErp.Domain.Common;
public abstract class BaseEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public Guid? CreatedBy { get; set; }
public Guid? UpdatedBy { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace SolutionErp.Domain.Contracts;
public enum ApprovalDecision
{
Pending = 0,
Approve = 1,
Reject = 2,
AutoApprove = 3,
}

View File

@ -0,0 +1,16 @@
namespace SolutionErp.Domain.Contracts;
// 9 phase state machine — xem docs/workflow-contract.md
public enum ContractPhase
{
DangChon = 1,
DangSoanThao = 2,
DangGopY = 3,
DangDamPhan = 4,
DangInKy = 5,
DangKiemTraCCM = 6,
DangTrinhKy = 7,
DangDongDau = 8,
DaPhatHanh = 9,
TuChoi = 99,
}

View File

@ -0,0 +1,12 @@
namespace SolutionErp.Domain.Contracts;
public enum ContractType
{
HopDongThauPhu = 1,
HopDongGiaoKhoan = 2,
HopDongNhaCungCap = 3,
HopDongDichVu = 4,
HopDongMuaBan = 5,
HopDongNguyenTacNCC = 6,
HopDongNguyenTacDichVu = 7,
}

View File

@ -0,0 +1,23 @@
namespace SolutionErp.Domain.Identity;
public static class AppRoles
{
public const string Admin = "Admin";
public const string Drafter = "Drafter";
public const string DeptManager = "DeptManager";
public const string ProjectManager = "ProjectManager";
public const string Procurement = "Procurement";
public const string CostControl = "CostControl";
public const string Finance = "Finance";
public const string Accounting = "Accounting";
public const string Equipment = "Equipment";
public const string Director = "Director";
public const string AuthorizedSigner = "AuthorizedSigner";
public const string HrAdmin = "HrAdmin";
public static readonly string[] All = [
Admin, Drafter, DeptManager, ProjectManager,
Procurement, CostControl, Finance, Accounting, Equipment,
Director, AuthorizedSigner, HrAdmin,
];
}

View File

@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Identity;
namespace SolutionErp.Domain.Identity;
public class Role : IdentityRole<Guid>
{
public string? Description { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Identity;
namespace SolutionErp.Domain.Identity;
public class User : IdentityUser<Guid>
{
public string FullName { get; set; } = string.Empty;
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiresAt { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}

View File

@ -6,4 +6,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.6" />
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Identity;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Services;
namespace SolutionErp.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<IDateTime, DateTimeService>();
services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<AuditingInterceptor>();
services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
var connectionString = configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Missing ConnectionStrings:Default");
options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName));
options.AddInterceptors(sp.GetRequiredService<AuditingInterceptor>());
});
services.AddScoped<IApplicationDbContext>(sp => sp.GetRequiredService<ApplicationDbContext>());
services.AddIdentityCore<User>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
options.User.RequireUniqueEmail = true;
})
.AddRoles<Role>()
.AddEntityFrameworkStores<ApplicationDbContext>();
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace SolutionErp.Infrastructure.Identity;
public class JwtSettings
{
public const string SectionName = "Jwt";
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public int AccessTokenExpiryMinutes { get; set; } = 60;
public int RefreshTokenExpiryDays { get; set; } = 7;
}

View File

@ -0,0 +1,63 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Infrastructure.Identity;
public class JwtTokenService : IJwtTokenService
{
private readonly JwtSettings _settings;
private readonly IDateTime _dateTime;
public JwtTokenService(IOptions<JwtSettings> options, IDateTime dateTime)
{
_settings = options.Value;
_dateTime = dateTime;
}
public Task<(string AccessToken, string RefreshToken, DateTime RefreshTokenExpiresAt)> GenerateTokensAsync(User user, IList<string> roles)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("fullName", user.FullName),
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
expires: _dateTime.UtcNow.AddMinutes(_settings.AccessTokenExpiryMinutes),
signingCredentials: creds);
var accessToken = new JwtSecurityTokenHandler().WriteToken(token);
var refreshToken = GenerateRefreshToken();
var refreshExpires = _dateTime.UtcNow.AddDays(_settings.RefreshTokenExpiryDays);
return Task.FromResult((accessToken, refreshToken, refreshExpires));
}
public Task<Guid?> ValidateRefreshTokenAsync(string userId, string refreshToken)
{
return Task.FromResult<Guid?>(null);
}
private static string GenerateRefreshToken()
{
var bytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes);
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Infrastructure.Persistence;
public class ApplicationDbContext
: IdentityDbContext<User, Role, Guid>, IApplicationDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Rename Identity tables
builder.Entity<User>(e =>
{
e.ToTable("Users");
e.Property(u => u.FullName).HasMaxLength(200).IsRequired();
e.Property(u => u.RefreshToken).HasMaxLength(512);
});
builder.Entity<Role>(e =>
{
e.ToTable("Roles");
e.Property(r => r.Description).HasMaxLength(500);
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserRole<Guid>>(e => e.ToTable("UserRoles"));
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserClaim<Guid>>(e => e.ToTable("UserClaims"));
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserLogin<Guid>>(e => e.ToTable("UserLogins"));
builder.Entity<Microsoft.AspNetCore.Identity.IdentityRoleClaim<Guid>>(e => e.ToTable("RoleClaims"));
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserToken<Guid>>(e => e.ToTable("UserTokens"));
builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
}

View File

@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Infrastructure.Persistence;
public static class DbInitializer
{
public const string AdminEmail = "admin@solutionerp.local";
public const string AdminPassword = "Admin@123456";
public static async Task InitializeAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var sp = scope.ServiceProvider;
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("DbInitializer");
var db = sp.GetRequiredService<ApplicationDbContext>();
var userManager = sp.GetRequiredService<UserManager<User>>();
var roleManager = sp.GetRequiredService<RoleManager<Role>>();
logger.LogInformation("Applying migrations...");
await db.Database.MigrateAsync();
foreach (var roleName in AppRoles.All)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
await roleManager.CreateAsync(new Role { Name = roleName, CreatedAt = DateTime.UtcNow });
logger.LogInformation("Created role {Role}", roleName);
}
}
var admin = await userManager.FindByEmailAsync(AdminEmail);
if (admin is null)
{
admin = new User
{
UserName = AdminEmail,
Email = AdminEmail,
FullName = "Administrator",
EmailConfirmed = true,
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
var result = await userManager.CreateAsync(admin, AdminPassword);
if (!result.Succeeded)
{
logger.LogError("Failed to seed admin: {Errors}", string.Join("; ", result.Errors.Select(e => e.Description)));
return;
}
await userManager.AddToRoleAsync(admin, AppRoles.Admin);
logger.LogInformation("Seeded admin user {Email}", AdminEmail);
}
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace SolutionErp.Infrastructure.Persistence;
// Chỉ dùng khi chạy `dotnet ef migrations` / `database update` từ CLI.
// Runtime app dùng AddDbContext trong DependencyInjection.cs.
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(
"Server=(localdb)\\MSSQLLocalDB;Database=SolutionErp_Design;Trusted_Connection=True;TrustServerCertificate=true",
sql => sql.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName))
.Options;
return new ApplicationDbContext(options);
}
}

View File

@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Common;
namespace SolutionErp.Infrastructure.Persistence.Interceptors;
public class AuditingInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUser _currentUser;
private readonly IDateTime _dateTime;
public AuditingInterceptor(ICurrentUser currentUser, IDateTime dateTime)
{
_currentUser = currentUser;
_dateTime = dateTime;
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
Apply(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
Apply(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void Apply(DbContext? context)
{
if (context is null) return;
var userId = _currentUser.UserId;
var now = _dateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<BaseEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = userId;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
break;
}
}
foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = now;
entry.Entity.DeletedBy = userId;
}
}
}
}

View File

@ -0,0 +1,306 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SolutionErp.Infrastructure.Persistence;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260421034520_Init")]
partial class Init
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("Roles", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("RefreshToken")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime?>("RefreshTokenExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,232 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Roles",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
RefreshToken = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
RefreshTokenExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_RoleClaims_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserClaims", x => x.Id);
table.ForeignKey(
name: "FK_UserClaims_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_UserLogins_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_UserRoles_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserRoles_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_UserTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RoleClaims_RoleId",
table: "RoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "Roles",
column: "NormalizedName",
unique: true,
filter: "[NormalizedName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UserClaims_UserId",
table: "UserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserLogins_UserId",
table: "UserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserRoles_RoleId",
table: "UserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "Users",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "Users",
column: "NormalizedUserName",
unique: true,
filter: "[NormalizedUserName] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RoleClaims");
migrationBuilder.DropTable(
name: "UserClaims");
migrationBuilder.DropTable(
name: "UserLogins");
migrationBuilder.DropTable(
name: "UserRoles");
migrationBuilder.DropTable(
name: "UserTokens");
migrationBuilder.DropTable(
name: "Roles");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,303 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SolutionErp.Infrastructure.Persistence;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("Roles", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("RefreshToken")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime?>("RefreshTokenExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,9 @@
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Infrastructure.Services;
public class DateTimeService : IDateTime
{
public DateTime UtcNow => DateTime.UtcNow;
public DateTime Now => DateTime.Now;
}

View File

@ -11,6 +11,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>