Files
external-app/app/DashboardPage.tsx
2026-07-14 15:21:28 +08:00

209 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 的登录 URLOAuth2app_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>
)
}