Next.js + Mantine mock SSO service providing account/password, phone verify-code, and Feishu QR login. Issues a one-time sso_code that the external app exchanges for user info. Includes: - Feishu callback fix: guard against duplicate/concurrent /api/login submissions of the same single-use authorization code (feishuLogin once-guard + memoized onLoginSuccess to stop effect re-fire). - ADAPTATION_GUIDE.md: how to switch from the in-memory code store to the real backend auth/index + inner_get_user_info endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useForm } from '@mantine/form'
|
||
import {
|
||
Box,
|
||
TextInput,
|
||
PasswordInput,
|
||
Paper,
|
||
Text,
|
||
Button,
|
||
LoadingOverlay,
|
||
Stack,
|
||
Flex,
|
||
BackgroundImage,
|
||
Tabs,
|
||
FloatingIndicator,
|
||
Input,
|
||
useComputedColorScheme,
|
||
} from '@mantine/core'
|
||
import { showErrorMsg, showSuccessMsg } from '@/utils/message'
|
||
import { useLoginStore } from './store'
|
||
import DarkBgImage from './components/images/dark-login-bg.png'
|
||
import LightBgImage from './components/images/light-login-bg.png'
|
||
import loginBg2Dark from './components/images/login-bg2-dark.png'
|
||
import loginBg2Light from './components/images/login-bg2-light.png'
|
||
import LogoDigImage from './components/images/logo-dig.png'
|
||
import tabClasses from './Tab.module.css'
|
||
import { DigitalIcon } from './components/icons/digital'
|
||
import { encrypt, getQueryVariable, validatePhoneNumber } from './util'
|
||
import QrLogin from './components/login/qr-login'
|
||
|
||
const HeaderIcon = DigitalIcon
|
||
const headerTitle = '酷哇数字化平台'
|
||
const fixedTenant = 'coowa'
|
||
|
||
export default function LoginPage() {
|
||
const colorScheme = useComputedColorScheme()
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [addrMap, setAddrMap] = useState<Record<string, string>>({})
|
||
const [activeTab, setActiveTab] = useState<string | null>(() => {
|
||
// 飞书 OAuth 回调(URL 带 ?code=xxx)时自动切到飞书 tab
|
||
if (typeof window !== 'undefined' && getQueryVariable('code')) {
|
||
return 'feishu'
|
||
}
|
||
return 'account'
|
||
})
|
||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
|
||
const controlsRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
|
||
controlsRefs.current[val] = node
|
||
}
|
||
|
||
const { fingerprint, setFingerprint } = useLoginStore()
|
||
|
||
// SSO 跳转参数
|
||
const isClient = typeof window !== 'undefined'
|
||
const appId = isClient ? (getQueryVariable('app_id') as string) || '' : ''
|
||
const appUrl = isClient
|
||
? decodeURIComponent((getQueryVariable('app_url') as string) || '')
|
||
: ''
|
||
|
||
// 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数,存 localStorage
|
||
useEffect(() => {
|
||
if (appId && appUrl) {
|
||
localStorage.setItem(appId, appUrl) // app_id -> app_url
|
||
localStorage.setItem('sso_app_id', appId) // 记住当前 app_id
|
||
}
|
||
}, [appId, appUrl])
|
||
|
||
// 获取租户简称列表(与 uirefbase 一致)
|
||
useEffect(() => {
|
||
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/front/getAbbrList`, {
|
||
method: 'GET',
|
||
headers: { 'Content-type': 'application/json' },
|
||
})
|
||
.then((response) => response.json())
|
||
.then((res) => {
|
||
setAddrMap(res?.data?.keys || {})
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
// 生成浏览器指纹
|
||
useEffect(() => {
|
||
const ua = navigator.userAgent
|
||
const screenInfo = `${screen.width}x${screen.height}`
|
||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||
const raw = `${ua}|${screenInfo}|${timezone}`
|
||
let hash = 0
|
||
for (let i = 0; i < raw.length; i++) {
|
||
hash = (hash << 5) - hash + raw.charCodeAt(i)
|
||
hash |= 0
|
||
}
|
||
setFingerprint(Math.abs(hash).toString(36))
|
||
}, [setFingerprint])
|
||
|
||
// ============ 登录成功后跳转回外部应用 ============
|
||
const handleLoginSuccess = useCallback((res: any) => {
|
||
if (res?.error) {
|
||
setError(res.error || '登录失败,请重试')
|
||
return
|
||
}
|
||
|
||
if (!res?.user_info?.account) {
|
||
setError('登录失败:后端未返回有效用户信息')
|
||
return
|
||
}
|
||
|
||
// 从 localStorage 获取外部应用地址
|
||
const storedAppId = appId || localStorage.getItem('sso_app_id') || ''
|
||
const targetUrl =
|
||
appUrl || (storedAppId ? localStorage.getItem(storedAppId) || '' : '')
|
||
|
||
if (res?.code && targetUrl) {
|
||
const separator = targetUrl.includes('?') ? '&' : '?'
|
||
window.location.href = `${targetUrl}${separator}sso_code=${res.code}`
|
||
return
|
||
}
|
||
|
||
// 无外部应用地址 — sso-mock 不提供本地登录
|
||
setError('缺少外部应用回调地址(app_url),无法完成 SSO 跳转')
|
||
}, [appId, appUrl])
|
||
|
||
// ============ 统一调用 /api/login ============
|
||
// SSO 参数优先取 URL,其次取 localStorage(飞书 OAuth 回调后 URL 参数丢失)
|
||
const postLogin = async (payload: object) => {
|
||
const effectiveAppId = appId || localStorage.getItem('sso_app_id') || ''
|
||
const effectiveAppUrl =
|
||
appUrl || (effectiveAppId ? localStorage.getItem(effectiveAppId) || '' : '')
|
||
|
||
const res = await fetch(
|
||
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/login`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify(
|
||
encrypt(
|
||
JSON.stringify({
|
||
...payload,
|
||
fingerprint,
|
||
...(effectiveAppId ? { client_id: effectiveAppId } : {}),
|
||
...(effectiveAppUrl ? { client_redirect_uri: effectiveAppUrl } : {}),
|
||
})
|
||
)
|
||
),
|
||
}
|
||
)
|
||
.then((r) => r.text())
|
||
.then((t) => JSON.parse(t))
|
||
|
||
return res
|
||
}
|
||
|
||
// ============ Tab 1: 账户登录 ============
|
||
const accountForm = useForm({
|
||
initialValues: { username: '', password: '' },
|
||
validate: {
|
||
username: (v) => (!v ? '账户名不能为空' : null),
|
||
password: (v) => (!v ? '密码不能为空' : null),
|
||
},
|
||
validateInputOnChange: true,
|
||
validateInputOnBlur: true,
|
||
clearInputErrorOnChange: true,
|
||
})
|
||
|
||
const handleAccountSubmit = accountForm.onSubmit(async (values) => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const res = await postLogin({
|
||
account: values.username,
|
||
password: values.password,
|
||
abbr: fixedTenant,
|
||
type: 'account',
|
||
})
|
||
handleLoginSuccess(res)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '登录失败,请重试')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
})
|
||
|
||
// ============ Tab 2: 手机号登录 ============
|
||
const [codeTime, setCodeTime] = useState(0)
|
||
|
||
const handleCountDown = () => {
|
||
setCodeTime(60)
|
||
const timer = setInterval(() => {
|
||
setCodeTime((t) => {
|
||
if (t <= 1) {
|
||
clearInterval(timer)
|
||
return 0
|
||
}
|
||
return t - 1
|
||
})
|
||
}, 1000)
|
||
}
|
||
|
||
const handleGetCode = async () => {
|
||
if (!validatePhoneNumber(phoneForm.values.phone)) {
|
||
setError('请输入正确的手机号')
|
||
return
|
||
}
|
||
try {
|
||
const tntkey = addrMap?.[fixedTenant] || ''
|
||
if (!tntkey) {
|
||
showErrorMsg('请输入正确的租户')
|
||
return
|
||
}
|
||
const res: any = await fetch(
|
||
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/getVerifyCode`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: phoneForm.values.phone.startsWith('+86')
|
||
? phoneForm.values.phone
|
||
: `+86${phoneForm.values.phone}`,
|
||
tntkey,
|
||
}),
|
||
}
|
||
).then((r) => r.json())
|
||
|
||
if (res?.code === 200) {
|
||
showSuccessMsg('验证码已发送, 请注意查收')
|
||
handleCountDown()
|
||
} else {
|
||
showErrorMsg(res?.message || '验证码发送失败')
|
||
}
|
||
} catch {
|
||
showSuccessMsg('验证码已发送')
|
||
handleCountDown()
|
||
}
|
||
}
|
||
|
||
const phoneForm = useForm({
|
||
initialValues: { phone: '', code: '' },
|
||
validate: {
|
||
phone: (v) => {
|
||
if (!v) return '手机号不能为空'
|
||
if (v.length !== 11) return '手机号必须为11位'
|
||
return null
|
||
},
|
||
code: (v) => {
|
||
if (!v) return '验证码不能为空'
|
||
if (v.length !== 6) return '验证码必须为6位'
|
||
return null
|
||
},
|
||
},
|
||
validateInputOnChange: true,
|
||
validateInputOnBlur: true,
|
||
clearInputErrorOnChange: true,
|
||
})
|
||
|
||
const handlePhoneSubmit = phoneForm.onSubmit(async (values) => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const tntkey = addrMap?.[fixedTenant] || ''
|
||
if (!tntkey) {
|
||
setError('请输入正确的租户')
|
||
setLoading(false)
|
||
return
|
||
}
|
||
const res = await postLogin({
|
||
phone: values.phone.startsWith('+86')
|
||
? values.phone
|
||
: `+86${values.phone}`,
|
||
verify_code: values.code,
|
||
tntkey,
|
||
type: 'phone',
|
||
})
|
||
handleLoginSuccess(res)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '登录失败,请重试')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
})
|
||
|
||
// ============ 错误提示 ============
|
||
useEffect(() => {
|
||
if (error) showErrorMsg(error)
|
||
}, [error])
|
||
|
||
const bgImage = useMemo(
|
||
() => (colorScheme === 'dark' ? DarkBgImage : LightBgImage),
|
||
[colorScheme]
|
||
)
|
||
const stackBg2Image = useMemo(
|
||
() => (colorScheme === 'dark' ? loginBg2Dark : loginBg2Light),
|
||
[colorScheme]
|
||
)
|
||
|
||
// 飞书 OAuth 回调中:显示 loading,不渲染表单
|
||
const isFeishuCallback = isClient && !!getQueryVariable('code')
|
||
if (isFeishuCallback) {
|
||
return (
|
||
<Stack
|
||
align="center"
|
||
justify="center"
|
||
h={'100vh'}
|
||
w="100%"
|
||
bg={`url(${bgImage.src})`}
|
||
bgsz="cover"
|
||
bgp="center"
|
||
bgr="no-repeat">
|
||
<Paper withBorder shadow="md" radius="md" p="xl">
|
||
<Stack align="center" gap="md">
|
||
<LoadingOverlay visible />
|
||
<Text size="sm" c="dimmed">正在验证飞书登录...</Text>
|
||
</Stack>
|
||
</Paper>
|
||
{/* QrLogin 仍在后台执行 feishuLogin */}
|
||
<Box style={{ position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
|
||
<QrLogin onLoginSuccess={handleLoginSuccess} />
|
||
</Box>
|
||
</Stack>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Stack
|
||
align="center"
|
||
justify="center"
|
||
h={'100vh'}
|
||
w="100%"
|
||
bg={`url(${bgImage.src})`}
|
||
bgsz="cover"
|
||
bgp="center"
|
||
bgr="no-repeat">
|
||
<Paper
|
||
withBorder
|
||
shadow="md"
|
||
radius="md"
|
||
h={520}
|
||
maw={'100%'}
|
||
style={{ borderColor: 'rgba(255, 255, 255, 0.5)' }}>
|
||
<Flex w="100%" h={'100%'}>
|
||
{/* 左侧装饰区域 */}
|
||
<Stack
|
||
w={600}
|
||
p={32}
|
||
visibleFrom="md"
|
||
style={{ position: 'relative' }}>
|
||
<Box
|
||
w="100%"
|
||
h="100%"
|
||
style={{ position: 'absolute', top: 0, left: 0 }}>
|
||
<BackgroundImage src={stackBg2Image.src} w="100%" h="100%" />
|
||
</Box>
|
||
<Flex
|
||
justify="center"
|
||
align="center"
|
||
h="100%"
|
||
style={{ zIndex: 11 }}>
|
||
<BackgroundImage
|
||
src={LogoDigImage.src}
|
||
radius="xs"
|
||
w={432}
|
||
h={310}
|
||
visibleFrom="md"
|
||
/>
|
||
</Flex>
|
||
</Stack>
|
||
|
||
{/* 右侧登录表单 */}
|
||
<Stack h={'100%'} w={380} p={'54px 32px'} gap={0}>
|
||
<Flex mb={'2rem'} gap={8} justify="center" align={'center'}>
|
||
<HeaderIcon />
|
||
<Text size="xxl">{headerTitle}</Text>
|
||
</Flex>
|
||
|
||
{/* SSO 来源提示 */}
|
||
{appId && (
|
||
<Text size="xs" c="dimmed" ta="center" mb="sm">
|
||
正在为应用 {appId} 进行登录认证
|
||
</Text>
|
||
)}
|
||
|
||
<Tabs variant="none" value={activeTab} onChange={setActiveTab}>
|
||
<Tabs.List ref={setRootRef} className={tabClasses.list}>
|
||
<Tabs.Tab
|
||
value="account"
|
||
ref={setControlRef('account')}
|
||
className={tabClasses.tab}>
|
||
账户登录
|
||
</Tabs.Tab>
|
||
<Tabs.Tab
|
||
value="phone"
|
||
ref={setControlRef('phone')}
|
||
className={tabClasses.tab}>
|
||
手机号登录
|
||
</Tabs.Tab>
|
||
<Tabs.Tab
|
||
value="feishu"
|
||
ref={setControlRef('feishu')}
|
||
className={tabClasses.tab}>
|
||
飞书扫码
|
||
</Tabs.Tab>
|
||
<FloatingIndicator
|
||
target={activeTab ? controlsRefs.current[activeTab] : null}
|
||
parent={rootRef}
|
||
className={tabClasses.indicator}
|
||
/>
|
||
</Tabs.List>
|
||
|
||
{/* 账户登录 */}
|
||
<Tabs.Panel value="account">
|
||
<form
|
||
onSubmit={handleAccountSubmit}
|
||
style={{ marginTop: '30px' }}>
|
||
<LoadingOverlay visible={loading} />
|
||
<TextInput
|
||
withAsterisk
|
||
label="账户名"
|
||
placeholder="请输入账户名"
|
||
mt="md"
|
||
{...accountForm.getInputProps('username')}
|
||
/>
|
||
<PasswordInput
|
||
withAsterisk
|
||
label="密码"
|
||
placeholder="请输入密码"
|
||
mt="md"
|
||
{...accountForm.getInputProps('password')}
|
||
/>
|
||
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||
{loading ? '登录中...' : '登录'}
|
||
</Button>
|
||
</form>
|
||
</Tabs.Panel>
|
||
|
||
{/* 手机号登录 */}
|
||
<Tabs.Panel value="phone">
|
||
<form
|
||
onSubmit={handlePhoneSubmit}
|
||
style={{ marginTop: '30px' }}>
|
||
<LoadingOverlay visible={loading} />
|
||
<TextInput
|
||
withAsterisk
|
||
label="手机号"
|
||
placeholder="请输入手机号"
|
||
mt="md"
|
||
{...phoneForm.getInputProps('phone')}
|
||
/>
|
||
<Input.Wrapper
|
||
label="验证码"
|
||
withAsterisk
|
||
mt="md"
|
||
error={phoneForm.errors.code}>
|
||
<Flex align="center" gap={18}>
|
||
<Input
|
||
placeholder="请输入验证码"
|
||
style={{ flex: 1 }}
|
||
{...phoneForm.getInputProps('code')}
|
||
/>
|
||
<Button
|
||
style={{ flexShrink: 0 }}
|
||
type="button"
|
||
disabled={
|
||
!validatePhoneNumber(phoneForm.values.phone) ||
|
||
!!codeTime
|
||
}
|
||
onClick={handleGetCode}>
|
||
{codeTime !== 0 ? `${codeTime}秒后获取` : '获取验证码'}
|
||
</Button>
|
||
</Flex>
|
||
</Input.Wrapper>
|
||
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||
{loading ? '登录中...' : '登录'}
|
||
</Button>
|
||
</form>
|
||
</Tabs.Panel>
|
||
|
||
{/* 飞书扫码(真实二维码) */}
|
||
<Tabs.Panel value="feishu">
|
||
{isClient && (
|
||
<QrLogin onLoginSuccess={handleLoginSuccess} />
|
||
)}
|
||
</Tabs.Panel>
|
||
</Tabs>
|
||
</Stack>
|
||
</Flex>
|
||
</Paper>
|
||
</Stack>
|
||
)
|
||
}
|