209 lines
6.8 KiB
TypeScript
209 lines
6.8 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect } from 'react'
|
||
import {
|
||
AppShell,
|
||
Group,
|
||
Text,
|
||
Button,
|
||
Avatar,
|
||
Paper,
|
||
Stack,
|
||
Badge,
|
||
Code,
|
||
Divider,
|
||
SimpleGrid,
|
||
Menu,
|
||
} from '@mantine/core'
|
||
import {
|
||
IconLogout,
|
||
IconUser,
|
||
IconShield,
|
||
IconServer,
|
||
IconKey,
|
||
} from '@tabler/icons-react'
|
||
|
||
interface UserInfo {
|
||
id: string
|
||
account: string
|
||
name: string
|
||
phone: string
|
||
roles: string[]
|
||
}
|
||
|
||
// 构建跳转 sso-portal 的登录 URL(OAuth2:app_id 即 client_id)
|
||
const SSO_APP_ID = process.env.NEXT_PUBLIC_SSO_APP_ID || ''
|
||
const SSO_URL = (process.env.NEXT_PUBLIC_SSO_URL || 'http://localhost:5501').replace(/\/+$/, '')
|
||
const APP_URL = (process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:4000').replace(/\/+$/, '')
|
||
const SSO_STATE_STORAGE_KEY = 'sso_login_state'
|
||
|
||
function buildSsoLoginUrl(): string {
|
||
const bytes = new Uint8Array(32)
|
||
window.crypto.getRandomValues(bytes)
|
||
const state = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||
const loginUrl = new URL(`${SSO_URL}/login`)
|
||
|
||
window.sessionStorage.setItem(SSO_STATE_STORAGE_KEY, state)
|
||
loginUrl.searchParams.set('app_id', SSO_APP_ID)
|
||
loginUrl.searchParams.set('app_url', `${APP_URL}/callback`)
|
||
loginUrl.searchParams.set('state', state)
|
||
return loginUrl.toString()
|
||
}
|
||
|
||
export default function DashboardPage({
|
||
user,
|
||
authenticated,
|
||
}: {
|
||
user: UserInfo | null
|
||
// 登录态以 token 为准(拿到 access_token 即算成功);user 可能为 null
|
||
authenticated: boolean
|
||
}) {
|
||
// 未登录(无 token)-> 跳转 SSO
|
||
useEffect(() => {
|
||
if (!authenticated) {
|
||
window.location.href = buildSsoLoginUrl()
|
||
}
|
||
}, [authenticated])
|
||
|
||
if (!authenticated) {
|
||
return (
|
||
<Stack align="center" justify="center" h="100vh">
|
||
<Text size="lg" c="dimmed">正在跳转到 SSO 登录页面...</Text>
|
||
</Stack>
|
||
)
|
||
}
|
||
|
||
const handleLogout = async () => {
|
||
await fetch('/api/logout', { method: 'POST' })
|
||
// 跳到 SSO 登录页;每次登录都生成新的 state 防止登录 CSRF。
|
||
window.location.href = buildSsoLoginUrl()
|
||
}
|
||
|
||
return (
|
||
<AppShell header={{ height: 60 }} padding="md">
|
||
<AppShell.Header>
|
||
<Group h="100%" px="md" justify="space-between">
|
||
<Group gap="sm">
|
||
<IconServer size={24} color="#0080FF" />
|
||
<Text fw={700} size="lg">外部应用 Demo</Text>
|
||
<Badge color="green" variant="light">已登录</Badge>
|
||
</Group>
|
||
<Menu shadow="md" width={220}>
|
||
<Menu.Target>
|
||
<Group gap="sm" style={{ cursor: 'pointer' }}>
|
||
<Avatar color="brand" radius="xl" size="sm">
|
||
{user?.name?.[0] || 'U'}
|
||
</Avatar>
|
||
<Text size="sm" fw={500}>{user?.name || '已登录用户'}</Text>
|
||
</Group>
|
||
</Menu.Target>
|
||
<Menu.Dropdown>
|
||
<Menu.Item leftSection={<IconUser size={14} />}>
|
||
{user?.account || '(未获取到账号)'}
|
||
</Menu.Item>
|
||
<Menu.Divider />
|
||
<Menu.Item leftSection={<IconLogout size={14} />} color="red" onClick={handleLogout}>
|
||
退出登录
|
||
</Menu.Item>
|
||
</Menu.Dropdown>
|
||
</Menu>
|
||
</Group>
|
||
</AppShell.Header>
|
||
|
||
<AppShell.Main>
|
||
<Stack maw={800} mx="auto" pt="xl">
|
||
<Text size="xl" fw={700}>登录成功!</Text>
|
||
<Text c="dimmed">
|
||
您已通过 SSO 认证登录到此应用(OAuth2 授权码流程,已换取 access_token)。
|
||
</Text>
|
||
|
||
<SimpleGrid cols={{ base: 1, sm: 2 }} mt="md">
|
||
<Paper withBorder p="lg" radius="md">
|
||
<Group gap="sm" mb="md">
|
||
<IconUser size={20} color="#0080FF" />
|
||
<Text fw={600}>用户信息</Text>
|
||
</Group>
|
||
{user ? (
|
||
<Stack gap="xs">
|
||
<Group>
|
||
<Text size="sm" c="dimmed" w={80}>ID</Text>
|
||
<Code>{user.id}</Code>
|
||
</Group>
|
||
<Group>
|
||
<Text size="sm" c="dimmed" w={80}>账号</Text>
|
||
<Text size="sm">{user.account}</Text>
|
||
</Group>
|
||
<Group>
|
||
<Text size="sm" c="dimmed" w={80}>姓名</Text>
|
||
<Text size="sm">{user.name}</Text>
|
||
</Group>
|
||
<Group>
|
||
<Text size="sm" c="dimmed" w={80}>手机</Text>
|
||
<Text size="sm">{user.phone}</Text>
|
||
</Group>
|
||
</Stack>
|
||
) : (
|
||
<Text size="sm" c="dimmed">
|
||
后端 access_token 接口未返回用户详情,暂无用户信息。
|
||
</Text>
|
||
)}
|
||
</Paper>
|
||
|
||
<Paper withBorder p="lg" radius="md">
|
||
<Group gap="sm" mb="md">
|
||
<IconShield size={20} color="#20C997" />
|
||
<Text fw={600}>角色权限</Text>
|
||
</Group>
|
||
{user?.roles?.length ? (
|
||
<Group gap="xs">
|
||
{user.roles.map((role: string) => (
|
||
<Badge key={role} variant="light">{role}</Badge>
|
||
))}
|
||
</Group>
|
||
) : (
|
||
<Text size="sm" c="dimmed">暂无角色信息</Text>
|
||
)}
|
||
</Paper>
|
||
</SimpleGrid>
|
||
|
||
<Divider my="md" />
|
||
|
||
<Paper withBorder p="lg" radius="md">
|
||
<Group gap="sm" mb="md">
|
||
<IconKey size={20} color="#F59F00" />
|
||
<Text fw={600}>SSO 流程说明</Text>
|
||
</Group>
|
||
<Stack gap="sm">
|
||
<Text size="sm">
|
||
<strong>1.</strong> 您打开此应用(<Code>http://localhost:4000</Code>)
|
||
</Text>
|
||
<Text size="sm">
|
||
<strong>2.</strong> 检测到未登录,自动跳转到 SSO(<Code>http://localhost:5501/login</Code>)
|
||
</Text>
|
||
<Text size="sm">
|
||
<strong>3.</strong> 在 SSO 页面完成登录,SSO 返回 <Code>sso_code</Code>
|
||
</Text>
|
||
<Text size="sm">
|
||
<strong>4.</strong> 此应用用 <Code>sso_code</Code> 调用后端 <Code>/api/v1/basis/sso/access_token</Code> 换取 access_token
|
||
</Text>
|
||
<Text size="sm">
|
||
<strong>5.</strong> access_token 存入 httpOnly Cookie,即算登录成功;用户详情需后端 userinfo 接口提供
|
||
</Text>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
<Group justify="center" mt="xl">
|
||
<Button
|
||
color="red"
|
||
variant="light"
|
||
leftSection={<IconLogout size={16} />}
|
||
onClick={handleLogout}>
|
||
退出登录(跳转回 SSO)
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</AppShell.Main>
|
||
</AppShell>
|
||
)
|
||
}
|