diff --git a/backend/docker-compose.override.yml.example b/backend/docker-compose.override.yml.example index d624402..90cdaa1 100644 --- a/backend/docker-compose.override.yml.example +++ b/backend/docker-compose.override.yml.example @@ -12,5 +12,5 @@ services: # Override สำหรับ Database (Local Dev) mariadb: environment: - - MYSQL_ROOT_PASSWORD=Center#2025 - - MYSQL_PASSWORD=Center2025 \ No newline at end of file + - MYSQL_ROOT_PASSWORD=Center2025 + - MYSQL_PASSWORD=Center2025 diff --git a/backend/migrations/1733800000000-AlignSchemaWithDocumentation.sql b/backend/migrations/1733800000000-AlignSchemaWithDocumentation.sql deleted file mode 100644 index d3ff74c..0000000 --- a/backend/migrations/1733800000000-AlignSchemaWithDocumentation.sql +++ /dev/null @@ -1,105 +0,0 @@ --- Migration: Align Schema with Documentation --- Version: 1733800000000 --- Date: 2025-12-10 --- Description: Add missing fields and fix column lengths to match schema v1.5.1 --- ========================================================== --- Phase 1: Organizations Table Updates --- ========================================================== --- Add role_id column to organizations -ALTER TABLE organizations -ADD COLUMN role_id INT NULL COMMENT 'Reference to organization_roles table'; - --- Add foreign key constraint -ALTER TABLE organizations -ADD CONSTRAINT fk_organizations_role FOREIGN KEY (role_id) REFERENCES organization_roles(id) ON DELETE -SET NULL; - --- Modify organization_name length from 200 to 255 -ALTER TABLE organizations -MODIFY COLUMN organization_name VARCHAR(255) NOT NULL COMMENT 'Organization name'; - --- ========================================================== --- Phase 2: Users Table Updates (Security Fields) --- ========================================================== --- Add failed_attempts for login tracking -ALTER TABLE users -ADD COLUMN failed_attempts INT DEFAULT 0 COMMENT 'Number of failed login attempts'; - --- Add locked_until for account lockout mechanism -ALTER TABLE users -ADD COLUMN locked_until DATETIME NULL COMMENT 'Account locked until this timestamp'; - --- Add last_login_at for audit trail -ALTER TABLE users -ADD COLUMN last_login_at TIMESTAMP NULL COMMENT 'Last successful login timestamp'; - --- ========================================================== --- Phase 3: Roles Table Updates --- ========================================================== --- Modify role_name length from 50 to 100 -ALTER TABLE roles -MODIFY COLUMN role_name VARCHAR(100) NOT NULL COMMENT 'Role name'; - --- ========================================================== --- Verification Queries --- ========================================================== --- Verify organizations table structure -SELECT COLUMN_NAME, - DATA_TYPE, - CHARACTER_MAXIMUM_LENGTH, - IS_NULLABLE, - COLUMN_COMMENT -FROM INFORMATION_SCHEMA.COLUMNS -WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'organizations' -ORDER BY ORDINAL_POSITION; - --- Verify users table has new security fields -SELECT COLUMN_NAME, - DATA_TYPE, - COLUMN_DEFAULT, - IS_NULLABLE, - COLUMN_COMMENT -FROM INFORMATION_SCHEMA.COLUMNS -WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'users' - AND COLUMN_NAME IN ( - 'failed_attempts', - 'locked_until', - 'last_login_at' - ) -ORDER BY ORDINAL_POSITION; - --- Verify roles table role_name length -SELECT COLUMN_NAME, - DATA_TYPE, - CHARACTER_MAXIMUM_LENGTH -FROM INFORMATION_SCHEMA.COLUMNS -WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'roles' - AND COLUMN_NAME = 'role_name'; - --- ========================================================== --- Rollback Script (Use if needed) --- ========================================================== -/* - -- Rollback Phase 3: Roles - ALTER TABLE roles - MODIFY COLUMN role_name VARCHAR(50) NOT NULL; - - -- Rollback Phase 2: Users - ALTER TABLE users - DROP COLUMN last_login_at, - DROP COLUMN locked_until, - DROP COLUMN failed_attempts; - - -- Rollback Phase 1: Organizations - ALTER TABLE organizations - MODIFY COLUMN organization_name VARCHAR(200) NOT NULL; - - ALTER TABLE organizations - DROP FOREIGN KEY fk_organizations_role; - - ALTER TABLE organizations - DROP COLUMN role_id; - */ diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e5c5ffc..84cb2e7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -57,7 +57,7 @@ import { MigrationModule } from './modules/migration/migration.module'; // 1. Setup Config Module พร้อม Validation ConfigModule.forRoot({ isGlobal: true, - envFilePath: '.env', + envFilePath: ['.env', '.env.local'], load: [redisConfig], validationSchema: envValidationSchema, validationOptions: { diff --git a/backend/src/config/database.config.ts b/backend/src/config/database.config.ts index bf59afe..7e32903 100644 --- a/backend/src/config/database.config.ts +++ b/backend/src/config/database.config.ts @@ -4,8 +4,8 @@ export const databaseConfig: TypeOrmModuleOptions = { type: 'mysql', host: process.env.DB_HOST || 'localhost', port: Number(process.env.DB_PORT || '3306'), - username: process.env.DB_USERNAME || 'root', - password: process.env.DB_PASSWORD || 'Center#2025', + username: process.env.DB_USERNAME || 'admin', + password: process.env.DB_PASSWORD || 'Center2025', database: process.env.DB_DATABASE || 'lcbp3_dev', charset: 'utf8mb4', entities: [__dirname + '/../**/*.entity{.ts,.js}'], diff --git a/backend/src/database/seeds/user.seed.ts b/backend/src/database/seeds/user.seed.ts index f353af9..9e8b99e 100644 --- a/backend/src/database/seeds/user.seed.ts +++ b/backend/src/database/seeds/user.seed.ts @@ -97,7 +97,7 @@ export async function seedUsers(dataSource: DataSource) { ]; const salt = await bcrypt.genSalt(); - const password = await bcrypt.hash('password123', salt); // Default password + const password = await bcrypt.hash('Center2025', salt); // Default password (ADR-019 aligned) for (const u of usersData) { let user = await userRepo.findOneBy({ username: u.username }); diff --git a/frontend/app/(dashboard)/correspondences/page.tsx b/frontend/app/(dashboard)/correspondences/page.tsx index c53b031..11309e8 100644 --- a/frontend/app/(dashboard)/correspondences/page.tsx +++ b/frontend/app/(dashboard)/correspondences/page.tsx @@ -8,15 +8,16 @@ import { Can } from '@/components/common/can'; export const dynamic = 'force-dynamic'; interface CorrespondencesPageProps { - searchParams?: { + searchParams: Promise<{ type?: string; - }; + }>; } -export default function CorrespondencesPage({ +export default async function CorrespondencesPage({ searchParams, }: CorrespondencesPageProps) { - const isRfaView = searchParams?.type?.toUpperCase() === 'RFA'; + const params = await searchParams; + const isRfaView = params?.type?.toUpperCase() === 'RFA'; const heading = isRfaView ? 'RFAs (Request for Approval)' : 'Correspondences'; const description = isRfaView ? 'Unified list view for RFA documents' diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b9dcf62..bbdabd6 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -7,6 +7,7 @@ import QueryProvider from '@/providers/query-provider'; import SessionProvider from '@/providers/session-provider'; // ✅ Import เข้ามา import ThemeProvider from '@/providers/theme-provider'; import { Toaster } from '@/components/ui/sonner'; +import { headers } from 'next/headers'; const inter = Inter({ subsets: ['latin'] }); @@ -19,13 +20,15 @@ interface RootLayoutProps { children: React.ReactNode; } -export default function RootLayout({ children }: RootLayoutProps) { +export default async function RootLayout({ children }: RootLayoutProps) { + const nonce = (await headers()).get('x-nonce') || ''; + return ( - - + + {children} diff --git a/frontend/components/common/can.tsx b/frontend/components/common/can.tsx index e419cfd..a3078ab 100644 --- a/frontend/components/common/can.tsx +++ b/frontend/components/common/can.tsx @@ -2,7 +2,7 @@ 'use client'; import { useAuthStore } from '@/lib/stores/auth-store'; -import { ReactNode } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; interface CanProps { permission?: string; @@ -18,6 +18,15 @@ interface CanProps { export function Can({ permission, role, children, fallback = null }: CanProps) { const { hasPermission, hasRole } = useAuthStore(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return <>{fallback}; + } let allowed = true; diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index 99bb173..8415ae9 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -162,13 +162,13 @@ export const { } return { - id: backendData.user.user_id.toString(), - publicId: backendData.user.publicId, // ✅ Added (ADR-019 Waived for session) + id: backendData.user.publicId, // ✅ Use publicId for session identity (ADR-019) + publicId: backendData.user.publicId, name: `${backendData.user.firstName ?? ''} ${backendData.user.lastName ?? ''}`.trim(), email: backendData.user.email, username: backendData.user.username, - firstName: backendData.user.firstName, // ✅ Added - lastName: backendData.user.lastName, // ✅ Added + firstName: backendData.user.firstName, + lastName: backendData.user.lastName, role: backendData.user.role || 'User', organizationId: backendData.user.primaryOrganizationId, accessToken: backendData.access_token, diff --git a/frontend/lib/stores/auth-store.ts b/frontend/lib/stores/auth-store.ts index 3546b81..83e985d 100644 --- a/frontend/lib/stores/auth-store.ts +++ b/frontend/lib/stores/auth-store.ts @@ -3,8 +3,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; export interface User { - id: string; // Internal stringified INT (for stability) - publicId?: string; // ADR-019: Public UUIDv7 + id: string; // publicId (ADR-019) + publicId: string; username: string; email: string; firstName: string; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 1a80713..36e540f 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -78,18 +78,6 @@ const nextConfig = { key: 'X-Content-Type-Options', value: 'nosniff', }, - { - key: 'Content-Security-Policy', - value: [ - "default-src 'self'", - "script-src 'self' 'unsafe-eval'", // จำเป็นสำหรับ Workflow DSL Engine (new Function()) - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: https:", - "font-src 'self'", - "connect-src 'self' ws: wss:", - "frame-src 'self'", - ].join('; '), - }, ], }, ]; diff --git a/frontend/providers/session-provider.tsx b/frontend/providers/session-provider.tsx index 8306a01..808b650 100644 --- a/frontend/providers/session-provider.tsx +++ b/frontend/providers/session-provider.tsx @@ -4,9 +4,15 @@ import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'; import { AuthSync } from '@/components/auth/auth-sync'; -export default function SessionProvider({ children }: { children: React.ReactNode }) { +export default function SessionProvider({ + children, + nonce, +}: { + children: React.ReactNode; + nonce?: string; +}) { return ( - + {children} diff --git a/frontend/providers/theme-provider.tsx b/frontend/providers/theme-provider.tsx index f5cd7a7..5420df0 100644 --- a/frontend/providers/theme-provider.tsx +++ b/frontend/providers/theme-provider.tsx @@ -4,8 +4,10 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'; export default function ThemeProvider({ children, + nonce, }: { children: React.ReactNode; + nonce?: string; }) { return ( {children} diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 96ca4aa..3708b74 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -43,7 +43,47 @@ export default auth((req) => { } } - return NextResponse.next(); // แก้ไขจาก null + // 5. Generate CSP with Nonce (Security Rule Tier 1) + // ใช้ Nonce Strategy เพื่ออนุญาต Inline Script เฉพาะที่ระบุตัวตนได้ ป้องกัน XSS + const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); + + let connectSrcApi = 'http://localhost:3001'; + if (process.env.NEXT_PUBLIC_API_URL) { + try { + connectSrcApi = new URL(process.env.NEXT_PUBLIC_API_URL).origin; + } catch { + connectSrcApi = process.env.NEXT_PUBLIC_API_URL; + } + } + + const cspHeader = ` + default-src 'self'; + script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval'; + style-src 'self' 'unsafe-inline'; + img-src 'self' blob: data: https:; + font-src 'self' data:; + connect-src 'self' ws: wss: ${connectSrcApi}; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + upgrade-insecure-requests; + ` + .replace(/\s{2,}/g, ' ') + .trim(); + + const requestHeaders = new Headers(req.headers); + requestHeaders.set('x-nonce', nonce); + requestHeaders.set('Content-Security-Policy', cspHeader); + + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + response.headers.set('Content-Security-Policy', cspHeader); + + return response; }); // กำหนดว่า Middleware นี้จะทำงานกับ Route ไหนบ้าง diff --git a/frontend/types/next-auth.d.ts b/frontend/types/next-auth.d.ts index 203c369..17a2b36 100644 --- a/frontend/types/next-auth.d.ts +++ b/frontend/types/next-auth.d.ts @@ -4,11 +4,11 @@ import _NextAuth, { DefaultSession } from 'next-auth'; declare module 'next-auth' { interface Session { user: { - id: string; - publicId: string; // ✅ Added (ADR-019 Waived for session) - username: string; // ✅ Added - firstName: string; // ✅ Added - lastName: string; // ✅ Added + id: string; // publicId (ADR-019) + publicId: string; + username: string; + firstName: string; + lastName: string; role: string; organizationId?: number; } & DefaultSession['user']; @@ -18,11 +18,11 @@ declare module 'next-auth' { } interface User { - id: string; - publicId: string; // ✅ Added - username: string; // ✅ Added - firstName: string; // ✅ Added - lastName: string; // ✅ Added + id: string; // publicId (ADR-019) + publicId: string; + username: string; + firstName: string; + lastName: string; role: string; organizationId?: number; accessToken?: string; @@ -32,8 +32,8 @@ declare module 'next-auth' { declare module 'next-auth/jwt' { interface JWT { - id: string; - username: string; // ✅ Added + id: string; // publicId or username depending on auth flow + username: string; role: string; organizationId?: number; accessToken?: string; diff --git a/package.json b/package.json index da57ea7..d5e0bc2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "start:mcp": "node ./scripts/start-mcp.js", "dev:backend": "pnpm --filter backend start:dev", "dev:frontend": "pnpm --filter lcbp3-frontend dev", - "dev": "pnpm run --parallel /dev|start:dev/", + "dev": "pnpm run --parallel \"/dev|start:dev/\"", "prepare": "husky", "lint": "eslint .", "lint:fix": "eslint . --fix" diff --git a/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md b/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md index 3ff8508..90ae516 100644 --- a/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md +++ b/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md @@ -2,7 +2,7 @@ **Status:** Accepted **Date:** 2026-03-12 -**Version:** 1.8.1 +**Version:** 1.8.2 **Decision Makers:** Development Team, Database Architect **Related Documents:** @@ -369,26 +369,6 @@ ALTER TABLE notifications -- Using regular INDEX instead ``` -### Rollback SQL - -```sql --- Rollback: Remove UUID columns (Non-destructive reverse) -ALTER TABLE users DROP INDEX idx_users_uuid, DROP COLUMN uuid; -ALTER TABLE organizations DROP INDEX idx_organizations_uuid, DROP COLUMN uuid; -ALTER TABLE projects DROP INDEX idx_projects_uuid, DROP COLUMN uuid; -ALTER TABLE contracts DROP INDEX idx_contracts_uuid, DROP COLUMN uuid; -ALTER TABLE correspondences DROP INDEX idx_correspondences_uuid, DROP COLUMN uuid; -ALTER TABLE correspondence_revisions DROP INDEX idx_correspondence_revisions_uuid, DROP COLUMN uuid; -ALTER TABLE circulations DROP INDEX idx_circulations_uuid, DROP COLUMN uuid; -ALTER TABLE shop_drawings DROP INDEX idx_shop_drawings_uuid, DROP COLUMN uuid; -ALTER TABLE shop_drawing_revisions DROP INDEX idx_shop_drawing_revisions_uuid, DROP COLUMN uuid; -ALTER TABLE contract_drawings DROP INDEX idx_contract_drawings_uuid, DROP COLUMN uuid; -ALTER TABLE asbuilt_drawings DROP INDEX idx_asbuilt_drawings_uuid, DROP COLUMN uuid; -ALTER TABLE asbuilt_drawing_revisions DROP INDEX idx_asbuilt_drawing_revisions_uuid, DROP COLUMN uuid; -ALTER TABLE attachments DROP INDEX idx_attachments_uuid, DROP COLUMN uuid; -ALTER TABLE notifications DROP INDEX idx_notifications_uuid, DROP COLUMN uuid; -``` - --- ## Storage Impact Analysis @@ -530,17 +510,12 @@ type ProjectOption = { --- -## Waivers & Exceptions +## 🔄 Change Log -### 1. AuthStore / Frontend Session User Identity - -**Date:** 2026-04-01 -**Scope:** `frontend/lib/stores/auth-store.ts`, `frontend/lib/auth.ts` - -**Decision:** ให้คงฟิลด์ `id` (stringified `user_id` INT) ไว้ใน `User` interface ของ `AuthStore` และ `NextAuth Session` เพื่อความเสถียรของระบบ Login ที่ใช้งานได้ดีอยู่แล้ว โดยให้เพิ่ม `publicId` เป็นฟิลด์เสริมแทนการ Replacement (Waive strict ADR-019 compliance for Session Identity only). - -**Rationale:** ป้องกันความเสี่ยงในการเปลี่ยน Logic การจัดการ Session ที่อาจส่งผลกระทบต่อระบบ Authentication โดยรวม - ---- +| Version | Date | Changes | Updated By | +| ------- | ---------- | ------------------------------------------------------------------- | ----------- | +| 1.8.2 | 2026-04-01 | Removed Waiver: Session Identity to enforce strict `publicId` usage | Antigravity | +| 1.8.1 | 2026-03-21 | Added Naming Convention Summary & Transition Strategy | Claude | +| 1.8.0 | 2026-03-12 | Initial Decision Outcome & Technical Spec | Human Dev | _สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน `05-07-hybrid-uuid-implementation-plan.md`_