fix(css): fix version
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": [
|
|
||||||
"next/core-web-vitals",
|
|
||||||
"next/typescript"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
42
README.md
42
README.md
@@ -1,6 +1,6 @@
|
|||||||
# iam-front
|
# 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
|
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
|
||||||
|
<LoginFormCard
|
||||||
|
clientId={searchParams.clientId ?? ""}
|
||||||
|
tenantId={searchParams.tenantId ?? ""}
|
||||||
|
callback={searchParams.callback ?? ""}
|
||||||
|
initialEmail={rememberedEmail}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|||||||
15
eslint.config.mjs
Normal file
15
eslint.config.mjs
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
3
next-env.d.ts
vendored
3
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// 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.
|
||||||
|
|||||||
@@ -1,40 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
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;
|
module.exports = nextConfig;
|
||||||
|
|
||||||
|
|||||||
72
package.json
72
package.json
@@ -1,36 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "iam-front",
|
"name": "iam-front",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 6020",
|
"dev": "next dev --turbopack -p 6020",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 6020",
|
"start": "next start -p 6020",
|
||||||
"lint": "next lint"
|
"lint": "eslint .",
|
||||||
},
|
"typecheck": "tsc --noEmit"
|
||||||
"dependencies": {
|
},
|
||||||
"@radix-ui/react-checkbox": "^1.3.0",
|
"dependencies": {
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-checkbox": "^1.3.0",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"clsx": "^2.1.1",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"jose": "^5.9.6",
|
"class-variance-authority": "^0.7.1",
|
||||||
"lucide-react": "^0.468.0",
|
"clsx": "^2.1.1",
|
||||||
"next": "^14.2.25",
|
"jose": "^5.9.6",
|
||||||
"react": "^18.3.1",
|
"lucide-react": "^0.468.0",
|
||||||
"react-dom": "^18.3.1",
|
"next": "^16.1.6",
|
||||||
"redis": "^4.7.0",
|
"react": "^19.2.4",
|
||||||
"tailwind-merge": "^2.5.4"
|
"react-dom": "^19.2.4",
|
||||||
},
|
"tailwind-merge": "^3.4.0"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/node": "^20.17.16",
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/node": "^20.17.16",
|
||||||
"autoprefixer": "^10.4.20",
|
"@types/react": "^19.2.0",
|
||||||
"eslint": "^8.57.1",
|
"@types/react-dom": "^19.2.0",
|
||||||
"eslint-config-next": "^14.2.25",
|
"@next/eslint-plugin-next": "^16.1.6",
|
||||||
"postcss": "^8.5.1",
|
"autoprefixer": "^10.4.20",
|
||||||
"tailwindcss": "^3.4.17",
|
"eslint": "^9.39.2",
|
||||||
"typescript": "^5.7.3"
|
"eslint-config-next": "^16.1.6",
|
||||||
}
|
"postcss": "^8.5.1",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.54.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
6
postcss.config.mjs
Normal file
6
postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,101 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@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 {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
@@ -27,4 +122,3 @@ body {
|
|||||||
background: hsl(var(--background));
|
background: hsl(var(--background));
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import dynamic from "next/dynamic";
|
import LoginFormCard from "@/components/login-form";
|
||||||
|
|
||||||
const LoginFormNoSSR = dynamic(() => import("@/components/login-form"), {
|
export default async function LoginPage({
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function LoginPage({
|
|
||||||
searchParams,
|
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 (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center p-6">
|
<main className="min-h-screen flex items-center justify-center container-responsive">
|
||||||
<LoginFormNoSSR
|
<div className="w-full max-w-md mx-auto">
|
||||||
clientId={searchParams.clientId ?? ""}
|
<LoginFormCard
|
||||||
tenantId={searchParams.tenantId ?? ""}
|
clientId={sp.clientId ?? ""}
|
||||||
callback={searchParams.callback ?? ""}
|
tenantId={sp.tenantId ?? ""}
|
||||||
initialEmail={rememberedEmail}
|
callback={sp.callback ?? ""}
|
||||||
/>
|
initialEmail={rememberedEmail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/components/auth/captcha-field.tsx
Normal file
50
src/components/auth/captcha-field.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="captcha">验证码</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="captcha"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={props.captcha}
|
||||||
|
onChange={(e) => props.onCaptchaChange(e.target.value)}
|
||||||
|
disabled={props.disabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded-md border border-input bg-background px-2"
|
||||||
|
onClick={props.onRefresh}
|
||||||
|
aria-label="刷新验证码"
|
||||||
|
disabled={props.disabled}
|
||||||
|
>
|
||||||
|
{props.captchaKey ? (
|
||||||
|
<Image
|
||||||
|
alt="captcha"
|
||||||
|
src={`/api/captcha?key=${encodeURIComponent(props.captchaKey)}`}
|
||||||
|
width={120}
|
||||||
|
height={40}
|
||||||
|
className="w-[120px] h-[40px] object-contain"
|
||||||
|
unoptimized
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-[120px] h-[40px] bg-muted/50 animate-pulse rounded-md" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/components/auth/login-form.tsx
Normal file
150
src/components/auth/login-form.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
{missingParams ? (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
缺少 clientId、tenantId 或 callback 参数,无法继续登录
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{props.externalError ? (
|
||||||
|
<div className="text-sm text-destructive">{props.externalError}</div>
|
||||||
|
) : null}
|
||||||
|
{error ? <div className="text-sm text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">用户名</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CaptchaField
|
||||||
|
captcha={props.captcha}
|
||||||
|
onCaptchaChange={props.onCaptchaChange}
|
||||||
|
captchaKey={props.captchaKey}
|
||||||
|
onRefresh={props.onRefreshCaptcha}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={rememberMe}
|
||||||
|
onCheckedChange={(v) => setRememberMe(Boolean(v))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
记住我
|
||||||
|
</label>
|
||||||
|
<Link
|
||||||
|
className="text-sm underline underline-offset-4"
|
||||||
|
href={`/forgot-password?tenantId=${encodeURIComponent(props.tenantId)}`}
|
||||||
|
>
|
||||||
|
忘记密码
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={disabled || missingParams}
|
||||||
|
>
|
||||||
|
{submitting ? "登录中..." : "登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
src/components/auth/register-form.tsx
Normal file
155
src/components/auth/register-form.tsx
Normal file
@@ -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<void>
|
||||||
|
}) {
|
||||||
|
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<string | null>(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 (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
{missingTenant ? (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
缺少 tenantId 参数,无法继续注册
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? <div className="text-sm text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="registerEmail">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="registerEmail"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="registerPassword">密码</Label>
|
||||||
|
<Input
|
||||||
|
id="registerPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="registerConfirmPassword">确认密码</Label>
|
||||||
|
<Input
|
||||||
|
id="registerConfirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{passwordMismatch ? (
|
||||||
|
<div className="text-sm text-destructive">两次输入的密码不一致</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CaptchaField
|
||||||
|
captcha={props.captcha}
|
||||||
|
onCaptchaChange={props.onCaptchaChange}
|
||||||
|
captchaKey={props.captchaKey}
|
||||||
|
onRefresh={props.onRefreshCaptcha}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={disabled || passwordMismatch || missingTenant}
|
||||||
|
>
|
||||||
|
{submitting ? "注册中..." : "注册并登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ const LoginFormCard = (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const [tab, setTab] = React.useState<"login" | "register">("login");
|
const [tab, setTab] = React.useState<"login" | "register">("login");
|
||||||
const [captcha, setCaptcha] = React.useState("");
|
const [captcha, setCaptcha] = React.useState("");
|
||||||
const [captchaKey, setCaptchaKey] = React.useState(() => String(Date.now()));
|
const [captchaKey, setCaptchaKey] = React.useState("");
|
||||||
const [loginExternalError, setLoginExternalError] = React.useState<
|
const [loginExternalError, setLoginExternalError] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -30,6 +30,10 @@ const LoginFormCard = (props: {
|
|||||||
password: string;
|
password: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setCaptchaKey(String(Date.now()));
|
||||||
|
}, []);
|
||||||
|
|
||||||
function refreshCaptcha() {
|
function refreshCaptcha() {
|
||||||
setCaptchaKey(String(Date.now()));
|
setCaptchaKey(String(Date.now()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
50
src/components/ui/tabs.tsx
Normal file
50
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 ring-offset-background focus-visible:outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { createClient } from "redis"
|
|
||||||
|
|
||||||
import { mustGetEnv } from "@/lib/env"
|
|
||||||
|
|
||||||
type AnyRedisClient = ReturnType<typeof createClient>
|
|
||||||
|
|
||||||
let client: AnyRedisClient | null = null
|
|
||||||
let connecting: Promise<AnyRedisClient> | null = null
|
|
||||||
|
|
||||||
export async function getRedis(): Promise<AnyRedisClient> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export function proxy(req: NextRequest) {
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
const proto = req.headers.get("x-forwarded-proto")
|
const proto = req.headers.get("x-forwarded-proto")
|
||||||
if (proto && proto !== "https") {
|
if (proto && proto !== "https") {
|
||||||
@@ -15,4 +15,3 @@ export function middleware(req: NextRequest) {
|
|||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "es2022"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es2022"
|
||||||
|
],
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,15 +15,28 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user