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
|
||||
|
||||
统一认证前端(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
|
||||
<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/image-types/global" />
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
74
package.json
74
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"
|
||||
}
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<LoginFormNoSSR
|
||||
clientId={searchParams.clientId ?? ""}
|
||||
tenantId={searchParams.tenantId ?? ""}
|
||||
callback={searchParams.callback ?? ""}
|
||||
initialEmail={rememberedEmail}
|
||||
/>
|
||||
<main className="min-h-screen flex items-center justify-center container-responsive">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<LoginFormCard
|
||||
clientId={sp.clientId ?? ""}
|
||||
tenantId={sp.tenantId ?? ""}
|
||||
callback={sp.callback ?? ""}
|
||||
initialEmail={rememberedEmail}
|
||||
/>
|
||||
</div>
|
||||
</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 [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()));
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
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,
|
||||
)}
|
||||
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"
|
||||
|
||||
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).*)"],
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user