提供北京大学统一认证支持,仅需配置环境变量
This commit is contained in:
243
src/app/(auth)/login/login-form.tsx
Normal file
243
src/app/(auth)/login/login-form.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { AlertTriangle, ExternalLink } from "lucide-react"
|
||||
import type { IaaaClientConfig } from "./page"
|
||||
|
||||
// 登录表单验证 schema
|
||||
const loginSchema = z.object({
|
||||
id: z.string().min(1, "请输入用户ID"),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
})
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>
|
||||
|
||||
/** IAAA 回调错误码到用户提示的映射 */
|
||||
const IAAA_ERROR_MESSAGES: Record<string, string> = {
|
||||
iaaa_no_token: '未收到认证票据,请重试',
|
||||
iaaa_validate_failed: '统一认证验证失败,请重试',
|
||||
iaaa_user_not_found: '系统中不存在与您北大账号对应的用户,请联系管理员',
|
||||
iaaa_server_error: '服务器内部错误,请稍后重试',
|
||||
}
|
||||
|
||||
/** 创建隐藏表单 POST 跳转到 IAAA 统一认证页面 */
|
||||
function redirectToIaaa(appId: string, callbackPath: string) {
|
||||
const form = document.createElement('form')
|
||||
form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp'
|
||||
form.method = 'POST'
|
||||
form.style.display = 'none'
|
||||
|
||||
function addField(name: string, value: string) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = name
|
||||
input.value = value
|
||||
form.appendChild(input)
|
||||
}
|
||||
|
||||
const baseUrl = `${location.protocol}//${location.host}`
|
||||
addField('appID', appId)
|
||||
addField('redirectUrl', `${baseUrl}${callbackPath}`)
|
||||
// 保留应用自有登录入口,以便用户可以选择回到密码登录
|
||||
addField('redirectLogonUrl', `${baseUrl}/login`)
|
||||
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
}
|
||||
|
||||
export function LoginForm({ iaaaConfig }: { iaaaConfig: IaaaClientConfig }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const autoRedirected = useRef(false)
|
||||
const [showDevHint, setShowDevHint] = useState(false)
|
||||
|
||||
const form = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
id: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
// IAAA 回调错误提示(使用 iaaa_error 参数名,避免与 next-auth 的 error 参数冲突)
|
||||
const iaaaErrorCode = searchParams.get('iaaa_error')
|
||||
const iaaaError = iaaaErrorCode ? IAAA_ERROR_MESSAGES[iaaaErrorCode] : null
|
||||
|
||||
// 自动跳转:配置开启且 IAAA 已启用时,进入登录页自动跳转到统一认证
|
||||
// 仅在非错误回调场景下触发(避免验证失败后死循环)
|
||||
useEffect(() => {
|
||||
if (
|
||||
iaaaConfig.enabled &&
|
||||
iaaaConfig.autoRedirect &&
|
||||
!iaaaErrorCode &&
|
||||
!autoRedirected.current
|
||||
) {
|
||||
autoRedirected.current = true
|
||||
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
|
||||
}
|
||||
}, [iaaaConfig, iaaaErrorCode])
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
id: data.id,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "用户ID或密码错误"
|
||||
})
|
||||
} else if (result?.ok) {
|
||||
router.push("/")
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "登录失败,请重试"
|
||||
})
|
||||
console.error("Login error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// IAAA 是否可见:已启用,或开发模式下需要显示配置提醒
|
||||
const showIaaa = iaaaConfig.enabled || iaaaConfig.showInDev
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* IAAA 回调错误提示 */}
|
||||
{iaaaError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{iaaaError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 密码登录表单 */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入用户ID"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<div className="text-sm text-red-600 text-center">
|
||||
{form.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* IAAA 统一认证登录 */}
|
||||
{showIaaa && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (iaaaConfig.enabled) {
|
||||
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
|
||||
} else {
|
||||
setShowDevHint(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
北京大学统一认证登录
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 开发模式下未配置 IAAA 的提示对话框(仅开发模式渲染,避免泄露内部架构) */}
|
||||
{iaaaConfig.showInDev && <Dialog open={showDevHint} onOpenChange={setShowDevHint}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>北大统一认证未配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
IAAA 统一认证功能需要配置以下环境变量才能使用:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="font-medium">1. 在 IAAA 平台注册应用时填写:</p>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
|
||||
<div>系统URL:<span className="text-muted-foreground">https://你的域名</span></div>
|
||||
<div>服务器IP:<span className="text-muted-foreground">应用服务器的出口IP</span></div>
|
||||
<div>回调URL:<span className="text-muted-foreground">https://你的域名/api/auth/iaaa/callback</span></div>
|
||||
</div>
|
||||
<p className="font-medium">2. 将获取的凭据写入 <code className="bg-muted px-1 py-0.5 rounded">.env</code> 文件:</p>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
|
||||
<div>IAAA_APP_ID=你的应用ID</div>
|
||||
<div>IAAA_KEY=你的密钥</div>
|
||||
<div className="text-muted-foreground"># 可选:进入登录页自动跳转统一认证</div>
|
||||
<div>IAAA_AUTO_REDIRECT=true</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
配置完成后重启开发服务器即可生效。
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +1,23 @@
|
||||
'use client'
|
||||
import { getIaaaClientConfig } from "@/server/service/iaaa"
|
||||
import { LoginForm } from "./login-form"
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
|
||||
// 登录表单验证 schema
|
||||
const loginSchema = z.object({
|
||||
id: z.string().min(1, "请输入用户ID"),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
})
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>
|
||||
export interface IaaaClientConfig {
|
||||
enabled: boolean
|
||||
appId: string
|
||||
callbackPath: string
|
||||
autoRedirect: boolean
|
||||
/** 开发模式下未配置 IAAA,需要显示配置提醒 */
|
||||
showInDev: boolean
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const config = getIaaaClientConfig()
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
const form = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
id: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
id: data.id,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "用户ID或密码错误"
|
||||
})
|
||||
} else if (result?.ok) {
|
||||
// 登录成功,重定向到首页
|
||||
router.push("/")
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "登录失败,请重试"
|
||||
})
|
||||
console.error("Login error:", error)
|
||||
}
|
||||
const iaaaConfig: IaaaClientConfig = {
|
||||
...config,
|
||||
showInDev: isDev && !config.enabled,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入用户ID"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<div className="text-sm text-red-600 text-center">
|
||||
{form.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <LoginForm iaaaConfig={iaaaConfig} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user