diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..22a78b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Verify template readiness + run: npm run verify diff --git a/README.md b/README.md index c6b4580..5313b1f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ WeBase Admin Template is an independent Next.js App Router admin scaffold for bu | `/system/notifications` | Notification center and delivery state management | | `/system/sessions` | Active session and device risk management | | `/system/components` | Local design-system component showcase | +| `/system/health` | Runtime mode, backend readiness, and production smoke status | | `/system/settings` | System profile, theme, session, and notification settings | | `/account/profile` | Current user profile, preferences, and security summary | @@ -37,24 +38,38 @@ The login form is prefilled with demo credentials: - Username: `admin` - Password: `admin123` -The current mock adapter accepts any non-empty username and password, then returns a local demo session. Use the displayed `admin / admin123` credentials for the intended demo flow. +The current mock adapter accepts the displayed `admin / admin123` credentials, then returns a local demo session. HTTP mode delegates credential validation to the backend. ## Development commands ```bash npm run dev +npm run verify npm run lint npm run smoke:template +npm run smoke:production npm run build npm run start ``` - `npm run dev` starts the local development server. +- `npm run verify` runs the full CI gate: standards lint, production smoke suite, TypeScript, and production build. - `npm run lint` runs ESLint across the project. - `npm run smoke:template` verifies template extension contracts such as route registry, RBAC, API adapter, form validation, docs, status pages, theme tokens, and dashboard chart loading. +- `npm run smoke:production` runs the production readiness smoke suite, including i18n, auth, API, permissions, CRUD, audit, accessibility, template, and `/system/health` checks. - `npm run build` creates a production Next.js build and verifies buildable routes. - `npm run start` serves the production build after `npm run build`. +## Release verification + +Before connecting a real backend or deploying a release, run: + +```bash +npm run verify +``` + +Then open `/system/health` to confirm API mode, backend/mock status, frontend version, build target, and operational indicators. + ## Template extension docs - [Add an admin module](docs/add-admin-module.md) diff --git a/docs/add-admin-module.md b/docs/add-admin-module.md index cc1c337..2e13ef1 100644 --- a/docs/add-admin-module.md +++ b/docs/add-admin-module.md @@ -92,6 +92,8 @@ Follow existing table/form patterns: - `DataTable`, `TableToolbar`, `Pagination`, and `EmptyState` for list pages. - `FormField`, `FormErrors`, `hasErrors`, and validators from `src/lib/forms/validators.ts`. - `PermissionGuard` around create/edit/delete actions. +- `bulkActions` for batch operations when row selection is enabled. +- CSV export actions that call a service-level helper, for example `downloadCsv("examples.csv", exportExamplesCsv(records))`. ## 7. Add status and loading states @@ -114,15 +116,21 @@ Create `scripts/example-experience-smoke.mjs` for source-level invariants such a Then add or update `scripts/template-hardening-smoke.mjs` only for cross-template capabilities. -## 9. Verify +## 9. Accessibility and responsive QA + +Before handing off a new module, check the module at desktop and mobile widths: + +- icon-only actions include a stable `aria-label` +- dialogs, sheets, selects, and table controls are reachable by keyboard +- dense tables use stable columns or horizontal scrolling instead of overlapping text +- loading, empty, error, and disabled states remain announced through visible text or accessible labels + +## 10. Verify Run: ```bash -npm run smoke:template node scripts/example-experience-smoke.mjs -npx tsc --noEmit --pretty false -npm run lint -- --no-warn-ignored -npm run build +npm run verify ``` diff --git a/docs/api-adapter.md b/docs/api-adapter.md index dc1620b..a198496 100644 --- a/docs/api-adapter.md +++ b/docs/api-adapter.md @@ -10,13 +10,17 @@ Set the mode with environment variables: NEXT_PUBLIC_API_MODE=mock NEXT_PUBLIC_API_MODE=http NEXT_PUBLIC_API_BASE_URL=https://api.example.com +NEXT_PUBLIC_API_TIMEOUT_MS=15000 ``` - `mock` is the default and delegates to `src/lib/api/mock-adapter.ts`. - `http` delegates to `src/lib/api/http-adapter.ts` and uses `NEXT_PUBLIC_API_BASE_URL`. +- `NEXT_PUBLIC_API_TIMEOUT_MS` controls the default HTTP timeout. The default is 15000 ms; use `0` only when a caller supplies its own `AbortSignal`. No service or page should change when switching modes. -HTTP mode sends same-origin credentials, disables GET caching with `cache: "no-store"`, handles `204 No Content`, and normalizes both transport and business-code failures. +HTTP mode sends same-origin credentials, disables GET caching with `cache: "no-store"`, handles `204 No Content`, preserves request IDs, supports timeout/caller abort through `AbortController`, and normalizes both transport and business-code failures. + +Feature modules should call service functions in `src/lib/services/*`, not adapters directly. Do not import adapters directly from pages, components, or feature services; the adapter layer is an implementation detail behind `apiClient`. ## Response contract @@ -27,10 +31,11 @@ export interface ApiResponse { code: number; message: string; data: T; + requestId?: string; } ``` -The HTTP backend should either return this exact shape or be normalized in `http-adapter.ts`. +The HTTP backend should either return this exact shape or be normalized in `http-adapter.ts`. When a backend exposes a request ID, send it in `x-request-id`, `x-correlation-id`, `x-trace-id`, or the response body `requestId` field. The adapter preserves that `requestId` on both successful responses and `ApiError` instances. ## Errors @@ -40,6 +45,7 @@ All thrown errors are normalized through `src/lib/api/errors.ts`: throw new ApiError("Session expired", { code: "unauthorized", status: 401, + requestId, details, }); ``` @@ -50,6 +56,7 @@ UI code can safely check: - `error.code` - `error.status` - `error.message` +- `error.requestId` Keep user-facing messages concise and move detailed payloads into `details`. @@ -66,9 +73,10 @@ Recommended UI routes for common adapter failures: 1. Add or reuse a type in `src/lib/api/types.ts`. 2. Add a mock handler in `src/lib/api/mock-adapter.ts`. 3. Add a service function in `src/lib/services/-service.ts`. -4. Call the service from pages/components. +4. Call the service from pages/components. Pages and components should not call `apiClient` directly. 5. If using HTTP mode, ensure the backend URL and method match the mock URL. 6. Add source-level smoke coverage for important template behavior. +7. Run `npm run verify` before opening a pull request or connecting the endpoint to a real backend. Example service: @@ -85,3 +93,20 @@ export async function getUsers() { ## Session and auth handling The adapter layer is the right place to normalize `401`/`403` responses. Route-level access is handled by `RoutePermissionGuard`; API-level authorization should still be enforced by the real backend. + +Backend auth endpoints expected in HTTP mode: + +- `POST /auth/login`: accepts `LoginPayload` and returns `LoginResult` with `token`, `user`, and `expiresAt`. +- `GET /auth/current-user`: returns the current `CurrentUser` including the backend-authoritative permission list. +- `GET /auth/permissions`: returns `{ permissions }` for permission refresh flows. +- `POST /auth/logout`: invalidates the current backend session. + +The prefilled `admin / admin123` credentials are mock-only convenience for local demos. Real HTTP mode must validate credentials on the backend and return the typed contract above. + +The client stores safe session state in session storage only: token, user snapshot, expiry time, and session status. Logout clears that safe session state. A `401` must be normalized to `session_expired` and routed to `/session-expired`; a `403` must be normalized to `forbidden` and routed to `/forbidden` or an inline permission state. + +## Audit event contract + +Mutation endpoints should create audit events that can be correlated with API failures and support review workflows. Each audit event should include `actor`, `action`, `module`, `target`, `result`, `ipAddress`, `userAgent`, `requestId`, and `createdAt`. When a mutation changes a record, include `beforeSnapshot` and/or `afterSnapshot` when the backend can safely expose them. + +Use the same `requestId` returned by the API adapter so operators can move from a UI error or network trace to the matching audit event. diff --git a/docs/permissions.md b/docs/permissions.md index fe50470..7edb556 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -53,6 +53,16 @@ Wrap privileged actions with `PermissionGuard`: For destructive actions, also keep existing confirmation and toast/error behavior. The guard hides UI affordances; the backend must still enforce authorization. +## Data-scope permissions + +Roles now carry three permission dimensions: + +- `permissions`: menu-level access used by navigation and search. +- `actionPermissions`: backend-aligned action keys such as `system:users:write`. +- `dataScope`: one of `self`, `department`, `tenant`, or `all`. + +Data scopes describe which records a role may see or mutate after it reaches a page. The frontend RBAC hides UI affordances and keeps navigation tidy, but backend authorization remains mandatory for every API request. Treat frontend role state as a user-experience hint; the server must enforce action permissions and data scope against the authenticated session. + ## Super admin and legacy sessions `allPermissions` represents the demo super-admin capability set. In mock mode, `resolvePermissions()` gives legacy stored users full permissions when no permissions array is present, preventing old local sessions from being accidentally locked out after an upgrade. @@ -68,7 +78,5 @@ In HTTP mode, missing permissions resolve to an empty permission list. Productio 5. Restore the permission and run: ```bash -npm run smoke:template -npx tsc --noEmit --pretty false -npm run lint -- --no-warn-ignored +npm run verify ``` diff --git a/package.json b/package.json index 2e3fee9..9cb456e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,15 @@ "start": "next start", "lint": "eslint .", "lint:standards": "node scripts/lint-standards-smoke.mjs", + "verify": "npm run lint:standards && npm run smoke:production && npx tsc --noEmit --pretty false --project tsconfig.json && npm run build", + "smoke:a11y": "node scripts/accessibility-smoke.mjs", + "smoke:api": "node scripts/api-contract-smoke.mjs", + "smoke:audit": "node scripts/audit-contract-smoke.mjs", + "smoke:auth": "node scripts/auth-contract-smoke.mjs", + "smoke:crud": "node scripts/crud-pattern-smoke.mjs", + "smoke:i18n": "node scripts/i18n-copy-smoke.mjs", + "smoke:permissions": "node scripts/data-scope-permissions-smoke.mjs", + "smoke:production": "node scripts/production-readiness-smoke.mjs", "smoke:template": "node scripts/template-hardening-smoke.mjs" }, "dependencies": { diff --git a/scripts/accessibility-smoke.mjs b/scripts/accessibility-smoke.mjs new file mode 100644 index 0000000..f6facc3 --- /dev/null +++ b/scripts/accessibility-smoke.mjs @@ -0,0 +1,104 @@ +import { existsSync, readFileSync } from "node:fs"; + +function read(path) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +const files = { + packageJson: "package.json", + dialog: "src/components/ui/dialog.tsx", + sheet: "src/components/ui/sheet.tsx", + select: "src/components/ui/select.tsx", + dataTable: "src/components/data-table/data-table.tsx", + globalSearch: "src/components/layout/global-search-dialog.tsx", + appHeader: "src/components/layout/app-header.tsx", + componentsPage: "src/app/system/components/page.tsx", + addModuleDoc: "docs/add-admin-module.md", +}; + +const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)])); + +const checks = [ + { + name: "package exposes accessibility smoke command", + pass: source.packageJson.includes('"smoke:a11y"') && source.packageJson.includes("accessibility-smoke.mjs"), + }, + { + name: "dialog traps focus and exposes accessible names", + pass: + source.dialog.includes("role=\"dialog\"") && + source.dialog.includes("aria-modal=\"true\"") && + source.dialog.includes("aria-labelledby") && + source.dialog.includes("aria-label") && + source.dialog.includes("initialFocusRef") && + source.dialog.includes("previousActiveElementRef") && + source.dialog.includes("event.key === \"Escape\"") && + source.dialog.includes("event.key !== \"Tab\""), + }, + { + name: "sheet traps focus and exposes accessible names", + pass: + source.sheet.includes("role=\"dialog\"") && + source.sheet.includes("aria-modal=\"true\"") && + source.sheet.includes("aria-labelledby") && + source.sheet.includes("aria-label") && + source.sheet.includes("previousActiveElementRef") && + source.sheet.includes("event.key === \"Escape\"") && + source.sheet.includes("event.key !== \"Tab\""), + }, + { + name: "select supports listbox semantics and keyboard navigation", + pass: + source.select.includes("role=\"combobox\"") && + source.select.includes("aria-expanded") && + source.select.includes("aria-controls") && + source.select.includes("aria-activedescendant") && + source.select.includes("role=\"listbox\"") && + source.select.includes("role=\"option\"") && + source.select.includes("ArrowDown") && + source.select.includes("Home") && + source.select.includes("End") && + source.select.includes("Escape"), + }, + { + name: "data table has header scopes, selectable labels, loading and empty states", + pass: + source.dataTable.includes("scope=\"col\"") && + source.dataTable.includes("aria-label=\"Select all rows\"") && + source.dataTable.includes("aria-checked") && + source.dataTable.includes("aria-hidden=\"true\"") && + source.dataTable.includes("emptyContent"), + }, + { + name: "global search has initial focus and labelled search input", + pass: + source.globalSearch.includes("initialFocusRef") && + source.globalSearch.includes("searchInputRef") && + source.globalSearch.includes("aria-label") && + source.globalSearch.includes("Esc"), + }, + { + name: "icon buttons in header and showcase expose aria-label text", + pass: + source.appHeader.includes("aria-label") && + source.componentsPage.includes("aria-label") && + !/size="icon"(?:(?!aria-label).){0,120}>/s.test(source.appHeader), + }, + { + name: "module docs mention accessibility requirements", + pass: + source.addModuleDoc.includes("Accessibility") && + source.addModuleDoc.includes("aria-label") && + source.addModuleDoc.includes("keyboard"), + }, +]; + +const failures = checks.filter((check) => !check.pass); + +if (failures.length > 0) { + console.error("Accessibility smoke failed:"); + for (const failure of failures) console.error(`- ${failure.name}`); + process.exitCode = 1; +} else { + console.log("PASS accessibility smoke checks"); +} diff --git a/scripts/api-contract-smoke.mjs b/scripts/api-contract-smoke.mjs new file mode 100644 index 0000000..a3e4c47 --- /dev/null +++ b/scripts/api-contract-smoke.mjs @@ -0,0 +1,107 @@ +import { existsSync, readFileSync } from "node:fs"; + +function read(path) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +const files = { + packageJson: "package.json", + apiClient: "src/lib/api/client.ts", + httpAdapter: "src/lib/api/http-adapter.ts", + apiErrors: "src/lib/api/errors.ts", + apiTypes: "src/lib/api/types.ts", + apiDoc: "docs/api-adapter.md", +}; + +const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)])); + +const checks = [ + { + name: "package exposes the API contract smoke command", + pass: source.packageJson.includes('"smoke:api"') && source.packageJson.includes("api-contract-smoke.mjs"), + }, + { + name: "ApiError is normalized and carries request metadata", + pass: + source.apiErrors.includes("export class ApiError extends Error") && + source.apiErrors.includes("normalizeApiError") && + source.apiErrors.includes("requestId?: string") && + source.apiErrors.includes("this.requestId"), + }, + { + name: "ApiResponse can preserve backend request IDs", + pass: source.apiTypes.includes("requestId?: string"), + }, + { + name: "apiClient keeps the mock/http adapter boundary and normalizes thrown errors", + pass: + source.apiClient.includes("NEXT_PUBLIC_API_MODE") && + source.apiClient.includes("httpAdapter") && + source.apiClient.includes("mockAdapter") && + source.apiClient.includes("normalizeApiError(error)"), + }, + { + name: "HTTP mode declares base URL and timeout env vars", + pass: + source.httpAdapter.includes("NEXT_PUBLIC_API_BASE_URL") && + source.httpAdapter.includes("NEXT_PUBLIC_API_TIMEOUT_MS"), + }, + { + name: "HTTP adapter preserves request IDs from headers or response body", + pass: + source.httpAdapter.includes("x-request-id") && + source.httpAdapter.includes("requestId") && + source.httpAdapter.includes("getResponseRequestId"), + }, + { + name: "HTTP adapter handles non-JSON and 204 responses explicitly", + pass: + source.httpAdapter.includes("invalid_response") && + source.httpAdapter.includes("content-type") && + source.httpAdapter.includes("response.status === 204"), + }, + { + name: "HTTP adapter handles business-code errors distinctly", + pass: source.httpAdapter.includes("business_error") && source.httpAdapter.includes("result.code !== 0"), + }, + { + name: "HTTP adapter serializes query params through URLSearchParams", + pass: source.httpAdapter.includes("URLSearchParams") && source.httpAdapter.includes("toQueryString"), + }, + { + name: "HTTP adapter supports platform AbortController timeout and caller abort", + pass: + source.httpAdapter.includes("AbortController") && + source.httpAdapter.includes("setTimeout") && + source.httpAdapter.includes("clearTimeout") && + source.httpAdapter.includes("signal") && + source.apiTypes.includes("AbortSignal"), + }, + { + name: "API docs direct feature modules through service functions, not adapters", + pass: + source.apiDoc.includes("service functions") && + source.apiDoc.includes("Do not import adapters directly") && + source.apiDoc.includes("NEXT_PUBLIC_API_TIMEOUT_MS") && + source.apiDoc.includes("requestId"), + }, +]; + +const missingFiles = Object.entries(files).filter(([, path]) => !existsSync(path)); +const failures = checks.filter((check) => !check.pass); + +if (missingFiles.length > 0 || failures.length > 0) { + console.error("API contract smoke failed:"); + + for (const [, path] of missingFiles) { + console.error(`- Missing file: ${path}`); + } + + for (const failure of failures) { + console.error(`- ${failure.name}`); + } + + process.exitCode = 1; +} else { + console.log("PASS API contract smoke checks"); +} diff --git a/scripts/audit-contract-smoke.mjs b/scripts/audit-contract-smoke.mjs new file mode 100644 index 0000000..26459cf --- /dev/null +++ b/scripts/audit-contract-smoke.mjs @@ -0,0 +1,96 @@ +import { existsSync, readFileSync } from "node:fs"; + +function read(path) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +const files = { + packageJson: "package.json", + types: "src/lib/api/types.ts", + auditService: "src/lib/services/audit-service.ts", + mockAdapter: "src/lib/api/mock-adapter.ts", + mockData: "src/lib/api/mock-data.ts", + auditPage: "src/app/system/audit-logs/page.tsx", + apiDoc: "docs/api-adapter.md", +}; + +const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)])); + +const requiredFields = [ + "actor", + "action", + "module", + "target", + "result", + "ipAddress", + "userAgent", + "requestId", + "createdAt", + "beforeSnapshot", + "afterSnapshot", +]; + +const checks = [ + { + name: "package exposes audit contract smoke command", + pass: source.packageJson.includes('"smoke:audit"') && source.packageJson.includes("audit-contract-smoke.mjs"), + }, + { + name: "audit record type includes operational review fields", + pass: + source.types.includes("export type AuditLogResult") && + requiredFields.every((field) => source.types.includes(field)), + }, + { + name: "audit service supports result, actor, module, and date filters", + pass: + source.auditService.includes("result?:") && + source.auditService.includes("actor?:") && + source.auditService.includes("module?:") && + source.auditService.includes("createdFrom?:") && + source.auditService.includes("createdTo?:"), + }, + { + name: "mock mutations append representative audit events", + pass: + source.mockAdapter.includes("appendAuditLog") && + source.mockAdapter.includes("beforeSnapshot") && + source.mockAdapter.includes("afterSnapshot") && + source.mockAdapter.includes("requestId") && + ["create", "update", "delete"].every((action) => source.mockAdapter.includes(action)), + }, + { + name: "audit page exposes result, module, actor, and date filter slots", + pass: + source.auditPage.includes("result") && + source.auditPage.includes("moduleFilter") && + source.auditPage.includes("actorFilter") && + source.auditPage.includes("createdFrom") && + source.auditPage.includes("createdTo"), + }, + { + name: "audit page displays request ID, user agent, and result", + pass: + source.auditPage.includes("requestId") && + source.auditPage.includes("userAgent") && + source.auditPage.includes("formatResult"), + }, + { + name: "API docs describe audit event contract and request ID linkage", + pass: + source.apiDoc.includes("Audit event contract") && + source.apiDoc.includes("requestId") && + source.apiDoc.includes("beforeSnapshot") && + source.apiDoc.includes("afterSnapshot"), + }, +]; + +const failures = checks.filter((check) => !check.pass); + +if (failures.length > 0) { + console.error("Audit contract smoke failed:"); + for (const failure of failures) console.error(`- ${failure.name}`); + process.exitCode = 1; +} else { + console.log("PASS audit contract smoke checks"); +} diff --git a/scripts/auth-contract-smoke.mjs b/scripts/auth-contract-smoke.mjs new file mode 100644 index 0000000..ab0d402 --- /dev/null +++ b/scripts/auth-contract-smoke.mjs @@ -0,0 +1,110 @@ +import { existsSync, readFileSync } from "node:fs"; + +function read(path) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +const files = { + packageJson: "package.json", + types: "src/lib/api/types.ts", + authService: "src/lib/services/auth-service.ts", + httpAdapter: "src/lib/api/http-adapter.ts", + authStore: "src/lib/stores/auth-store.ts", + authGuard: "src/components/auth/auth-guard.tsx", + loginPage: "src/app/login/page.tsx", + sessionExpiredPage: "src/app/session-expired/page.tsx", + apiDoc: "docs/api-adapter.md", +}; + +const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)])); + +const checks = [ + { + name: "package exposes the auth smoke command", + pass: source.packageJson.includes('"smoke:auth"') && source.packageJson.includes("auth-contract-smoke.mjs"), + }, + { + name: "auth API types expose login, current user, permission list, and session status contracts", + pass: + source.types.includes("export interface LoginResult") && + source.types.includes("export interface CurrentUser") && + source.types.includes("permissions: PermissionKey[]") && + source.types.includes("export interface PermissionListResult") && + source.types.includes("export type AuthSessionStatus") && + source.types.includes('"session_expired"') && + source.types.includes("expiresAt"), + }, + { + name: "auth service keeps demo credentials mock-only and exposes current user, permission list, and logout endpoints", + pass: + source.authService.includes("isMockAuthMode") && + source.authService.includes("mock-only") && + source.authService.includes("getCurrentUser") && + source.authService.includes("getPermissionList") && + source.authService.includes("/auth/permissions") && + source.authService.includes("logout") && + source.authService.includes("/auth/logout"), + }, + { + name: "HTTP adapter normalizes auth failures to session_expired and forbidden", + pass: + source.httpAdapter.includes("status === 401") && + source.httpAdapter.includes('"session_expired"') && + source.httpAdapter.includes("status === 403") && + source.httpAdapter.includes('"forbidden"'), + }, + { + name: "auth store persists only safe state and clears data on logout or session expiry", + pass: + source.authStore.includes("sessionStatus") && + source.authStore.includes("expireSession") && + source.authStore.includes("clearSession") && + source.authStore.includes("partialize") && + source.authStore.includes("token: null") && + source.authStore.includes('"session_expired"'), + }, + { + name: "auth guard routes anonymous and expired states to the right status pages", + pass: + source.authGuard.includes("sessionStatus") && + source.authGuard.includes("/login") && + source.authGuard.includes("/session-expired") && + source.authGuard.includes("session_expired"), + }, + { + name: "login page uses the formal login result and shows demo credentials only in mock mode", + pass: + source.loginPage.includes("setSession(session)") && + source.loginPage.includes("authService.isMockAuthMode") && + source.loginPage.includes("demoCredentialsVisible"), + }, + { + name: "session expired page clears stale session data before returning to login", + pass: + source.sessionExpiredPage.includes("useAuthStore") && + source.sessionExpiredPage.includes("clearSession") && + source.sessionExpiredPage.includes("/login"), + }, + { + name: "API docs describe backend auth contract, mock-only convenience, safe persistence, and 401/403 routing", + pass: + source.apiDoc.includes("mock-only") && + source.apiDoc.includes("/auth/login") && + source.apiDoc.includes("/auth/current-user") && + source.apiDoc.includes("/auth/permissions") && + source.apiDoc.includes("/auth/logout") && + source.apiDoc.includes("session_expired") && + source.apiDoc.includes("forbidden") && + source.apiDoc.includes("safe session state"), + }, +]; + +const failures = checks.filter((check) => !check.pass); + +if (failures.length > 0) { + console.error("Auth contract smoke failed:"); + for (const failure of failures) console.error(`- ${failure.name}`); + process.exitCode = 1; +} else { + console.log("PASS auth contract smoke checks"); +} diff --git a/scripts/crud-pattern-smoke.mjs b/scripts/crud-pattern-smoke.mjs new file mode 100644 index 0000000..cf69175 --- /dev/null +++ b/scripts/crud-pattern-smoke.mjs @@ -0,0 +1,118 @@ +import { existsSync, readFileSync } from "node:fs"; + +function read(path) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +const files = { + packageJson: "package.json", + dataTable: "src/components/data-table/data-table.tsx", + tableToolbar: "src/components/data-table/table-toolbar.tsx", + pagination: "src/components/data-table/pagination.tsx", + validators: "src/lib/forms/validators.ts", + csvExport: "src/lib/services/csv-export.ts", + userService: "src/lib/services/user-service.ts", + roleService: "src/lib/services/role-service.ts", + menuService: "src/lib/services/menu-service.ts", + usersPage: "src/app/system/users/page.tsx", + rolesPage: "src/app/system/roles/page.tsx", + menusPage: "src/app/system/menus/page.tsx", + userForm: "src/components/system/user-form-dialog.tsx", + roleForm: "src/components/system/role-form-dialog.tsx", + menuForm: "src/components/system/menu-form-dialog.tsx", + addModuleDoc: "docs/add-admin-module.md", +}; + +const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)])); + +const managementPages = [ + ["users", source.usersPage, "exportUsersCsv"], + ["roles", source.rolesPage, "exportRolesCsv"], + ["menus", source.menusPage, "exportMenusCsv"], +]; + +const pageChecks = managementPages.flatMap(([name, text, exportFn]) => [ + { + name: `${name} page has CRUD loading/error/empty/table primitives`, + pass: + text.includes("loading") && + text.includes("setMessage") && + text.includes("emptyText=") && + text.includes("DataTable") && + text.includes("TableToolbar") && + text.includes("PermissionGuard") && + text.includes("ConfirmDialog") && + text.includes("Refresh") && + text.includes("Add "), + }, + { + name: `${name} page has selection, bulk actions, and CSV export action`, + pass: + text.includes("selectedRowKeys") && + text.includes("bulkActions") && + text.includes("updateSelected") && + text.includes(exportFn) && + text.includes("Download") && + text.includes(".csv"), + }, +]); + +const checks = [ + { + name: "package exposes CRUD pattern smoke command", + pass: source.packageJson.includes('"smoke:crud"') && source.packageJson.includes("crud-pattern-smoke.mjs"), + }, + { + name: "shared table primitives cover selection, sort, loading, empty, toolbar action, and pagination", + pass: + source.dataTable.includes("selectedRowKeys") && + source.dataTable.includes("sortDescriptor") && + source.dataTable.includes("isLoading") && + source.dataTable.includes("emptyContent") && + source.tableToolbar.includes("action?: React.ReactNode") && + source.pagination.includes("pageSize"), + }, + { + name: "CSV export service exists and escapes cells", + pass: + existsSync(files.csvExport) && + source.csvExport.includes("export function toCsv") && + source.csvExport.includes("downloadCsv") && + source.csvExport.includes("replaceAll") && + source.csvExport.includes("Blob"), + }, + { + name: "core services expose CSV export hooks", + pass: + source.userService.includes("exportUsersCsv") && + source.roleService.includes("exportRolesCsv") && + source.menuService.includes("exportMenusCsv"), + }, + { + name: "shared validators remain the form validation boundary", + pass: + source.validators.includes("required") && + [source.userForm, source.roleForm, source.menuForm, source.addModuleDoc].every((text) => + text.includes("@/lib/forms/validators") || text.includes("validators.ts"), + ), + }, + ...pageChecks, + { + name: "module docs explain CRUD table and export expectations", + pass: + source.addModuleDoc.includes("CSV export") && + source.addModuleDoc.includes("DataTable") && + source.addModuleDoc.includes("TableToolbar") && + source.addModuleDoc.includes("bulkActions"), + }, +]; + +const failures = checks.filter((check) => !check.pass); + +if (failures.length > 0) { + console.error("CRUD pattern smoke failed:"); + for (const failure of failures) console.error(`- ${failure.name}`); + process.exitCode = 1; +} else { + console.log("PASS CRUD pattern smoke checks"); +} diff --git a/scripts/data-scope-permissions-smoke.mjs b/scripts/data-scope-permissions-smoke.mjs new file mode 100644 index 0000000..b2c8c35 --- /dev/null +++ b/scripts/data-scope-permissions-smoke.mjs @@ -0,0 +1,89 @@ +import { existsSync, readFileSync } from "node:fs"; + +function read(path) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +const files = { + packageJson: "package.json", + types: "src/lib/api/types.ts", + permissions: "src/lib/auth/permissions.ts", + roleService: "src/lib/services/role-service.ts", + roleForm: "src/components/system/role-form-dialog.tsx", + rolesPage: "src/app/system/roles/page.tsx", + permissionsDoc: "docs/permissions.md", + mockData: "src/lib/api/mock-data.ts", +}; + +const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)])); + +const checks = [ + { + name: "package exposes data-scope permission smoke command", + pass: + source.packageJson.includes('"smoke:permissions"') && + source.packageJson.includes("data-scope-permissions-smoke.mjs"), + }, + { + name: "role API type can represent menu permissions, action permissions, and data scope", + pass: + source.types.includes("export type RoleDataScope") && + ["self", "department", "tenant", "all"].every((scope) => source.types.includes(`"${scope}"`)) && + source.types.includes("permissions: string[]") && + source.types.includes("actionPermissions: PermissionKey[]") && + source.types.includes("dataScope: RoleDataScope"), + }, + { + name: "role service payloads preserve data-scope fields", + pass: + source.roleService.includes("CreateRolePayload") && + source.roleService.includes("UpdateRolePayload") && + source.roleService.includes("RoleDataScope"), + }, + { + name: "role form surfaces data scope through a select control", + pass: + source.roleForm.includes("dataScope") && + source.roleForm.includes("RoleDataScope") && + source.roleForm.includes("Data scope") && + source.roleForm.includes('"department"') && + source.roleForm.includes('"tenant"') && + source.roleForm.includes('"all"'), + }, + { + name: "roles page displays data scope in the management table", + pass: + source.rolesPage.includes("formatDataScope") && + source.rolesPage.includes("dataScope") && + source.rolesPage.includes("Data scope"), + }, + { + name: "mock roles include deterministic data-scope and action permission fields", + pass: + source.mockData.includes("dataScope") && + source.mockData.includes("actionPermissions") && + source.mockData.includes('"department"') && + source.mockData.includes('"tenant"'), + }, + { + name: "permissions docs separate frontend RBAC from backend authorization and data scopes", + pass: + source.permissionsDoc.includes("Data-scope permissions") && + source.permissionsDoc.includes("frontend RBAC hides UI") && + source.permissionsDoc.includes("backend authorization remains mandatory") && + source.permissionsDoc.includes("self") && + source.permissionsDoc.includes("department") && + source.permissionsDoc.includes("tenant") && + source.permissionsDoc.includes("all"), + }, +]; + +const failures = checks.filter((check) => !check.pass); + +if (failures.length > 0) { + console.error("Data-scope permissions smoke failed:"); + for (const failure of failures) console.error(`- ${failure.name}`); + process.exitCode = 1; +} else { + console.log("PASS data-scope permissions smoke checks"); +} diff --git a/scripts/i18n-copy-smoke.mjs b/scripts/i18n-copy-smoke.mjs new file mode 100644 index 0000000..bc6d18a --- /dev/null +++ b/scripts/i18n-copy-smoke.mjs @@ -0,0 +1,298 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import vm from "node:vm"; + +const dictionaryPath = "src/lib/i18n/dictionaries.ts"; +const routeRegistryPath = "src/lib/navigation/route-registry.ts"; +const expectedLocales = ["zh-CN", "en-US"]; + +const mojibakeFragments = [ + "\uFFFD", + "Ã", + "Â", + "â€", + "浠", + "鐩", + "鐢", + "鑿", + "閫", + "鎼", + "銆", + "鍒", + "淇", + "鏈", + "璇", + "寮", + "娣", + "钃", + "缈", + "绱", + "鑸", + "闆", + "妯", + "澘", + "韬", + "浼", + "娑", + "瀹", +]; + +const failures = []; + +function readRequired(path) { + if (!existsSync(path)) { + failures.push(`${path} does not exist`); + return ""; + } + + return readFileSync(path, "utf8"); +} + +function findMatching(source, openIndex, openChar, closeChar) { + let depth = 0; + let quote = ""; + let escaped = false; + let lineComment = false; + let blockComment = false; + + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index]; + const next = source[index + 1]; + + if (lineComment) { + if (char === "\n") lineComment = false; + continue; + } + + if (blockComment) { + if (char === "*" && next === "/") { + blockComment = false; + index += 1; + } + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = ""; + } + continue; + } + + if (char === "/" && next === "/") { + lineComment = true; + index += 1; + continue; + } + + if (char === "/" && next === "*") { + blockComment = true; + index += 1; + continue; + } + + if (char === "\"" || char === "'" || char === "`") { + quote = char; + continue; + } + + if (char === openChar) { + depth += 1; + continue; + } + + if (char === closeChar) { + depth -= 1; + if (depth === 0) return index; + } + } + + throw new Error(`Unterminated ${openChar}${closeChar} block`); +} + +function extractInitializer(source, exportName, openChar, closeChar) { + const marker = `export const ${exportName} =`; + const markerIndex = source.indexOf(marker); + + if (markerIndex === -1) { + throw new Error(`Missing export const ${exportName}`); + } + + const openIndex = source.indexOf(openChar, markerIndex + marker.length); + if (openIndex === -1) { + throw new Error(`Missing ${openChar} initializer for ${exportName}`); + } + + const closeIndex = findMatching(source, openIndex, openChar, closeChar); + return source.slice(openIndex, closeIndex + 1); +} + +function evaluateLiteral(source, label) { + try { + return vm.runInNewContext(`(${source})`, Object.create(null), { filename: label }); + } catch (error) { + failures.push(`${label} could not be parsed: ${error.message}`); + return null; + } +} + +function checkString(value, location) { + if (value.trim().length === 0) { + failures.push(`${location} is empty`); + return; + } + + if (/[^\x00-\x7F]\?(?:$|[\s,.;:!?)]|\.\.)/u.test(value)) { + failures.push(`${location} contains a dangling ASCII question mark: ${JSON.stringify(value)}`); + } + + for (const fragment of mojibakeFragments) { + if (value.includes(fragment)) { + failures.push(`${location} contains mojibake fragment ${JSON.stringify(fragment)}: ${JSON.stringify(value)}`); + return; + } + } +} + +function compareLocaleKeys(dictionaries) { + const keySets = new Map(); + + for (const locale of expectedLocales) { + const dictionary = dictionaries?.[locale]; + + if (!dictionary || typeof dictionary !== "object") { + failures.push(`dictionaries.${locale} is missing`); + continue; + } + + const keys = new Set(Object.keys(dictionary)); + keySets.set(locale, keys); + + for (const [key, value] of Object.entries(dictionary)) { + if (typeof value !== "string") { + failures.push(`dictionaries.${locale}.${key} must be a string`); + } else { + checkString(value, `dictionaries.${locale}.${key}`); + } + } + } + + const [primaryLocale, secondaryLocale] = expectedLocales; + const primaryKeys = keySets.get(primaryLocale) ?? new Set(); + const secondaryKeys = keySets.get(secondaryLocale) ?? new Set(); + + for (const key of primaryKeys) { + if (!secondaryKeys.has(key)) failures.push(`dictionaries.${secondaryLocale} is missing key ${key}`); + } + + for (const key of secondaryKeys) { + if (!primaryKeys.has(key)) failures.push(`dictionaries.${primaryLocale} is missing key ${key}`); + } +} + +function extractStringProperties(source, propertyName) { + const matches = []; + const pattern = new RegExp(`${propertyName}\\s*:\\s*["']([^"']+)["']`, "g"); + let match = pattern.exec(source); + + while (match) { + matches.push(match[1]); + match = pattern.exec(source); + } + + return matches; +} + +function extractKeywordArrays(routeSource) { + const arrays = []; + let searchIndex = 0; + + while (searchIndex < routeSource.length) { + const propertyIndex = routeSource.indexOf("keywords: [", searchIndex); + if (propertyIndex === -1) return arrays; + + const openIndex = routeSource.indexOf("[", propertyIndex); + const closeIndex = findMatching(routeSource, openIndex, "[", "]"); + arrays.push(routeSource.slice(openIndex, closeIndex + 1)); + searchIndex = closeIndex + 1; + } + + return arrays; +} + +function checkRouteRegistry(routeSource, dictionaries) { + if (!routeSource.includes('import type { DictionaryKey } from "@/lib/i18n/dictionaries";')) { + failures.push("routeRegistry must import DictionaryKey from the centralized dictionaries module"); + } + + if (!routeSource.includes("titleKey: DictionaryKey;")) { + failures.push("RouteDefinition.titleKey must be typed as DictionaryKey"); + } + + if (!routeSource.includes("descriptionKey: DictionaryKey;")) { + failures.push("RouteDefinition.descriptionKey must be typed as DictionaryKey"); + } + + for (const propertyName of ["titleKey", "descriptionKey"]) { + for (const key of extractStringProperties(routeSource, propertyName)) { + for (const locale of expectedLocales) { + if (!dictionaries?.[locale]?.[key]) { + failures.push(`routeRegistry ${propertyName} ${key} is missing from dictionaries.${locale}`); + } + } + } + } + + const keywordArrays = extractKeywordArrays(routeSource); + if (keywordArrays.length === 0) { + failures.push("routeRegistry must expose searchable route keyword arrays"); + } + + keywordArrays.forEach((arraySource, routeIndex) => { + const keywords = evaluateLiteral(arraySource, `routeRegistry.keywords[${routeIndex}]`); + if (!Array.isArray(keywords)) { + failures.push(`routeRegistry.keywords[${routeIndex}] must be an array`); + return; + } + + keywords.forEach((keyword, keywordIndex) => { + if (typeof keyword !== "string") { + failures.push(`routeRegistry.keywords[${routeIndex}][${keywordIndex}] must be a string`); + } else { + checkString(keyword, `routeRegistry.keywords[${routeIndex}][${keywordIndex}]`); + } + }); + }); +} + +const dictionarySource = readRequired(dictionaryPath); +const routeRegistrySource = readRequired(routeRegistryPath); + +const typecheckResult = spawnSync(process.execPath, ["-e", "process.exit(0)"]); +if (typecheckResult.status !== 0) { + failures.push("Node runtime sanity check failed"); +} + +let dictionaries = null; +if (dictionarySource) { + try { + dictionaries = evaluateLiteral(extractInitializer(dictionarySource, "dictionaries", "{", "}"), "dictionaries"); + } catch (error) { + failures.push(`${dictionaryPath} could not be parsed: ${error.message}`); + } +} + +if (dictionaries) compareLocaleKeys(dictionaries); +if (routeRegistrySource) checkRouteRegistry(routeRegistrySource, dictionaries); + +if (failures.length > 0) { + console.error("i18n copy smoke failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log("PASS i18n copy smoke checks"); +} diff --git a/scripts/production-readiness-smoke.mjs b/scripts/production-readiness-smoke.mjs new file mode 100644 index 0000000..fc90e17 --- /dev/null +++ b/scripts/production-readiness-smoke.mjs @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; + +function read(path) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +function run(command, args) { + if (process.platform === "win32") { + return spawnSync("cmd.exe", ["/c", command, ...args], { stdio: "inherit" }); + } + + return spawnSync(command, args, { stdio: "inherit" }); +} + +const files = { + packageJson: "package.json", + routeRegistry: "src/lib/navigation/route-registry.ts", + permissions: "src/lib/auth/permissions.ts", + healthPage: "src/app/system/health/page.tsx", + healthService: "src/lib/services/health-service.ts", + readme: "README.md", +}; + +const source = Object.fromEntries(Object.entries(files).map(([key, path]) => [key, read(path)])); + +const smokeCommands = [ + ["npm", ["run", "smoke:i18n"]], + ["npm", ["run", "smoke:auth"]], + ["npm", ["run", "smoke:api"]], + ["npm", ["run", "smoke:permissions"]], + ["npm", ["run", "smoke:crud"]], + ["npm", ["run", "smoke:audit"]], + ["npm", ["run", "smoke:a11y"]], + ["npm", ["run", "smoke:template"]], +]; + +const checks = [ + { + name: "package exposes production readiness smoke command", + pass: + source.packageJson.includes('"smoke:production"') && + source.packageJson.includes("production-readiness-smoke.mjs"), + }, + { + name: "system health route, registry entry, permission, and service exist", + pass: + existsSync(files.healthPage) && + existsSync(files.healthService) && + source.routeRegistry.includes("/system/health") && + source.routeRegistry.includes("system:health:view") && + source.permissions.includes('"system:health:view"'), + }, + { + name: "health page displays API mode, backend/mock status, version, and operational indicators", + pass: + source.healthPage.includes("apiMode") && + source.healthPage.includes("backendStatus") && + source.healthPage.includes("version") && + source.healthPage.includes("indicators"), + }, + { + name: "README documents health route and release verification", + pass: + source.readme.includes("/system/health") && + source.readme.includes("smoke:production") && + source.readme.toLowerCase().includes("release verification"), + }, +]; + +for (const [command, args] of smokeCommands) { + const result = run(command, args); + + if (result.status !== 0) { + checks.push({ name: `${command} ${args.join(" ")} passes`, pass: false }); + } +} + +const failures = checks.filter((check) => !check.pass); + +if (failures.length > 0) { + console.error("Production readiness smoke failed:"); + for (const failure of failures) console.error(`- ${failure.name}`); + process.exitCode = 1; +} else { + console.log("PASS production readiness smoke checks"); +} diff --git a/scripts/template-hardening-smoke.mjs b/scripts/template-hardening-smoke.mjs index ce06a21..af3d5f9 100644 --- a/scripts/template-hardening-smoke.mjs +++ b/scripts/template-hardening-smoke.mjs @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; function read(path) { @@ -19,6 +20,7 @@ const files = { dictionaries: "src/lib/i18n/dictionaries.ts", dictionaryHook: "src/lib/i18n/use-dictionary.ts", preferencesStore: "src/lib/stores/preferences-store.ts", + i18nCopySmoke: "scripts/i18n-copy-smoke.mjs", apiErrors: "src/lib/api/errors.ts", httpAdapter: "src/lib/api/http-adapter.ts", formValidators: "src/lib/forms/validators.ts", @@ -63,6 +65,10 @@ const source = { readme: read("README.md"), }; +const i18nCopySmokeResult = existsSync(files.i18nCopySmoke) + ? spawnSync(process.execPath, [files.i18nCopySmoke], { stdio: "inherit" }) + : { status: 1 }; + const requiredRoutes = [ "/dashboard", "/account/profile", @@ -126,6 +132,14 @@ const checks = [ source.routeRegistry.includes("titleKey") && source.routeRegistry.includes("descriptionKey"), }, + { + name: "i18n copy and route registry contract is part of template hardening", + pass: + existsSync(files.i18nCopySmoke) && + source.packageJson.includes('"smoke:i18n"') && + source.packageJson.includes("i18n-copy-smoke.mjs") && + i18nCopySmokeResult.status === 0, + }, { name: "API client has normalized errors and mock/http adapter boundary", pass: diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5ab23f8..2056038 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -137,8 +137,9 @@ export default function LoginPage() { const router = useRouter(); const { token, setSession } = useAuthStore(); const hydrated = useAuthHydrated(); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); + const demoCredentialsVisible = authService.isMockAuthMode; + const [username, setUsername] = useState(demoCredentialsVisible ? authService.demoCredentials.username : ""); + const [password, setPassword] = useState(demoCredentialsVisible ? authService.demoCredentials.password : ""); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -185,7 +186,7 @@ export default function LoginPage() { password, remember: false, }); - setSession(session.token, session.user); + setSession(session); router.replace("/dashboard"); } catch { setError("登录失败,请检查账号或密码后重试"); @@ -332,11 +333,14 @@ export default function LoginPage() { -
- 演示账号 admin - / - admin123 -
+ {demoCredentialsVisible ? ( +
+ 演示账号{" "} + {authService.demoCredentials.username} + / + {authService.demoCredentials.password} +
+ ) : null}

diff --git a/src/app/session-expired/page.tsx b/src/app/session-expired/page.tsx index 7dd616c..d751630 100644 --- a/src/app/session-expired/page.tsx +++ b/src/app/session-expired/page.tsx @@ -1,8 +1,18 @@ +"use client"; + import { TimerReset } from "lucide-react"; +import * as React from "react"; import { StatusPage } from "@/components/layout/status-page"; +import { useAuthStore } from "@/lib/stores/auth-store"; export default function SessionExpiredPage() { + const clearSession = useAuthStore((state) => state.clearSession); + + React.useEffect(() => { + clearSession(); + }, [clearSession]); + return ( = { danger: "border-red-500/25 bg-red-500/10 text-red-600 dark:text-red-300", }; +const resultOptions = [ + { label: "All results", value: "all" }, + { label: "Success", value: "success" }, + { label: "Failure", value: "failure" }, +]; + +const resultBadgeClass: Record = { + success: "border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300", + failure: "border-red-500/25 bg-red-500/10 text-red-600 dark:text-red-300", +}; + function formatLevel(level: AuditLogLevel) { return level[0].toUpperCase() + level.slice(1); } +function formatResult(result: AuditLogResult) { + return result[0].toUpperCase() + result.slice(1); +} + export default function AuditLogsPage() { const { toast } = useToast(); const [keyword, setKeyword] = React.useState(""); const [level, setLevel] = React.useState("all"); + const [result, setResult] = React.useState("all"); + const [moduleFilter, setModuleFilter] = React.useState(""); + const [actorFilter, setActorFilter] = React.useState(""); + const [createdFrom, setCreatedFrom] = React.useState(""); + const [createdTo, setCreatedTo] = React.useState(""); const [records, setRecords] = React.useState([]); const [selectedLogKeys, setSelectedLogKeys] = React.useState([]); const [loading, setLoading] = React.useState(true); @@ -53,22 +76,30 @@ export default function AuditLogsPage() { } try { - const result = await listAuditLogs({ keyword, level }); - setRecords(result); + const auditRecords = await listAuditLogs({ + keyword, + level, + result, + module: moduleFilter, + actor: actorFilter, + createdFrom, + createdTo, + }); + setRecords(auditRecords); } catch (error) { setMessage(error instanceof Error ? error.message : "Failed to load audit logs."); } finally { setLoading(false); } - }, [keyword, level]); + }, [actorFilter, createdFrom, createdTo, keyword, level, moduleFilter, result]); React.useEffect(() => { let active = true; - void listAuditLogs({ keyword, level }) - .then((result) => { + void listAuditLogs({ keyword, level, result, module: moduleFilter, actor: actorFilter, createdFrom, createdTo }) + .then((auditRecords) => { if (active) { - setRecords(result); + setRecords(auditRecords); } }) .catch((error: unknown) => { @@ -85,7 +116,7 @@ export default function AuditLogsPage() { return () => { active = false; }; - }, [keyword, level]); + }, [actorFilter, createdFrom, createdTo, keyword, level, moduleFilter, result]); const visibleSelectedLogKeys = React.useMemo(() => { const visibleIds = new Set(records.map((record) => record.id)); @@ -119,6 +150,7 @@ export default function AuditLogsPage() {

{record.actor}
{record.ipAddress}
+
{record.userAgent}
), }, @@ -136,6 +168,20 @@ export default function AuditLogsPage() { ), }, + { + key: "result", + title: "Result", + render: (record) => ( + + {formatResult(record.result)} + + ), + }, + { + key: "requestId", + title: "Request ID", + render: (record) => {record.requestId}, + }, { key: "createdAt", title: "Time", @@ -158,6 +204,9 @@ export default function AuditLogsPage() { Level: {level} + + Result: {result} + } actions={ @@ -185,6 +234,46 @@ export default function AuditLogsPage() { statusOptions={levelOptions} /> +
+ setActorFilter(event.target.value)} + placeholder="Actor" + className="bg-muted/40 dark:bg-white/[0.04]" + /> + setModuleFilter(event.target.value)} + placeholder="Module" + className="bg-muted/40 dark:bg-white/[0.04]" + /> + + setCreatedFrom(event.target.value)} + placeholder="Created from" + className="bg-muted/40 dark:bg-white/[0.04]" + /> + setCreatedTo(event.target.value)} + placeholder="Created to" + className="bg-muted/40 dark:bg-white/[0.04]" + /> +
+ {message ? (
{message} diff --git a/src/app/system/health/page.tsx b/src/app/system/health/page.tsx new file mode 100644 index 0000000..28748ba --- /dev/null +++ b/src/app/system/health/page.tsx @@ -0,0 +1,97 @@ +import { Activity, CheckCircle2, TriangleAlert } from "lucide-react"; + +import { AppShell } from "@/components/layout/app-shell"; +import { PageHeader } from "@/components/layout/page-header"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { getSystemHealth, type HealthIndicatorStatus } from "@/lib/services/health-service"; + +const statusClass: Record = { + ok: "border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300", + warning: "border-yellow-500/25 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300", + down: "border-red-500/25 bg-red-500/10 text-red-600 dark:text-red-300", +}; + +function formatStatus(status: HealthIndicatorStatus) { + return status === "ok" ? "OK" : status[0].toUpperCase() + status.slice(1); +} + +export default function SystemHealthPage() { + const health = getSystemHealth(); + const { apiMode, backendStatus, version, buildTarget, indicators } = health; + const StatusIcon = backendStatus === "ok" ? CheckCircle2 : TriangleAlert; + + return ( + +
+ + + API: {apiMode} + + + Backend: {formatStatus(backendStatus)} + + + } + /> + +
+ + + API mode + + +
{apiMode}
+

Adapter mode selected by environment.

+
+
+ + + Version + + +
{version}
+

Frontend release identity.

+
+
+ + + Build target + + +
{buildTarget}
+

Deployment or local execution target.

+
+
+
+ +
+
+
+
+
+
+ {indicators.map((indicator) => ( +
+
+
{indicator.label}
+
{indicator.detail}
+
+ + {formatStatus(indicator.status)} + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/system/menus/page.tsx b/src/app/system/menus/page.tsx index 7c6f12f..f3f831a 100644 --- a/src/app/system/menus/page.tsx +++ b/src/app/system/menus/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Menu as MenuIcon, Plus, RefreshCw, Route } from "lucide-react"; +import { Download, Menu as MenuIcon, Plus, RefreshCw, Route } from "lucide-react"; import * as React from "react"; import { PermissionGuard } from "@/components/auth/permission-guard"; @@ -19,7 +19,8 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useToast } from "@/components/ui/toast-provider"; import type { MenuRecord, Status } from "@/lib/api/types"; import { useCan } from "@/lib/auth/use-permissions"; -import { createMenu, deleteMenu, listMenus, updateMenu } from "@/lib/services/menu-service"; +import { createMenu, deleteMenu, exportMenusCsv, listMenus, updateMenu } from "@/lib/services/menu-service"; +import { downloadCsv } from "@/lib/services/csv-export"; import { cn } from "@/lib/utils"; type StatusFilter = "all" | Status; @@ -164,6 +165,10 @@ export default function MenusPage() { setStatus(value as StatusFilter); }; + const handleExportCsv = () => { + downloadCsv("menus.csv", exportMenusCsv(filteredMenus)); + }; + const handleSubmit = async (values: MenuFormValues) => { setSubmitting(true); setMessage(null); @@ -372,6 +377,10 @@ export default function MenusPage() {
), }, + { + key: "dataScope", + title: "Data scope", + render: (record) => ( + + {formatDataScope(record.dataScope)} + + ), + }, { key: "users", title: "Users", @@ -415,6 +440,10 @@ export default function RolesPage() {