[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:
@ -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`.
|
||||
|
||||
**Last updated:** 2026-04-21
|
||||
**Last updated:** 2026-04-21 11:00
|
||||
|
||||
## 📍 Phase hiện tại: **Phase 0 — Draft Scaffold** (in progress)
|
||||
## 📍 Phase hiện tại: **Phase 1 — Alpha Core** (foundation xong, chờ đợt 2)
|
||||
|
||||
## 🔥 In Progress
|
||||
|
||||
| Ai | Task | Bắt đầu |
|
||||
|---|---|---|
|
||||
| Claude | Hoàn tất Phase 0 — docs + git init | 2026-04-21 |
|
||||
_(không có — Phase 1 foundation xong, chờ quyết định bước tiếp)_
|
||||
|
||||
## ✅ Recently Done (newest on top)
|
||||
|
||||
| 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 | Parse 8 FORM → `forms-spec.md` (catalog + RG-001 code format) | — |
|
||||
| 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 | — |
|
||||
| 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 | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` |
|
||||
|
||||
## 🎯 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`
|
||||
- [ ] 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)
|
||||
## 🎯 Next up — Phase 1 đợt 2 (CRUD master + Permission Matrix)
|
||||
|
||||
## 📋 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:
|
||||
1. Install Tailwind + shadcn/ui cho 2 FE + setup `BrandingProvider`
|
||||
2. Tạo `BaseEntity`, `AuditableEntity`, `ApplicationDbContext`, migration đầu tiên
|
||||
3. ASP.NET Identity + JWT endpoint (login, refresh, logout, seed admin)
|
||||
4. Permission Matrix (Role × MenuKey × CRUD) — copy pattern từ NamGroup skill
|
||||
5. CRUD Supplier + Project + Contract (draft only, chưa workflow)
|
||||
6. FE admin: login page + layout + permission guard + 3 CRUD page
|
||||
7. FE user: login + layout + list HĐ của tôi
|
||||
### Contract draft (chưa workflow)
|
||||
- [ ] `Domain/Entities/Contract` skeleton (không state machine)
|
||||
- [ ] Basic CRUD controller + FE list page
|
||||
|
||||
### FE
|
||||
- [ ] `<PermissionGuard menuKey="Contracts">` + `usePermission()` hook
|
||||
- [ ] 3 trang CRUD admin (Suppliers / Projects / Departments) với table + modal
|
||||
- [ ] 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
|
||||
|
||||
- **Gitea remote** chưa có URL — chờ user cấp để push code
|
||||
- **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
|
||||
- **Node 22 local vs 20 CI** — phải test CI sớm (Phase 5) để tránh surprise
|
||||
- ⏳ **Gitea remote** chưa có URL — push sau
|
||||
- ⚠️ **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.
|
||||
- ⚠️ **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)
|
||||
- Test coverage: 0% (chưa có test)
|
||||
```
|
||||
Email: admin@solutionerp.local
|
||||
Password: Admin@123456
|
||||
```
|
||||
|
||||
URLs dev:
|
||||
- API: http://localhost:5443 — Swagger ở `/swagger`
|
||||
- Admin FE: http://localhost:8082
|
||||
- User FE: http://localhost:8080
|
||||
|
||||
@ -15,75 +15,70 @@
|
||||
- [x] Parse 8 form → `docs/forms-spec.md`
|
||||
- [x] Parse quy trình → `docs/workflow-contract.md`
|
||||
- [x] Viết `docs/{CLAUDE,STATUS,PROJECT-MAP}.md`
|
||||
- [ ] Viết `.gitignore`, `README.md`, `global.json`, `docker-compose.yml`
|
||||
- [ ] Tạo placeholder skill folders: `contract-workflow`, `form-engine`, `permission-matrix`
|
||||
- [ ] `git init` + commit đầu
|
||||
- [x] Viết `.gitignore`, `README.md`, `global.json`, `docker-compose.yml`
|
||||
- [x] Tạo placeholder skill folders: `contract-workflow`, `form-engine`, `permission-matrix`
|
||||
- [x] `git init` + commit đầu (`25dad7f`)
|
||||
- [ ] Push Gitea remote (chờ URL từ user)
|
||||
|
||||
## Phase 1 — Alpha Core (T2-4)
|
||||
|
||||
### Backend foundation
|
||||
### Foundation (đã xong Session 2)
|
||||
|
||||
- [ ] `Domain/BaseEntity.cs` (Id, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy)
|
||||
- [ ] `Domain/AuditableEntity : BaseEntity` (IsDeleted, DeletedAt, DeletedBy)
|
||||
- [ ] `Domain/ValueObjects/ContractCode.cs` (wrap string theo format RG-001)
|
||||
- [ ] `Domain/Enums/ContractType.cs`, `ContractPhase.cs`, `ApprovalDecision.cs`
|
||||
- [ ] `Application/Common/IApplicationDbContext.cs` interface
|
||||
- [ ] `Application/Common/IDateTime.cs`, `ICurrentUser.cs`
|
||||
- [ ] `Application/DependencyInjection.cs` — register MediatR, FluentValidation, AutoMapper
|
||||
- [ ] `Infrastructure/Persistence/ApplicationDbContext.cs` : `IdentityDbContext<User, Role, Guid>`, `IApplicationDbContext`
|
||||
- [ ] Configurations per entity qua `IEntityTypeConfiguration<T>`
|
||||
- [ ] `Infrastructure/DependencyInjection.cs` — register DbContext, Identity, services
|
||||
- [ ] `Api/Program.cs` setup: services, Serilog, auth, Swagger, CORS, middleware
|
||||
- [ ] `Api/Middleware/GlobalExceptionMiddleware.cs`
|
||||
- [x] `Domain/Common/BaseEntity.cs` (Id Guid, CreatedAt, UpdatedAt, CreatedBy, UpdatedBy)
|
||||
- [x] `Domain/Common/AuditableEntity.cs` (IsDeleted, DeletedAt, DeletedBy)
|
||||
- [x] `Domain/Contracts/` Enums: `ContractType`, `ContractPhase` (9 state), `ApprovalDecision`
|
||||
- [x] `Domain/Identity/User.cs` (IdentityUser<Guid> + FullName + RefreshToken + IsActive)
|
||||
- [x] `Domain/Identity/Role.cs` (IdentityRole<Guid> + Description)
|
||||
- [x] `Domain/Identity/AppRoles.cs` — 12 role constants
|
||||
- [x] `Application/Common/Interfaces/`: IApplicationDbContext, ICurrentUser, IDateTime, IJwtTokenService
|
||||
- [x] `Application/Common/Exceptions/*`
|
||||
- [x] `Application/Common/Behaviors/ValidationBehavior.cs`
|
||||
- [x] `Application/DependencyInjection.cs` — MediatR + FluentValidation
|
||||
- [x] `Infrastructure/Persistence/ApplicationDbContext.cs : IdentityDbContext`
|
||||
- [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>`
|
||||
- [ ] Migration 1: `Init` (Identity tables)
|
||||
- [ ] `Application/Auth/Commands/LoginCommand` + handler + validator
|
||||
- [ ] `Application/Auth/Commands/RefreshTokenCommand`
|
||||
- [ ] `Api/Controllers/AuthController` (login, refresh, logout, me)
|
||||
- [ ] JWT config: issuer, audience, key, expiry 1h + refresh 7d
|
||||
- [ ] Seed admin: `admin@solutionerp.local` / `Admin@123456`
|
||||
- [ ] Test login → get token → call `/me` OK
|
||||
|
||||
### Permission Matrix
|
||||
|
||||
- [ ] `Domain/Entities/MenuItem` (Key, Label, ParentKey, Order, Icon)
|
||||
- [ ] `Domain/Entities/Permission` (RoleId, MenuKey, CanRead, CanCreate, CanUpdate, CanDelete)
|
||||
- [ ] Seed default menu tree (based on FE screens list)
|
||||
- [ ] `Application/Permissions/Queries/GetMyMenuTree` — resolve per-user
|
||||
- [ ] `Api/Controllers/MenusController` + `RolesController` + `PermissionsController`
|
||||
- [ ] Admin UI: Permission Matrix grid (role × menu × CRUD checkbox)
|
||||
|
||||
### CRUD master data
|
||||
|
||||
- [ ] `Domain/Entities/Supplier` (Code, Name, TaxCode, Phone, Email, Address, Type: NCC/NTP/TĐ/ĐVDV)
|
||||
- [ ] `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)
|
||||
- [ ] `Domain/Entities/Supplier` (Code, Name, TaxCode, Phone, Email, Address, Type enum: NCC/NTP/TĐ/ĐVDV)
|
||||
- [ ] `Domain/Entities/Project` (Code, Name, StartDate, EndDate, ManagerUserId)
|
||||
- [ ] `Domain/Entities/Department` (Code, Name, ManagerUserId)
|
||||
- [ ] EF `IEntityTypeConfiguration<T>` cho mỗi entity
|
||||
- [ ] CQRS CRUD: Create/Update/Delete/GetById/List (với paging) cho 3 entity
|
||||
- [ ] `Api/Controllers/{SuppliersController, ProjectsController, DepartmentsController}`
|
||||
- [ ] Migration 2: `AddMasterData`
|
||||
- [ ] `Domain/Entities/MenuItem` (Key PascalCase, Label, ParentKey, Order, Icon)
|
||||
- [ ] `Domain/Entities/Permission` (RoleId, MenuKey, CanRead/Create/Update/Delete)
|
||||
- [ ] Seed default menu tree + permission admin có full access
|
||||
- [ ] `Application/Permissions/Queries/GetMyMenuTreeQuery` — resolve per-user, cache
|
||||
- [ ] `Api/Controllers/{MenusController, RolesController, PermissionsController}`
|
||||
- [ ] Migration 3: `AddPermissions`
|
||||
- [ ] `Domain/Entities/Contract` skeleton (Id, Type, SupplierId, ProjectId, Phase=DangChon, DraftData JSON)
|
||||
- [ ] Contract CRUD draft only (không workflow Phase 3)
|
||||
- [ ] FE: `<PermissionGuard menuKey="Suppliers" action="Update">` + `usePermission()` hook
|
||||
- [ ] FE Admin: 3 trang CRUD Supplier/Project/Department với table + modal + search/sort
|
||||
- [ ] FE Admin: Permission Matrix grid page (role × menu × CRUD checkbox)
|
||||
- [ ] FE User: trang "HĐ của tôi" list + filter
|
||||
- [ ] Route guard theo role admin-only
|
||||
- [ ] Update `SolutionErp.slnx` nếu thêm project mới
|
||||
|
||||
### Exit criteria Phase 1
|
||||
|
||||
|
||||
68
docs/changelog/sessions/2026-04-21-1045-phase0-scaffold.md
Normal file
68
docs/changelog/sessions/2026-04-21-1045-phase0-scaffold.md
Normal 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
|
||||
128
docs/changelog/sessions/2026-04-21-1100-phase1-foundation.md
Normal file
128
docs/changelog/sessions/2026-04-21-1100-phase1-foundation.md
Normal 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
3673
fe-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -13,8 +13,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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-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": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,120 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div className="p-8 text-slate-500">
|
||||
Trang này chưa được build — sẽ có ở Phase 1 đợt 2 / Phase 2 / 3.
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster richColors position="top-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
63
fe-admin/src/components/Layout.tsx
Normal file
63
fe-admin/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
fe-admin/src/components/ProtectedRoute.tsx
Normal file
16
fe-admin/src/components/ProtectedRoute.tsx
Normal 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}</>
|
||||
}
|
||||
33
fe-admin/src/components/ui/Button.tsx
Normal file
33
fe-admin/src/components/ui/Button.tsx
Normal 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'
|
||||
16
fe-admin/src/components/ui/Input.tsx
Normal file
16
fe-admin/src/components/ui/Input.tsx
Normal 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'
|
||||
11
fe-admin/src/components/ui/Label.tsx
Normal file
11
fe-admin/src/components/ui/Label.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
56
fe-admin/src/contexts/AuthContext.tsx
Normal file
56
fe-admin/src/contexts/AuthContext.tsx
Normal 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
|
||||
}
|
||||
@ -1,111 +1,20 @@
|
||||
:root {
|
||||
--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;
|
||||
@import "tailwindcss";
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
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;
|
||||
}
|
||||
@theme {
|
||||
--color-brand-50: #eff6ff;
|
||||
--color-brand-500: #2563eb;
|
||||
--color-brand-600: #1d4ed8;
|
||||
--color-brand-700: #1e40af;
|
||||
--font-sans: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--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;
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
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);
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
31
fe-admin/src/lib/api.ts
Normal file
31
fe-admin/src/lib/api.ts
Normal 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
6
fe-admin/src/lib/cn.ts
Normal 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))
|
||||
}
|
||||
@ -1,10 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
28
fe-admin/src/pages/DashboardPage.tsx
Normal file
28
fe-admin/src/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
fe-admin/src/pages/LoginPage.tsx
Normal file
73
fe-admin/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
fe-admin/src/types/auth.ts
Normal file
18
fe-admin/src/types/auth.ts
Normal 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
|
||||
}
|
||||
@ -19,7 +19,12 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path alias */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// Admin UI — port 8082, proxy /api → SolutionErp.Api (http://localhost:5443)
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
3673
fe-user/package-lock.json
generated
Normal file
3673
fe-user/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -13,8 +13,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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-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": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,120 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { InboxPage } from '@/pages/InboxPage'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div className="p-8 text-slate-500">
|
||||
Trang này chưa được build — sẽ có ở Phase 1 đợt 2 / Phase 2 / 3.
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster richColors position="top-right" />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
61
fe-user/src/components/Layout.tsx
Normal file
61
fe-user/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
fe-user/src/components/ProtectedRoute.tsx
Normal file
16
fe-user/src/components/ProtectedRoute.tsx
Normal 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}</>
|
||||
}
|
||||
33
fe-user/src/components/ui/Button.tsx
Normal file
33
fe-user/src/components/ui/Button.tsx
Normal 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'
|
||||
16
fe-user/src/components/ui/Input.tsx
Normal file
16
fe-user/src/components/ui/Input.tsx
Normal 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'
|
||||
11
fe-user/src/components/ui/Label.tsx
Normal file
11
fe-user/src/components/ui/Label.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
56
fe-user/src/contexts/AuthContext.tsx
Normal file
56
fe-user/src/contexts/AuthContext.tsx
Normal 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
|
||||
}
|
||||
@ -1,111 +1,20 @@
|
||||
:root {
|
||||
--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;
|
||||
@import "tailwindcss";
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
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;
|
||||
}
|
||||
@theme {
|
||||
--color-brand-50: #ecfdf5;
|
||||
--color-brand-500: #059669;
|
||||
--color-brand-600: #047857;
|
||||
--color-brand-700: #065f46;
|
||||
--font-sans: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--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;
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
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);
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
31
fe-user/src/lib/api.ts
Normal file
31
fe-user/src/lib/api.ts
Normal 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
6
fe-user/src/lib/cn.ts
Normal 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))
|
||||
}
|
||||
@ -1,10 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
18
fe-user/src/pages/InboxPage.tsx
Normal file
18
fe-user/src/pages/InboxPage.tsx
Normal 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ư — HĐ chờ xử lý</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 HĐ chờ role của bạn xử lý sẽ hiển thị ở đây (Phase 3).
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
fe-user/src/pages/LoginPage.tsx
Normal file
73
fe-user/src/pages/LoginPage.tsx
Normal 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 lý 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
18
fe-user/src/types/auth.ts
Normal 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
|
||||
}
|
||||
@ -19,7 +19,12 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path alias */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// User UI — port 8080, proxy /api → SolutionErp.Api (http://localhost:5443)
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
40
src/Backend/SolutionErp.Api/Controllers/AuthController.cs
Normal file
40
src/Backend/SolutionErp.Api/Controllers/AuthController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
// 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();
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
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();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
// ---------- Pipeline ----------
|
||||
app.UseMiddleware<GlobalExceptionMiddleware>();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "SolutionErp API v1"));
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
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();
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
{
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5235",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:5443;http://localhost:5444",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7241;http://localhost:5235",
|
||||
"applicationUrl": "http://localhost:5444",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
34
src/Backend/SolutionErp.Api/Services/CurrentUserService.cs
Normal file
34
src/Backend/SolutionErp.Api/Services/CurrentUserService.cs
Normal 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;
|
||||
}
|
||||
@ -8,9 +8,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -1,8 +1,18 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"ConnectionStrings": {
|
||||
"Default": "Server=(localdb)\\MSSQLLocalDB;Database=SolutionErp_Dev;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=true"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"ConnectionStrings": {
|
||||
"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",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
namespace SolutionErp.Application.Common.Interfaces;
|
||||
|
||||
public interface IApplicationDbContext
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
namespace SolutionErp.Application.Common.Interfaces;
|
||||
|
||||
public interface IDateTime
|
||||
{
|
||||
DateTime UtcNow { get; }
|
||||
DateTime Now { get; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
23
src/Backend/SolutionErp.Application/DependencyInjection.cs
Normal file
23
src/Backend/SolutionErp.Application/DependencyInjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.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>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
8
src/Backend/SolutionErp.Domain/Common/AuditableEntity.cs
Normal file
8
src/Backend/SolutionErp.Domain/Common/AuditableEntity.cs
Normal 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; }
|
||||
}
|
||||
10
src/Backend/SolutionErp.Domain/Common/BaseEntity.cs
Normal file
10
src/Backend/SolutionErp.Domain/Common/BaseEntity.cs
Normal 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; }
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
namespace SolutionErp.Domain.Contracts;
|
||||
|
||||
public enum ApprovalDecision
|
||||
{
|
||||
Pending = 0,
|
||||
Approve = 1,
|
||||
Reject = 2,
|
||||
AutoApprove = 3,
|
||||
}
|
||||
16
src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs
Normal file
16
src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs
Normal 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,
|
||||
}
|
||||
12
src/Backend/SolutionErp.Domain/Contracts/ContractType.cs
Normal file
12
src/Backend/SolutionErp.Domain/Contracts/ContractType.cs
Normal 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,
|
||||
}
|
||||
23
src/Backend/SolutionErp.Domain/Identity/AppRoles.cs
Normal file
23
src/Backend/SolutionErp.Domain/Identity/AppRoles.cs
Normal 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,
|
||||
];
|
||||
}
|
||||
9
src/Backend/SolutionErp.Domain/Identity/Role.cs
Normal file
9
src/Backend/SolutionErp.Domain/Identity/Role.cs
Normal 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; }
|
||||
}
|
||||
13
src/Backend/SolutionErp.Domain/Identity/User.cs
Normal file
13
src/Backend/SolutionErp.Domain/Identity/User.cs
Normal 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; }
|
||||
}
|
||||
@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
306
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421034520_Init.Designer.cs
generated
Normal file
306
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421034520_Init.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
Reference in New Issue
Block a user