diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..6b10a5b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ] +} diff --git a/src/app/api/auth/login-token/route.ts b/src/app/api/auth/login-token/route.ts new file mode 100644 index 0000000..bf0bee4 --- /dev/null +++ b/src/app/api/auth/login-token/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { IamApiError, iamLogin } from "@/lib/iam"; +import { verifyCaptchaCookie } from "@/lib/captcha"; + +export const runtime = "nodejs"; + +type Body = { + tenantId?: string; + email?: string; + password?: string; + captcha?: string; +}; + +export async function POST(req: NextRequest) { + const body = (await req.json()) as Body; + + const tenantId = body.tenantId?.trim() ?? ""; + const email = body.email?.trim() ?? ""; + const password = body.password ?? ""; + const captcha = body.captcha?.trim() ?? ""; + + if (!tenantId || !email || !password) { + return NextResponse.json( + { message: "missing required fields" }, + { status: 400 }, + ); + } + + const captchaCookie = req.cookies.get("iam_captcha")?.value; + if (!verifyCaptchaCookie(captchaCookie, captcha)) { + return NextResponse.json({ message: "invalid captcha" }, { status: 400 }); + } + + try { + const tokens = await iamLogin({ tenantId, email, password }); + return NextResponse.json(tokens, { + status: 200, + headers: { "Cache-Control": "no-store" }, + }); + } catch (err) { + const status = err instanceof IamApiError ? err.status : 500; + const message = err instanceof Error ? err.message : "登录失败"; + return NextResponse.json({ message }, { status }); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..84104cb --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { IamApiError, iamRegister } from "@/lib/iam"; + +export const runtime = "nodejs"; + +type Body = { + tenantId?: string; + email?: string; + password?: string; +}; + +export async function POST(req: NextRequest) { + const body = (await req.json()) as Body; + + const tenantId = body.tenantId?.trim() ?? ""; + const email = body.email?.trim() ?? ""; + const password = body.password ?? ""; + + if (!tenantId || !email || !password) { + return NextResponse.json( + { message: "missing required fields" }, + { status: 400 }, + ); + } + + try { + const user = await iamRegister({ tenantId, email, password }); + return NextResponse.json( + { id: user.id, email: user.email }, + { status: 201, headers: { "Cache-Control": "no-store" } }, + ); + } catch (err) { + const status = err instanceof IamApiError ? err.status : 500; + const message = err instanceof Error ? err.message : "注册失败"; + return NextResponse.json({ message }, { status }); + } +} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index b8e21e3..68ced6a 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -1,78 +1,104 @@ "use client"; import * as React from "react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; +import { LoginForm } from "@/components/auth/login-form"; +import { RegisterForm } from "@/components/auth/register-form"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -const LoginForm = (props: { +const LoginFormCard = (props: { clientId: string; tenantId: string; callback: string; initialEmail: string; }) => { - const [email, setEmail] = React.useState(props.initialEmail); - const [password, setPassword] = React.useState(""); + const [tab, setTab] = React.useState<"login" | "register">("login"); const [captcha, setCaptcha] = React.useState(""); - const [rememberMe, setRememberMe] = React.useState( - Boolean(props.initialEmail), - ); - const [submitting, setSubmitting] = React.useState(false); - const [error, setError] = React.useState(null); const [captchaKey, setCaptchaKey] = React.useState(() => String(Date.now())); + const [loginExternalError, setLoginExternalError] = React.useState< + string | null + >(null); + const [loginPrefill, setLoginPrefill] = React.useState<{ + email: string; + password: string; + } | null>(null); - const clientId = props.clientId; - const tenantId = props.tenantId; - const callback = props.callback; - - async function onSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setSubmitting(true); - try { - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId, - tenantId, - callback, - email, - password, - captcha, - rememberMe, - }), - }); - const json = (await res.json()) as { - redirectTo?: string; - message?: string; - }; - if (!res.ok || !json.redirectTo) { - throw new Error(json.message || "登录失败"); - } - console.log(`json.redirectTo`, json.redirectTo); - window.location.href = json.redirectTo; - } catch (err) { - setError(err instanceof Error ? err.message : "登录失败"); - setCaptcha(""); - setCaptchaKey(String(Date.now())); - } finally { - setSubmitting(false); - } + function refreshCaptcha() { + setCaptchaKey(String(Date.now())); } - const missingParams = !clientId || !tenantId || !callback; + async function loginTokenAndStore(payload: { + email: string; + password: string; + }) { + const tokenRes = await fetch("/api/auth/login-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tenantId: props.tenantId, + email: payload.email, + password: payload.password, + captcha, + }), + }); + const tokenJson = (await tokenRes.json()) as { + access_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + message?: string; + }; + if (!tokenRes.ok || !tokenJson.access_token || !tokenJson.refresh_token) { + throw new Error(tokenJson.message || "登录失败"); + } + + try { + sessionStorage.setItem("iam_access_token", tokenJson.access_token); + sessionStorage.setItem("iam_refresh_token", tokenJson.refresh_token); + if (tokenJson.token_type) { + sessionStorage.setItem("iam_token_type", tokenJson.token_type); + } + if (typeof tokenJson.expires_in === "number") { + sessionStorage.setItem("iam_expires_in", String(tokenJson.expires_in)); + } + sessionStorage.setItem("iam_token_issued_at", String(Date.now())); + } catch {} + } + + async function loginAndRedirect(payload: { + email: string; + password: string; + }) { + await loginTokenAndStore(payload); + 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: payload.email, + password: payload.password, + captcha, + rememberMe: true, + }), + }); + 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; + } return ( @@ -80,98 +106,62 @@ const LoginForm = (props: { 统一登录 使用 IAM 账号登录以访问业务系统 -
- - {missingParams ? ( -
- 缺少 clientId、tenantId 或 callback 参数,无法继续登录 -
- ) : null} - {error ? ( -
{error}
- ) : null} + + setTab(v as "login" | "register")} + className="w-full" + > + + 登录 + 注册 + -
- - setEmail(e.target.value)} - placeholder="user@example.com" - disabled={submitting} - required + + setLoginExternalError(null)} + captcha={captcha} + onCaptchaChange={setCaptcha} + captchaKey={captchaKey} + onRefreshCaptcha={refreshCaptcha} /> -
+ -
- - setPassword(e.target.value)} - disabled={submitting} - required + + { + setLoginPrefill(p); + }} + onLoginAfterRegister={async (p) => { + try { + await loginAndRedirect(p); + } catch (err) { + const msg = err instanceof Error ? err.message : "登录失败"; + setLoginExternalError( + `注册成功,但登录失败:${msg}。请在“登录”页修正验证码后继续。`, + ); + setTab("login"); + setCaptcha(""); + refreshCaptcha(); + } + }} /> -
- -
- -
- setCaptcha(e.target.value)} - disabled={submitting} - required - /> - -
-
- -
- - - 忘记密码 - -
-
- - - - + + +
); }; -export default LoginForm; +export default LoginFormCard; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index ef93ef0..255575f 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,8 +1,8 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -export interface InputProps extends React.InputHTMLAttributes {} +export type InputProps = React.InputHTMLAttributes; const Input = React.forwardRef( ({ className, type, ...props }, ref) => { @@ -16,10 +16,9 @@ const Input = React.forwardRef( ref={ref} {...props} /> - ) + ); }, -) -Input.displayName = "Input" - -export { Input } +); +Input.displayName = "Input"; +export { Input }; diff --git a/src/lib/iam.ts b/src/lib/iam.ts index 6f89f36..f124970 100644 --- a/src/lib/iam.ts +++ b/src/lib/iam.ts @@ -8,6 +8,16 @@ type AppResponse = { details?: unknown; }; +export class IamApiError extends Error { + status: number; + code?: number; + constructor(message: string, status: number, code?: number) { + super(message); + this.status = status; + this.code = code; + } +} + export type IamLoginResponse = { access_token: string; refresh_token: string; @@ -20,6 +30,11 @@ export type IamLoginCodeResponse = { expiresAt: number; }; +export type IamRegisterResponse = { + id: string; + email: string; +}; + export async function iamLogin(params: { tenantId: string; email: string; @@ -39,7 +54,31 @@ export async function iamLogin(params: { const body = (await res.json()) as AppResponse; if (!res.ok || body.code !== 0) { - throw new Error(body.message || "Login failed"); + throw new IamApiError(body.message || "Login failed", res.status, body.code); + } + return body.data; +} + +export async function iamRegister(params: { + tenantId: string; + email: string; + password: string; +}): Promise { + const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, ""); + const url = `${base}/api/v1/auth/register`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Tenant-ID": params.tenantId, + }, + body: JSON.stringify({ email: params.email, password: params.password }), + cache: "no-store", + }); + + const body = (await res.json()) as AppResponse; + if (!res.ok || body.code !== 0) { + throw new IamApiError(body.message || "Register failed", res.status, body.code); } return body.data; } @@ -71,7 +110,7 @@ export async function iamLoginCode(params: { const body = (await res.json()) as AppResponse; if (!res.ok || body.code !== 0) { - throw new Error(body.message || "Login failed"); + throw new IamApiError(body.message || "Login failed", res.status, body.code); } return body.data; }