diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 6b10a5b..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": [ - "next/core-web-vitals", - "next/typescript" - ] -} diff --git a/README.md b/README.md index 6032e6e..7a13051 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # iam-front -统一认证前端(SSO 登录页),基于 Next.js 14(App Router)+ TypeScript + Tailwind CSS + shadcn/ui。 +统一认证前端(SSO 登录页),基于 Next.js 16(App Router)+ TypeScript + Tailwind CSS + shadcn/ui。 ## 本地启动 @@ -17,3 +17,43 @@ npm install npm run dev ``` +> 说明:本项目使用 Turbopack 开发模式(`next dev --turbopack`)。遇到 Linux file watch 限制(ENOSPC)时会自动使用 polling(不影响 Fast Refresh,但可能略增 CPU)。 + +## 组件使用示例 + +统一登录页默认使用带 Tabs 切换的组件(登录/注册): + +- 页面路由:[login/page.tsx](file:///home/shay/project/backend/iam-front/src/app/login/page.tsx) +- 组件入口:[login-form.tsx](file:///home/shay/project/backend/iam-front/src/components/login-form.tsx) + +示例(页面内): + +```tsx + +``` + +## 常见问题 + +### 1) ENOSPC: System limit for number of file watchers reached + +这是 Linux `inotify` 文件监听数量上限过低导致(Next.js/Turbopack 在 dev 模式需要文件监听,超过上限会报错)。 + +本项目在 `next.config.js` 中做了自动降级:当检测到上限偏低时,会启用 polling watcher 来避免启动失败(可能会略增 CPU 占用)。 + +你也可以手动启用 polling: + +```bash +npm run dev:poll +``` + +如需从根源修复(需要 sudo 权限),可提高系统上限(示例): + +```bash +sudo sysctl -w fs.inotify.max_user_watches=524288 +sudo sysctl -w fs.inotify.max_user_instances=1024 +``` diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..9fc721f --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,15 @@ +import tseslint from "typescript-eslint"; +import next from "@next/eslint-plugin-next"; + +export default [ + { ignores: [".next/**", "node_modules/**", "scripts/**", "next.config.js"] }, + ...tseslint.configs.recommended, + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: { "@next/next": next }, + rules: { + ...next.configs.recommended.rules, + ...next.configs["core-web-vitals"].rules, + }, + }, +]; diff --git a/next-env.d.ts b/next-env.d.ts index 40c3d68..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.js b/next.config.js index 546fef7..ca3c845 100644 --- a/next.config.js +++ b/next.config.js @@ -1,40 +1,7 @@ /** @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 index 87e9382..8486f0c 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,40 @@ { - "name": "iam-front", - "private": true, - "version": "0.1.0", - "scripts": { - "dev": "next dev -p 6020", - "build": "next build", - "start": "next start -p 6020", - "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", - "jose": "^5.9.6", - "lucide-react": "^0.468.0", - "next": "^14.2.25", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "redis": "^4.7.0", - "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 + "name": "iam-front", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev --turbopack -p 6020", + "build": "next build", + "start": "next start -p 6020", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-slot": "^1.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jose": "^5.9.6", + "lucide-react": "^0.468.0", + "next": "^16.1.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^20.17.16", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@next/eslint-plugin-next": "^16.1.6", + "autoprefixer": "^10.4.20", + "eslint": "^9.39.2", + "eslint-config-next": "^16.1.6", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.18", + "typescript": "^5.7.3", + "typescript-eslint": "^8.54.0" + } +} diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 2ce518b..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} - diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..14502dc --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +} diff --git a/src/app/globals.css b/src/app/globals.css index 4ec32f8..038d743 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,101 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + /* Custom Container Max Width */ + --container-max-width: 1200px; +} + +/* Fluid Typography & Base Styles */ +@layer base { + html { + /* Mobile base size */ + font-size: 14px; + + /* Desktop base size (md breakpoint approx 768px) */ + @media (min-width: 768px) { + font-size: 16px; + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + /* Fluid scaling using clamp/calc */ + font-size: clamp(1.25rem, 1rem + 1vw, 2.5rem); + line-height: 1.2; + } +} + +/* Utility for Grid System */ +@utility grid-responsive { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + + @media (min-width: 768px) { + grid-template-columns: repeat(8, minmax(0, 1fr)); + } + + @media (min-width: 1024px) { + grid-template-columns: repeat(12, minmax(0, 1fr)); + } +} + +/* Utility for Responsive Content Container */ +@utility container-responsive { + width: 100%; + margin-left: auto; + margin-right: auto; + max-width: 1200px; + + /* Mobile Padding */ + padding-left: 1rem; /* 16px */ + padding-right: 1rem; + + @media (min-width: 768px) { + /* Tablet/Desktop Padding */ + padding-left: 2.5rem; /* 40px */ + padding-right: 2.5rem; + } + + @media (min-width: 1024px) { + padding-left: 3.75rem; /* 60px */ + padding-right: 3.75rem; + } +} :root { --background: 0 0% 100%; @@ -27,4 +122,3 @@ body { background: hsl(var(--background)); color: hsl(var(--foreground)); } - diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c55d105..3d2b762 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,25 +1,29 @@ import { cookies } from "next/headers"; -import dynamic from "next/dynamic"; +import LoginFormCard from "@/components/login-form"; -const LoginFormNoSSR = dynamic(() => import("@/components/login-form"), { - ssr: false, -}); - -export default function LoginPage({ +export default async function LoginPage({ searchParams, }: { - searchParams: { tenantId?: string; callback?: string; clientId?: string }; + searchParams: Promise<{ + tenantId?: string; + callback?: string; + clientId?: string; + }>; }) { - const rememberedEmail = cookies().get("iam_remember_email")?.value ?? ""; + const cookieStore = await cookies(); + const rememberedEmail = cookieStore.get("iam_remember_email")?.value ?? ""; + const sp = await searchParams; return ( -
- +
+
+ +
); } diff --git a/src/components/auth/captcha-field.tsx b/src/components/auth/captcha-field.tsx new file mode 100644 index 0000000..3ff84e9 --- /dev/null +++ b/src/components/auth/captcha-field.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import Image from "next/image"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function CaptchaField(props: { + captcha: string; + onCaptchaChange: (next: string) => void; + captchaKey: string; + onRefresh: () => void; + disabled?: boolean; +}) { + return ( +
+ +
+ props.onCaptchaChange(e.target.value)} + disabled={props.disabled} + required + /> + +
+
+ ); +} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 0000000..c1cd9e8 --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,150 @@ +import * as React from "react" +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { CaptchaField } from "@/components/auth/captcha-field" + +export function LoginForm(props: { + clientId: string + tenantId: string + callback: string + initialEmail: string + prefillPassword?: string + disabled?: boolean + externalError?: string | null + onClearExternalError?: () => void + captcha: string + onCaptchaChange: (next: string) => void + captchaKey: string + onRefreshCaptcha: () => void +}) { + const [email, setEmail] = React.useState(props.initialEmail) + const [password, setPassword] = React.useState("") + const [rememberMe, setRememberMe] = React.useState(Boolean(props.initialEmail)) + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState(null) + + React.useEffect(() => { + setEmail(props.initialEmail) + setRememberMe(Boolean(props.initialEmail)) + }, [props.initialEmail]) + + React.useEffect(() => { + if (props.prefillPassword) { + setPassword(props.prefillPassword) + } + }, [props.prefillPassword]) + + const missingParams = !props.clientId || !props.tenantId || !props.callback + + async function onSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + props.onClearExternalError?.() + setSubmitting(true) + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: props.clientId, + tenantId: props.tenantId, + callback: props.callback, + email, + password, + captcha: props.captcha, + rememberMe, + }), + }) + const json = (await res.json()) as { redirectTo?: string; message?: string } + if (!res.ok || !json.redirectTo) { + throw new Error(json.message || "登录失败") + } + window.location.href = json.redirectTo + } catch (err) { + setError(err instanceof Error ? err.message : "登录失败") + props.onCaptchaChange("") + props.onRefreshCaptcha() + } finally { + setSubmitting(false) + } + } + + const disabled = Boolean(props.disabled) || submitting + + return ( +
+ {missingParams ? ( +
+ 缺少 clientId、tenantId 或 callback 参数,无法继续登录 +
+ ) : null} + {props.externalError ? ( +
{props.externalError}
+ ) : null} + {error ?
{error}
: null} + +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + disabled={disabled} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={disabled} + required + /> +
+ + + +
+ + + 忘记密码 + +
+ + + + ) +} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx new file mode 100644 index 0000000..30cd0f0 --- /dev/null +++ b/src/components/auth/register-form.tsx @@ -0,0 +1,155 @@ +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { CaptchaField } from "@/components/auth/captcha-field" + +export function RegisterForm(props: { + tenantId: string + disabled?: boolean + captcha: string + onCaptchaChange: (next: string) => void + captchaKey: string + onRefreshCaptcha: () => void + onRegisterSuccessPrefillLogin: (payload: { email: string; password: string }) => void + onLoginAfterRegister: (payload: { email: string; password: string }) => Promise +}) { + const [email, setEmail] = React.useState("") + const [password, setPassword] = React.useState("") + const [confirmPassword, setConfirmPassword] = React.useState("") + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState(null) + + const passwordMismatch = + confirmPassword.length > 0 && password !== confirmPassword + + const missingTenant = !props.tenantId + + async function onSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + const nextEmail = email.trim() + if (!props.tenantId) { + setError("缺少 tenantId 参数,无法注册") + return + } + if (!nextEmail || !password || !confirmPassword) { + setError("请填写邮箱与密码") + return + } + if (passwordMismatch) { + setError("两次输入的密码不一致") + return + } + if (!props.captcha.trim()) { + setError("请填写验证码") + return + } + + setSubmitting(true) + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tenantId: props.tenantId, + email: nextEmail, + password, + }), + }) + + const json = (await res.json()) as { message?: string; id?: string } + if (!res.ok) { + if (res.status === 409) { + throw new Error("该邮箱已注册,请直接登录") + } + throw new Error(json.message || "注册失败") + } + if (!json.id) { + throw new Error("注册失败") + } + + props.onRegisterSuccessPrefillLogin({ email: nextEmail, password }) + await props.onLoginAfterRegister({ email: nextEmail, password }) + } catch (err) { + setError(err instanceof Error ? err.message : "注册失败") + props.onCaptchaChange("") + props.onRefreshCaptcha() + } finally { + setSubmitting(false) + } + } + + const disabled = Boolean(props.disabled) || submitting + + return ( +
+ {missingTenant ? ( +
+ 缺少 tenantId 参数,无法继续注册 +
+ ) : null} + {error ?
{error}
: null} + +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + disabled={disabled} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={disabled} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={disabled} + required + /> + {passwordMismatch ? ( +
两次输入的密码不一致
+ ) : null} +
+ + + + + + ) +} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index 68ced6a..1cc87a2 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -21,7 +21,7 @@ const LoginFormCard = (props: { }) => { const [tab, setTab] = React.useState<"login" | "register">("login"); const [captcha, setCaptcha] = React.useState(""); - const [captchaKey, setCaptchaKey] = React.useState(() => String(Date.now())); + const [captchaKey, setCaptchaKey] = React.useState(""); const [loginExternalError, setLoginExternalError] = React.useState< string | null >(null); @@ -30,6 +30,10 @@ const LoginFormCard = (props: { password: string; } | null>(null); + React.useEffect(() => { + setCaptchaKey(String(Date.now())); + }, []); + function refreshCaptcha() { setCaptchaKey(String(Date.now())); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index bbe24cc..daea2e8 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-base md:text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 255575f..184fdaa 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/lib/redis.ts b/src/lib/redis.ts deleted file mode 100644 index b6346d2..0000000 --- a/src/lib/redis.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createClient } from "redis" - -import { mustGetEnv } from "@/lib/env" - -type AnyRedisClient = ReturnType - -let client: AnyRedisClient | null = null -let connecting: Promise | null = null - -export async function getRedis(): Promise { - if (client) return client - if (connecting) return connecting - - connecting = (async () => { - const c = createClient({ url: mustGetEnv("REDIS_URL") }) - await c.connect() - client = c - return c - })() - - return connecting -} diff --git a/src/middleware.ts b/src/proxy.ts similarity index 90% rename from src/middleware.ts rename to src/proxy.ts index d0baf0a..26bed14 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" -export function middleware(req: NextRequest) { +export function proxy(req: NextRequest) { if (process.env.NODE_ENV === "production") { const proto = req.headers.get("x-forwarded-proto") if (proto && proto !== "https") { @@ -15,4 +15,3 @@ export function middleware(req: NextRequest) { export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], } - diff --git a/tailwind.config.ts b/tailwind.config.ts deleted file mode 100644 index fc85e23..0000000 --- a/tailwind.config.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 index 48907b4..0dafad6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["dom", "dom.iterable", "es2022"], + "lib": [ + "dom", + "dom.iterable", + "es2022" + ], "allowJs": false, "skipLibCheck": true, "strict": true, @@ -11,15 +15,28 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, - "plugins": [{ "name": "next" }], + "plugins": [ + { + "name": "next" + } + ], "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": [ + "src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } -