From c45123a2106eb59bd3fe1c20cbcde66467c097c9 Mon Sep 17 00:00:00 2001 From: willxue Date: Fri, 5 Jun 2026 19:26:13 +0800 Subject: [PATCH 1/7] Harden production readiness contracts before backend wiring Adds smoke contracts for i18n copy, auth lifecycle, and API observability before deeper production work. The implementation types route copy keys against the dictionary, formalizes session status and permission-list contracts, and preserves request IDs plus timeout/abort behavior through the HTTP adapter. Constraint: Keep the existing mock-first adapter boundary and local UI flow intact while making HTTP mode explicit. Rejected: Introduce a validation or i18n dependency | source-level smoke checks cover the current template contracts without new runtime packages. Confidence: high Scope-risk: moderate Directive: Keep new auth/API smoke scripts updated whenever backend contracts change. Tested: npm run smoke:i18n Tested: npm run smoke:api Tested: npm run smoke:auth Tested: npm run smoke:template Tested: npm run lint:standards Tested: npx tsc --noEmit --pretty false --project D:\\willxue\\WeOpen\\WeBase\\.worktrees\\production-readiness\\tsconfig.json Tested: npm run build Not-tested: Real backend auth endpoints and live HTTP timeout behavior remain mocked. Co-authored-by: OmX --- docs/api-adapter.md | 24 ++- package.json | 3 + scripts/api-contract-smoke.mjs | 107 ++++++++++ scripts/auth-contract-smoke.mjs | 110 ++++++++++ scripts/i18n-copy-smoke.mjs | 298 +++++++++++++++++++++++++++ scripts/template-hardening-smoke.mjs | 14 ++ src/app/login/page.tsx | 20 +- src/app/session-expired/page.tsx | 10 + src/components/auth/auth-guard.tsx | 16 +- src/lib/api/client.ts | 26 +-- src/lib/api/errors.ts | 11 +- src/lib/api/http-adapter.ts | 172 ++++++++++++++-- src/lib/api/mock-adapter.ts | 6 + src/lib/api/types.ts | 13 ++ src/lib/navigation/route-registry.ts | 5 +- src/lib/services/auth-service.ts | 20 +- src/lib/stores/auth-store.ts | 28 ++- 17 files changed, 831 insertions(+), 52 deletions(-) create mode 100644 scripts/api-contract-smoke.mjs create mode 100644 scripts/auth-contract-smoke.mjs create mode 100644 scripts/i18n-copy-smoke.mjs diff --git a/docs/api-adapter.md b/docs/api-adapter.md index dc1620b..26fe604 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,7 +73,7 @@ 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. @@ -85,3 +92,14 @@ 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. diff --git a/package.json b/package.json index 2e3fee9..68d6409 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "start": "next start", "lint": "eslint .", "lint:standards": "node scripts/lint-standards-smoke.mjs", + "smoke:api": "node scripts/api-contract-smoke.mjs", + "smoke:auth": "node scripts/auth-contract-smoke.mjs", + "smoke:i18n": "node scripts/i18n-copy-smoke.mjs", "smoke:template": "node scripts/template-hardening-smoke.mjs" }, "dependencies": { 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/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/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/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 ( state.token); + const sessionStatus = useAuthStore((state) => state.sessionStatus); const hydrated = useAuthHydrated(); useEffect(() => { - if (hydrated && !token) { + if (!hydrated) { + return; + } + + if (sessionStatus === "session_expired") { + router.replace("/session-expired"); + return; + } + + if (!token) { router.replace("/login"); } - }, [hydrated, token, router]); + }, [hydrated, sessionStatus, token, router]); if (!hydrated) { return ( @@ -25,7 +35,7 @@ export function AuthGuard({ children }: { children: ReactNode }) { ); } - if (!token) return null; + if (!token || sessionStatus === "session_expired") return null; return <>{children}; } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index b59d836..1231415 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -2,13 +2,13 @@ import { normalizeApiError, ApiError } from "@/lib/api/errors"; import { httpAdapter } from "@/lib/api/http-adapter"; import { mockDelete, mockGet, mockPost, mockPut } from "./mock-adapter"; -import type { ApiResponse } from "./types"; +import type { ApiRequestOptions, ApiResponse } from "./types"; type ApiAdapter = { - get(url: string, params?: object): Promise>; - post(url: string, body?: unknown): Promise>; - put(url: string, body?: unknown): Promise>; - delete(url: string): Promise>; + get(url: string, params?: object, options?: ApiRequestOptions): Promise>; + post(url: string, body?: unknown, options?: ApiRequestOptions): Promise>; + put(url: string, body?: unknown, options?: ApiRequestOptions): Promise>; + delete(url: string, options?: ApiRequestOptions): Promise>; }; const mockAdapter: ApiAdapter = { @@ -30,20 +30,20 @@ async function safe(request: () => Promise>) { } export const apiClient = { - get(url: string, params?: object): Promise> { - return safe(() => adapter.get(url, params)); + get(url: string, params?: object, options?: ApiRequestOptions): Promise> { + return safe(() => adapter.get(url, params, options)); }, - post(url: string, body?: unknown): Promise> { - return safe(() => adapter.post(url, body)); + post(url: string, body?: unknown, options?: ApiRequestOptions): Promise> { + return safe(() => adapter.post(url, body, options)); }, - put(url: string, body?: unknown): Promise> { - return safe(() => adapter.put(url, body)); + put(url: string, body?: unknown, options?: ApiRequestOptions): Promise> { + return safe(() => adapter.put(url, body, options)); }, - delete(url: string): Promise> { - return safe(() => adapter.delete(url)); + delete(url: string, options?: ApiRequestOptions): Promise> { + return safe(() => adapter.delete(url, options)); }, }; diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index a8d978d..90ec257 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -1,14 +1,23 @@ +export type ApiErrorOptions = { + code?: string; + status?: number; + details?: unknown; + requestId?: string; +}; + export class ApiError extends Error { code: string; status?: number; details?: unknown; + requestId?: string; - constructor(message: string, options: { code?: string; status?: number; details?: unknown } = {}) { + constructor(message: string, options: ApiErrorOptions = {}) { super(message); this.name = "ApiError"; this.code = options.code ?? "api_error"; this.status = options.status; this.details = options.details; + this.requestId = options.requestId; } } diff --git a/src/lib/api/http-adapter.ts b/src/lib/api/http-adapter.ts index 53ccb93..6bf4af4 100644 --- a/src/lib/api/http-adapter.ts +++ b/src/lib/api/http-adapter.ts @@ -1,40 +1,148 @@ import { ApiError } from "@/lib/api/errors"; -import type { ApiResponse } from "@/lib/api/types"; +import type { ApiRequestOptions, ApiResponse } from "@/lib/api/types"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; +const DEFAULT_API_TIMEOUT_MS = 15_000; +const REQUEST_ID_HEADERS = ["x-request-id", "x-correlation-id", "x-trace-id"]; type ApiErrorPayload = { code?: string | number; message?: string; details?: unknown; + requestId?: string | number; + traceId?: string | number; }; +function appendQueryValue(searchParams: URLSearchParams, key: string, value: unknown) { + if (value === undefined || value === null || value === "") return; + + if (Array.isArray(value)) { + for (const item of value) { + appendQueryValue(searchParams, key, item); + } + + return; + } + + searchParams.append(key, String(value)); +} + function toQueryString(params?: object) { if (!params) return ""; - const entries = Object.entries(params).flatMap(([key, value]) => { - if (value === undefined || value === null || value === "") return []; - return [[key, String(value)]]; - }); + const searchParams = new URLSearchParams(); - return entries.length > 0 ? `?${new URLSearchParams(entries).toString()}` : ""; + for (const [key, value] of Object.entries(params)) { + appendQueryValue(searchParams, key, value); + } + + const query = searchParams.toString(); + + return query ? `?${query}` : ""; } -async function readJsonBody(response: Response) { +function getConfiguredTimeoutMs(options?: ApiRequestOptions) { + const configured = options?.timeoutMs ?? process.env.NEXT_PUBLIC_API_TIMEOUT_MS; + + if (configured === undefined || configured === "") return DEFAULT_API_TIMEOUT_MS; + + const timeoutMs = Number(configured); + + return Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : DEFAULT_API_TIMEOUT_MS; +} + +function createRequestSignal(options?: ApiRequestOptions) { + const controller = new AbortController(); + const timeoutMs = getConfiguredTimeoutMs(options); + let timeoutId: ReturnType | undefined; + let timedOut = false; + let removeAbortListener = () => {}; + + if (options?.signal) { + const abortFromCaller = () => controller.abort(options.signal?.reason); + + if (options.signal.aborted) { + abortFromCaller(); + } else { + options.signal.addEventListener("abort", abortFromCaller, { once: true }); + removeAbortListener = () => options.signal?.removeEventListener("abort", abortFromCaller); + } + } + + if (timeoutMs > 0 && !controller.signal.aborted) { + timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + } + + return { + signal: controller.signal, + timedOut: () => timedOut, + cleanup: () => { + if (timeoutId) clearTimeout(timeoutId); + removeAbortListener(); + }, + }; +} + +function isJsonContentType(contentType: string) { + return contentType.includes("application/json") || contentType.includes("+json"); +} + +function normalizeRequestId(value: unknown) { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number") return String(value); + return undefined; +} + +function getPayloadRequestId(payload: unknown) { + if (!payload || typeof payload !== "object") return undefined; + + const payloadRecord = payload as ApiErrorPayload; + + return normalizeRequestId(payloadRecord.requestId) ?? normalizeRequestId(payloadRecord.traceId); +} + +function getResponseRequestId(response: Response, payload?: unknown) { + for (const header of REQUEST_ID_HEADERS) { + const requestId = normalizeRequestId(response.headers.get(header)); + + if (requestId) return requestId; + } + + return getPayloadRequestId(payload); +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + +async function readJsonBody(response: Response, requestId?: string) { const contentType = response.headers.get("content-type") ?? ""; if (response.status === 204) { return undefined; } - if (!contentType.includes("application/json")) { + if (!isJsonContentType(contentType)) { throw new ApiError("API returned a non-JSON response.", { code: "invalid_response", status: response.status, + requestId, }); } - return response.json() as Promise; + try { + return await response.json(); + } catch (error) { + throw new ApiError("API returned invalid JSON.", { + code: "invalid_response", + status: response.status, + details: error, + requestId, + }); + } } function getHttpErrorCode(status: number) { @@ -48,8 +156,10 @@ function getErrorPayload(payload: unknown): ApiErrorPayload { } function normalizeResponse(payload: unknown, response: Response): ApiResponse { + const requestId = getResponseRequestId(response, payload); + if (response.status === 204) { - return { code: 0, message: "success", data: undefined as T }; + return { code: 0, message: "success", data: undefined as T, requestId }; } const result = payload as ApiResponse; @@ -59,10 +169,14 @@ function normalizeResponse(payload: unknown, response: Response): ApiResponse code: "business_error", status: response.status, details: result, + requestId, }); } - return result; + return { + ...result, + requestId: requestId ?? result.requestId, + }; } async function request( @@ -70,8 +184,10 @@ async function request( url: string, body?: unknown, params?: object, + options?: ApiRequestOptions, ): Promise> { let response: Response; + const requestSignal = createRequestSignal(options); try { response = await fetch(`${API_BASE_URL}${url}${toQueryString(params)}`, { @@ -82,23 +198,43 @@ async function request( body: body === undefined ? undefined : JSON.stringify(body), cache: method === "GET" ? "no-store" : "default", credentials: "include", + signal: requestSignal.signal, }); } catch (error) { + if (requestSignal.timedOut()) { + throw new ApiError("Network request timed out.", { + code: "timeout", + details: error, + }); + } + + if (requestSignal.signal.aborted || isAbortError(error)) { + throw new ApiError("Network request was aborted.", { + code: "aborted", + details: error, + }); + } + throw new ApiError("Network request failed.", { code: "network_error", details: error, }); + } finally { + requestSignal.cleanup(); } - const payload = await readJsonBody(response); + const headerRequestId = getResponseRequestId(response); + const payload = await readJsonBody(response, headerRequestId); if (!response.ok) { const errorPayload = getErrorPayload(payload); + const requestId = getResponseRequestId(response, payload); throw new ApiError(errorPayload.message || response.statusText || "Request failed.", { code: errorPayload.code ? String(errorPayload.code) : getHttpErrorCode(response.status), status: response.status, details: payload, + requestId, }); } @@ -106,9 +242,11 @@ async function request( } export const httpAdapter = { - get: (url: string, params?: object) => request("GET", url, undefined, params), - post: (url: string, body?: unknown) => request("POST", url, body), - put: (url: string, body?: unknown) => request("PUT", url, body), - delete: (url: string) => request("DELETE", url), + get: (url: string, params?: object, options?: ApiRequestOptions) => + request("GET", url, undefined, params, options), + post: (url: string, body?: unknown, options?: ApiRequestOptions) => + request("POST", url, body, undefined, options), + put: (url: string, body?: unknown, options?: ApiRequestOptions) => + request("PUT", url, body, undefined, options), + delete: (url: string, options?: ApiRequestOptions) => request("DELETE", url, undefined, undefined, options), }; - diff --git a/src/lib/api/mock-adapter.ts b/src/lib/api/mock-adapter.ts index 71e93f9..9a46a6d 100644 --- a/src/lib/api/mock-adapter.ts +++ b/src/lib/api/mock-adapter.ts @@ -22,6 +22,7 @@ import type { NotificationRecord, NotificationStatus, PageResult, + PermissionListResult, RoleRecord, SessionRecord, SessionRisk, @@ -197,6 +198,8 @@ export async function mockGet(url: string, params?: object): Promise; + case "/auth/permissions": + return ok({ permissions: currentUser.permissions }) as ApiResponse; case "/account/profile": return ok(accountProfile) as ApiResponse; case "/dashboard/overview": @@ -237,10 +240,13 @@ export async function mockPost(url: string, body?: unknown): Promise; } + case "/auth/logout": + return ok({ ok: true }, "logout success") as ApiResponse; case "/users": { const record = { id: nextId("u"), ...(body as RecordPayload) }; users.unshift(record); diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index eeb62d5..9c494a4 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -5,6 +5,12 @@ export interface ApiResponse { code: number; message: string; data: T; + requestId?: string; +} + +export interface ApiRequestOptions { + signal?: AbortSignal; + timeoutMs?: number; } export interface PageResult { @@ -26,6 +32,12 @@ export interface CurrentUser { permissions: PermissionKey[]; } +export type AuthSessionStatus = "anonymous" | "authenticated" | "session_expired"; + +export interface PermissionListResult { + permissions: PermissionKey[]; +} + export interface AccountProfile extends CurrentUser { title: string; department: string; @@ -51,6 +63,7 @@ export interface LoginPayload { export interface LoginResult { token: string; user: CurrentUser; + expiresAt: string; } export interface DashboardMetric { diff --git a/src/lib/navigation/route-registry.ts b/src/lib/navigation/route-registry.ts index 635699b..9aa3d2b 100644 --- a/src/lib/navigation/route-registry.ts +++ b/src/lib/navigation/route-registry.ts @@ -13,6 +13,7 @@ import { } from "lucide-react"; import type { PermissionKey } from "@/lib/auth/permissions"; +import type { DictionaryKey } from "@/lib/i18n/dictionaries"; export type RouteGroup = "dashboard" | "account" | "system" | "showcase"; @@ -22,8 +23,8 @@ export interface RouteDefinition { group: RouteGroup; fallbackTitle: string; fallbackDescription: string; - titleKey: string; - descriptionKey: string; + titleKey: DictionaryKey; + descriptionKey: DictionaryKey; icon: LucideIcon; permission: PermissionKey; showInSidebar: boolean; diff --git a/src/lib/services/auth-service.ts b/src/lib/services/auth-service.ts index 4b0af2e..efe9856 100644 --- a/src/lib/services/auth-service.ts +++ b/src/lib/services/auth-service.ts @@ -1,7 +1,15 @@ import { apiClient } from "@/lib/api/client"; -import type { CurrentUser, LoginPayload, LoginResult } from "@/lib/api/types"; +import type { CurrentUser, LoginPayload, LoginResult, PermissionListResult } from "@/lib/api/types"; + +export const isMockAuthMode = process.env.NEXT_PUBLIC_API_MODE !== "http"; + +export const demoCredentials = { + username: "admin", + password: "admin123", +} as const; export async function login(payload: LoginPayload): Promise { + // The prefilled demo credentials are mock-only; HTTP mode depends on the backend auth contract. const response = await apiClient.post("/auth/login", payload); return response.data; @@ -12,3 +20,13 @@ export async function getCurrentUser(): Promise { return response.data; } + +export async function getPermissionList(): Promise { + const response = await apiClient.get("/auth/permissions"); + + return response.data; +} + +export async function logout(): Promise { + await apiClient.post<{ ok: boolean }>("/auth/logout"); +} diff --git a/src/lib/stores/auth-store.ts b/src/lib/stores/auth-store.ts index 8c555e2..ad0b9d1 100644 --- a/src/lib/stores/auth-store.ts +++ b/src/lib/stores/auth-store.ts @@ -2,13 +2,17 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { allPermissions } from "@/lib/auth/permissions"; -import type { CurrentUser } from "@/lib/api/types"; +import type { AuthSessionStatus, CurrentUser, LoginResult } from "@/lib/api/types"; interface AuthState { token: string | null; user: CurrentUser | null; - setSession: (token: string, user: CurrentUser) => void; + expiresAt: string | null; + sessionStatus: AuthSessionStatus; + setSession: (session: LoginResult) => void; updateUser: (user: CurrentUser) => void; + clearSession: () => void; + expireSession: () => void; logout: () => void; } @@ -30,13 +34,29 @@ export const useAuthStore = create()( (set) => ({ token: null, user: null, - setSession: (token, user) => set({ token, user: normalizeUser(user) }), + expiresAt: null, + sessionStatus: "anonymous", + setSession: (session) => + set({ + token: session.token, + user: normalizeUser(session.user), + expiresAt: session.expiresAt, + sessionStatus: "authenticated", + }), updateUser: (user) => set({ user: normalizeUser(user) }), - logout: () => set({ token: null, user: null }), + clearSession: () => set({ token: null, user: null, expiresAt: null, sessionStatus: "anonymous" }), + expireSession: () => set({ token: null, user: null, expiresAt: null, sessionStatus: "session_expired" }), + logout: () => set({ token: null, user: null, expiresAt: null, sessionStatus: "anonymous" }), }), { name: "webase-auth", storage: createJSONStorage(() => (typeof window === "undefined" ? noopStorage : sessionStorage)), + partialize: (state) => ({ + token: state.token, + user: state.user, + expiresAt: state.expiresAt, + sessionStatus: state.sessionStatus, + }), }, ), ); From 0bdb76e7e39eebdae6bb4b1aaf0f1dfa0aaaaa2c Mon Sep 17 00:00:00 2001 From: willxue Date: Fri, 5 Jun 2026 19:58:32 +0800 Subject: [PATCH 2/7] Make role permissions carry production data scope Adds a source-level smoke contract and role model fields for menu permissions, backend action permissions, and data scopes. The role form and table now expose the selected data scope while docs clarify that frontend RBAC hides UI and backend authorization remains mandatory. Constraint: Preserve the existing role/menu mock workflow and avoid introducing a policy engine dependency. Rejected: Encode data scopes only in free-form permission strings | it would make backend integration ambiguous and hard to verify. Confidence: high Scope-risk: moderate Directive: Keep RoleRecord dataScope and actionPermissions aligned with backend role responses. Tested: npm run smoke:permissions Tested: npm run smoke:template Tested: npx tsc --noEmit --pretty false --project D:\\willxue\\WeOpen\\WeBase\\.worktrees\\production-readiness\\tsconfig.json Tested: npm run build Not-tested: Backend enforcement for data-scope filtering is not implemented in this frontend template. Co-authored-by: OmX --- docs/permissions.md | 10 +++ package.json | 1 + scripts/data-scope-permissions-smoke.mjs | 89 ++++++++++++++++++++++ src/app/system/roles/page.tsx | 22 +++++- src/components/system/role-form-dialog.tsx | 35 ++++++++- src/lib/api/mock-data.ts | 48 +++++++++++- src/lib/api/types.ts | 4 + src/lib/services/role-service.ts | 5 +- 8 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 scripts/data-scope-permissions-smoke.mjs diff --git a/docs/permissions.md b/docs/permissions.md index fe50470..2963563 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. diff --git a/package.json b/package.json index 68d6409..d291cab 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "smoke:api": "node scripts/api-contract-smoke.mjs", "smoke:auth": "node scripts/auth-contract-smoke.mjs", "smoke:i18n": "node scripts/i18n-copy-smoke.mjs", + "smoke:permissions": "node scripts/data-scope-permissions-smoke.mjs", "smoke:template": "node scripts/template-hardening-smoke.mjs" }, "dependencies": { 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/src/app/system/roles/page.tsx b/src/app/system/roles/page.tsx index f5fc717..3ff2422 100644 --- a/src/app/system/roles/page.tsx +++ b/src/app/system/roles/page.tsx @@ -22,7 +22,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useToast } from "@/components/ui/toast-provider"; -import type { MenuRecord, RoleRecord, Status } from "@/lib/api/types"; +import type { MenuRecord, RoleDataScope, RoleRecord, Status } from "@/lib/api/types"; import { useCan } from "@/lib/auth/use-permissions"; import { listMenus } from "@/lib/services/menu-service"; import { createRole, deleteRole, listRoles, updateRole } from "@/lib/services/role-service"; @@ -35,6 +35,17 @@ function formatStatus(status: Status) { return status === "enabled" ? "Enabled" : "Disabled"; } +function formatDataScope(scope: RoleDataScope) { + const labels: Record = { + self: "Self", + department: "Department", + tenant: "Tenant", + all: "All", + }; + + return labels[scope]; +} + function roleMatchesKeyword(role: RoleRecord, keyword: string) { const normalizedKeyword = keyword.trim().toLowerCase(); @@ -345,6 +356,15 @@ export default function RolesPage() { ), }, + { + key: "dataScope", + title: "Data scope", + render: (record) => ( + + {formatDataScope(record.dataScope)} + + ), + }, { key: "users", title: "Users", diff --git a/src/components/system/role-form-dialog.tsx b/src/components/system/role-form-dialog.tsx index 003ca17..85331f7 100644 --- a/src/components/system/role-form-dialog.tsx +++ b/src/components/system/role-form-dialog.tsx @@ -9,7 +9,8 @@ import { FormField } from "@/components/ui/form-field"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import type { MenuRecord, RoleRecord, Status } from "@/lib/api/types"; +import type { MenuRecord, RoleDataScope, RoleRecord, Status } from "@/lib/api/types"; +import type { PermissionKey } from "@/lib/auth/permissions"; import type { FormErrors } from "@/lib/forms/form-errors"; import { hasErrors } from "@/lib/forms/form-errors"; import { required, runValidators } from "@/lib/forms/validators"; @@ -21,6 +22,8 @@ export interface RoleFormValues { description: string; status: Status; permissions: string[]; + actionPermissions: PermissionKey[]; + dataScope: RoleDataScope; } interface RoleFormDialogProps { @@ -48,9 +51,18 @@ const emptyValues: RoleFormValues = { description: "", status: "enabled", permissions: [], + actionPermissions: [], + dataScope: "department", }; type RoleFormField = "name" | "code" | "description"; +const dataScopeOptions: Array<{ value: RoleDataScope; label: string }> = [ + { value: "self", label: "Self" }, + { value: "department", label: "Department" }, + { value: "tenant", label: "Tenant" }, + { value: "all", label: "All" }, +]; + export function getMenuPermissionKey(menu: MenuRecord) { const segment = menu.path.split("/").filter(Boolean).at(-1) ?? menu.id; @@ -86,6 +98,8 @@ function getInitialValues(role?: RoleRecord | null, menus: MenuRecord[] = []): R description: role.description, status: role.status, permissions, + actionPermissions: role.actionPermissions, + dataScope: role.dataScope, }; } @@ -162,6 +176,8 @@ function RoleForm({ description, status: values.status, permissions: values.permissions, + actionPermissions: values.actionPermissions, + dataScope: values.dataScope, }); }; @@ -218,6 +234,23 @@ function RoleForm({ + + + +