From bdede98cde165d270228249e0da73b4b9b812d4c Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 3 Feb 2026 17:34:55 +0800 Subject: [PATCH] feat(project): init --- .env.example | 7 ++ .gitignore | 6 ++ Dockerfile | 20 ++++++ README.md | 29 ++++++++ next-env.d.ts | 5 ++ next.config.js | 40 +++++++++++ package.json | 34 +++++++++ postcss.config.js | 7 ++ src/app/api/dev/set-tenant/route.ts | 23 ++++++ src/app/auth-error/page.tsx | 21 ++++++ src/app/client-required/page.tsx | 10 +++ src/app/globals.css | 30 ++++++++ src/app/layout.tsx | 16 +++++ src/app/page.tsx | 22 ++++++ src/app/tenant-required/page.tsx | 12 ++++ src/lib/env.ts | 6 ++ src/lib/utils.ts | 7 ++ src/middleware.ts | 108 ++++++++++++++++++++++++++++ tailwind.config.ts | 57 +++++++++++++++ tsconfig.json | 25 +++++++ 20 files changed, 485 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app/api/dev/set-tenant/route.ts create mode 100644 src/app/auth-error/page.tsx create mode 100644 src/app/client-required/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/tenant-required/page.tsx create mode 100644 src/lib/env.ts create mode 100644 src/lib/utils.ts create mode 100644 src/middleware.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6b48186 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +IAM_FRONT_BASE_URL=https://iam.example.com +CMS_SERVICE_BASE_URL=https://cms-api.example.com +CMS_CLIENT_ID=cms +CMS_DEFAULT_TENANT_ID=4d779414-da04-49c3-b342-dabf93b6a119 + +# oauth_clients.redirect_uris 建议只填到 callback 路径(不要包含 next 参数),示例: +# http://localhost:5031/auth/callback diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f8bea3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +.next +.env +.DS_Store + +pnpm-lock.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46b4ad6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json ./ +RUN npm install + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=6031 +ENV HOSTNAME=0.0.0.0 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +CMD ["node", "server.js"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..64288b2 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# cms-front + +CMS 前端(业务系统),用于演示按 `iam-service/docs/SSO_INTEGRATION.md` 接入统一登录(iam-front)。 + +## 本地启动 + +```bash +cp .env.example .env +pnpm install +pnpm dev +``` + +## 本地联调流程 + +- 未登录访问页面时,middleware 会跳转到 iam-front 的 `/login`(携带 `clientId/tenantId/callback`) +- 登录成功后会回跳到业务 callback(通常是 `cms-service /auth/callback`),由后端换 token 并写入 cookie,然后再重定向回 cms-front +- 换取失败会跳转到 `/auth-error` 显示错误信息 + +开发环境设置 tenantId(写入 tenantId cookie): + +``` +/api/dev/set-tenant?tenantId=你的租户UUID +``` + +也可以在 .env 里设置默认租户(仅开发环境生效): + +``` +CMS_DEFAULT_TENANT_ID=你的租户UUID +``` diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..5722927 --- /dev/null +++ b/next.config.js @@ -0,0 +1,40 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + async headers() { + const isDev = process.env.NODE_ENV === "development" + const csp = [ + "default-src 'self'", + "base-uri 'self'", + "frame-ancestors 'none'", + "object-src 'none'", + "form-action 'self'", + "img-src 'self' data:", + isDev + ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" + : "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self'", + ].join("; ") + + return [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: csp }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "no-referrer" }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" }, + ], + }, + ] + }, +} + +module.exports = nextConfig + diff --git a/package.json b/package.json new file mode 100644 index 0000000..f15838c --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "cms-front", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev -p 6031", + "build": "next build", + "start": "next start -p 6031", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-slot": "^1.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "next": "^14.2.25", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.4" + }, + "devDependencies": { + "@types/node": "^20.17.16", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "^14.2.25", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2ce518b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/src/app/api/dev/set-tenant/route.ts b/src/app/api/dev/set-tenant/route.ts new file mode 100644 index 0000000..9c9ca84 --- /dev/null +++ b/src/app/api/dev/set-tenant/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server" + +export const runtime = "nodejs" + +export async function GET(req: NextRequest) { + const tenantId = req.nextUrl.searchParams.get("tenantId")?.trim() ?? "" + if (!tenantId) { + return NextResponse.json({ message: "tenantId is required" }, { status: 400 }) + } + + const res = NextResponse.redirect(new URL("/", req.url), 302) + res.cookies.set({ + name: "tenantId", + value: tenantId, + httpOnly: false, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 30 * 24 * 60 * 60, + }) + return res +} + diff --git a/src/app/auth-error/page.tsx b/src/app/auth-error/page.tsx new file mode 100644 index 0000000..4d9bb91 --- /dev/null +++ b/src/app/auth-error/page.tsx @@ -0,0 +1,21 @@ +export default function AuthErrorPage({ + searchParams, +}: { + searchParams?: { message?: string } +}) { + const message = searchParams?.message ?? "认证失败" + return ( +
+
+
无法完成登录
+
{message}
+ +
+
+ ) +} + diff --git a/src/app/client-required/page.tsx b/src/app/client-required/page.tsx new file mode 100644 index 0000000..e345cc1 --- /dev/null +++ b/src/app/client-required/page.tsx @@ -0,0 +1,10 @@ +export default function ClientRequiredPage() { + return ( +
+
+ 缺少 CMS_CLIENT_ID 环境变量,无法发起 SSO 登录跳转。 +
+
+ ) +} + diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..4ec32f8 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,30 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.75rem; +} + +body { + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..c67afda --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next" +import "./globals.css" + +export const metadata: Metadata = { + title: "CMS", + description: "CMS Frontend", +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..71ddcf4 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,22 @@ +import { cookies } from "next/headers" + +export default function Home() { + const tenantId = cookies().get("tenantId")?.value ?? "" + const userId = cookies().get("userId")?.value ?? "" + + return ( +
+
+
CMS
+
+
tenantId: {tenantId || "-"}
+
userId: {userId || "-"}
+
+
+ 如果未登录,会被中间件重定向到统一登录页。 +
+
+
+ ) +} + diff --git a/src/app/tenant-required/page.tsx b/src/app/tenant-required/page.tsx new file mode 100644 index 0000000..047d8df --- /dev/null +++ b/src/app/tenant-required/page.tsx @@ -0,0 +1,12 @@ +export default function TenantRequiredPage() { + return ( +
+
+ 缺少 tenantId(x-tenant-id header 或 tenantId cookie),无法跳转统一登录页。 +
+ 开发环境可访问:/api/dev/set-tenant?tenantId=你的租户UUID +
+
+
+ ) +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..9db5382 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,6 @@ +export function mustGetEnv(name: string): string { + const v = process.env[name] + if (!v) throw new Error(`${name} is required`) + return v +} + diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..e8ed525 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..362b61f --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; + +function decodeBase64Url(input: string): string { + const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); + const padLen = (4 - (normalized.length % 4)) % 4; + const padded = normalized + "=".repeat(padLen); + return atob(padded); +} + +function isExpired(jwt: string): boolean { + const parts = jwt.split("."); + if (parts.length < 2) return true; + try { + const payload = JSON.parse(decodeBase64Url(parts[1])) as { exp?: number }; + if (!payload.exp) return true; + return Math.floor(Date.now() / 1000) >= payload.exp; + } catch { + return true; + } +} + +export function middleware(req: NextRequest) { + if (process.env.NODE_ENV === "production") { + const proto = req.headers.get("x-forwarded-proto"); + if (proto && proto !== "https") { + const url = req.nextUrl.clone(); + url.protocol = "https"; + return NextResponse.redirect(url, 308); + } + } + + const cmsServiceBaseUrl = process.env.CMS_SERVICE_BASE_URL ?? ""; + const clientId = process.env.CMS_CLIENT_ID ?? ""; + + const currentUrl = req.nextUrl.clone(); + const pathname = currentUrl.pathname; + if ( + pathname === "/auth-error" || + pathname === "/tenant-required" || + pathname === "/client-required" + ) { + return NextResponse.next(); + } + + const tenantId = + req.headers.get("x-tenant-id") ?? + req.cookies.get("tenantId")?.value ?? + (process.env.NODE_ENV !== "production" + ? (process.env.CMS_DEFAULT_TENANT_ID ?? "") + : ""); + + const code = currentUrl.searchParams.get("code")?.trim() ?? ""; + if (code) { + if (!cmsServiceBaseUrl) { + const url = req.nextUrl.clone(); + url.pathname = "/client-required"; + url.search = ""; + return NextResponse.redirect(url, 302); + } + const nextUrl = currentUrl.clone(); + nextUrl.searchParams.delete("code"); + const callback = `${cmsServiceBaseUrl}/auth/callback?code=${encodeURIComponent( + code, + )}&tenant_id=${encodeURIComponent(tenantId)}&next=${encodeURIComponent(nextUrl.toString())}`; + return NextResponse.redirect(callback, 302); + } + + const accessToken = req.cookies.get("accessToken")?.value ?? ""; + + if (!accessToken || isExpired(accessToken)) { + if (!tenantId) { + const url = req.nextUrl.clone(); + url.pathname = "/tenant-required"; + url.search = ""; + return NextResponse.redirect(url, 302); + } + + if (!clientId) { + const url = req.nextUrl.clone(); + url.pathname = "/client-required"; + url.search = ""; + return NextResponse.redirect(url, 302); + } + + const next = encodeURIComponent(currentUrl.toString()); + if (!cmsServiceBaseUrl) { + const url = req.nextUrl.clone(); + url.pathname = "/client-required"; + url.search = ""; + return NextResponse.redirect(url, 302); + } + const callback = `${cmsServiceBaseUrl}/auth/callback?tenant_id=${encodeURIComponent( + tenantId, + )}&next=${next}`; + const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent( + clientId, + )}&tenantId=${encodeURIComponent( + tenantId, + )}&callback=${encodeURIComponent(callback)}`; + return NextResponse.redirect(loginUrl, 302); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], +}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..fc85e23 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,57 @@ +import type { Config } from "tailwindcss" + +const config: Config = { + darkMode: ["class"], + content: ["./src/**/*.{ts,tsx}"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [], +} + +export default config + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..48907b4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "es2022"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +