feat: Enhance homepage and OAuth authorization with improved design and functionality

- Redesign homepage with modern layout, feature cards, and interactive tabs
- Add Radix UI Tabs component for code example section
- Improve OAuth authorization page with more robust parameter validation
- Refactor Authorizing component with better loading state and error handling
- Optimize authorization code generation and expiration logic
- Update authorization flow to support standard OAuth 2.0 parameters
This commit is contained in:
wood chen 2025-02-20 03:44:05 +08:00
parent 5a31f79f75
commit 760bbdbafd
12 changed files with 548 additions and 352 deletions

135
README.md
View File

@ -26,141 +26,6 @@ pnpm turbo
您可以通过修改 `app/page.tsx` 来开始编辑页面。当您编辑文件时,页面会自动更新。 您可以通过修改 `app/page.tsx` 来开始编辑页面。当您编辑文件时,页面会自动更新。
## 配置
要使用此 OAuth 系统,您需要进行以下配置:
1. 在您的 Discourse 论坛中启用 SSO 功能。
2. 设置环境变量:
- `NEXT_PUBLIC_HOST_URL`: 应用程序的主机 URL不要在末尾添加 "/"
- `DATABASE_URL`: 数据库连接字符串
- `AUTH_SECRET`: Next Auth 的密钥
- `DISCOURSE_HOST`: 您的 Discourse 论坛 URL
- `DISCOURSE_SECRET`: 在 Discourse 中设置的 SSO secret
## 部署
### 使用 Docker 部署
本项目支持使用 Docker 进行部署。以下是使用 Docker Compose 部署的步骤:
1. 确保您的系统已安装 Docker 和 Docker Compose。
2. 在项目根目录下,运行以下命令启动服务:
```bash
docker-compose up -d
```
这将构建并启动 Web 应用和 PostgreSQL 数据库服务。
3. 应用将在 http://localhost:3000 上运行。
4. 要停止服务,运行:
```bash
docker-compose down
```
### 使用 Vercel 部署
另一种部署 Next.js 应用程序的简单方法是使用 [Vercel 平台](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme)。
查看我们的 [Next.js 部署文档](https://nextjs.org/docs/deployment) 了解更多详情。
## OAuth 2.0 接口
本项目实现了基于 OAuth 2.0 协议的认证系统。以下是主要的 OAuth 接口及其使用说明:
### 1. 授权请求
**端点:** `https://connect.q58.club/oauth/authorize`
**方法:** GET
**参数:**
- `response_type`: 必须为 "code"
- `client_id`: 您的客户端 ID
- `redirect_uri`: 授权后重定向的 URI
- `scope`: (可选)请求的权限范围
**示例:**
```
/oauth/authorize?response_type=code&client_id=your_client_id&redirect_uri=https://your-app.com/callback
```
### 2. 获取访问令牌
**端点:** `https://connect.q58.club/api/oauth/access_token`
**方法:** POST
**参数:**
- `code`: 从授权请求中获得的授权码
- `redirect_uri`: 必须与授权请求中的 redirect_uri 相同
**响应:**
```json
{
"access_token": "at_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"expires_in": 604800,
"token_type": "bearer"
}
```
### 3. 获取用户信息
**端点:** `https://connect.q58.club/api/oauth/user`
**方法:** GET
**请求头:**
- `Authorization: Bearer {access_token}`
**响应:**
```json
{
"id": "user_id",
"email": "user@example.com",
"username": "username",
"admin": false,
"moderator": false,
"avatar_url": "https://example.com/avatar.jpg",
"name": "User Name",
"groups": ["group1", "group2"]
}
```
**响应字段说明:**
- `id`: 用户唯一标识
- `email`: 用户邮箱
- `username`: 用户名
- `admin`: 是否是管理员
- `moderator`: 是否是版主
- `avatar_url`: 头像地址
- `name`: 用户昵称
- `groups`: 用户所属的论坛用户组列表
### 使用流程
1. 将用户重定向到授权页面(`/oauth/authorize`)。
2. 用户授权后,您的应用将收到一个授权码。
3. 使用授权码请求访问令牌(`/api/oauth/access_token`)。
4. 使用访问令牌获取用户信息(`/api/oauth/user`)。
注意:确保在生产环境中使用 HTTPS 来保护所有的 OAuth 请求和响应。
## 贡献
欢迎贡献代码、报告问题或提出改进建议。
## 许可证 ## 许可证
本项目采用 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。 本项目采用 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。

View File

@ -27,6 +27,7 @@
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",

30
pnpm-lock.yaml generated
View File

@ -38,6 +38,9 @@ dependencies:
'@radix-ui/react-switch': '@radix-ui/react-switch':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) version: 1.1.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-tabs':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-toast': '@radix-ui/react-toast':
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.6(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) version: 1.2.6(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
@ -1088,6 +1091,33 @@ packages:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
dev: false dev: false
/@radix-ui/react-tabs@1.1.3(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1)
'@types/react': 18.3.18
'@types/react-dom': 18.3.5(@types/react@18.3.18)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-toast@1.2.6(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1): /@radix-ui/react-toast@1.2.6(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==} resolution: {integrity: sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==}
peerDependencies: peerDependencies:

View File

@ -74,9 +74,11 @@ export async function handleAuthorizeAction(
return { error: "请先登录" }; return { error: "请先登录" };
} }
const userId = currentUser.id;
// 获取用户信息 // 获取用户信息
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: currentUser.id }, where: { id: userId },
select: { username: true }, select: { username: true },
}); });
@ -85,10 +87,7 @@ export async function handleAuthorizeAction(
} }
// 检查平台级权限 // 检查平台级权限
const platformAuth = await checkPlatformAuthorization( const platformAuth = await checkPlatformAuthorization(clientId, userId);
clientId,
currentUser.id,
);
if (!platformAuth.allowed) { if (!platformAuth.allowed) {
return { error: platformAuth.error }; return { error: platformAuth.error };
} }
@ -102,39 +101,43 @@ export async function handleAuthorizeAction(
return { error: appAuth.error }; return { error: appAuth.error };
} }
// 检查或创建授权记录 // 使用事务处理授权记录的更新或创建
const existingAuth = await prisma.authorization.findUnique({ await prisma.$transaction(async (tx) => {
where: { const existingAuth = await tx.authorization.findUnique({
userId_clientId: {
userId: currentUser.id,
clientId,
},
},
});
if (existingAuth) {
// 更新最后使用时间
await prisma.authorization.update({
where: { where: {
userId_clientId: { userId_clientId: {
userId: currentUser.id, userId,
clientId, clientId,
}, },
}, },
data: {
lastUsedAt: new Date(),
},
}); });
} else {
// 创建新的授权记录 if (existingAuth) {
await createAuthorization({ // 更新最后使用时间
userId: currentUser.id, await tx.authorization.update({
clientId, where: {
scope, userId_clientId: {
enabled: true, userId,
lastUsedAt: new Date(), clientId,
}); },
} },
data: {
lastUsedAt: new Date(),
},
});
} else {
// 创建新的授权记录
await tx.authorization.create({
data: {
userId,
clientId,
scope,
enabled: true,
lastUsedAt: new Date(),
},
});
}
});
// 处理重定向 // 处理重定向
const oauthParams = new URLSearchParams(atob(oauth)); const oauthParams = new URLSearchParams(atob(oauth));
@ -149,6 +152,9 @@ export async function handleAuthorizeAction(
return { redirectUrl }; return { redirectUrl };
} catch (error) { } catch (error) {
console.error("授权处理失败:", error); console.error("授权处理失败:", error);
if (error instanceof Error) {
return { error: `授权处理失败: ${error.message}` };
}
return { error: "授权处理失败,请稍后重试" }; return { error: "授权处理失败,请稍后重试" };
} }
} }

View File

@ -1,202 +1,270 @@
import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { ArrowRight } from "lucide-react"; import { ArrowRight, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Container } from "@/components/ui/container";
import { Section } from "@/components/ui/section";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
TypographyH1,
TypographyH2,
TypographyLead,
TypographyMuted,
} from "@/components/ui/typography";
import { NavBar } from "@/components/layout/nav-bar"; import { NavBar } from "@/components/layout/nav-bar";
import { ThemeToggle } from "@/components/theme-toggle";
// 动态导入 Logo 组件以避免服务器端渲染错误 export default function HomePage() {
const DynamicLogo = dynamic(() => import("@/components/dynamic-logo"), {
ssr: false,
});
export default function IndexPage() {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<NavBar /> <NavBar />
<main className="flex-1"> <main className="flex-1">
{/* Hero Section */} <Section className="pb-0">
<div className="bg-gradient-to-b from-white to-gray-50 py-20 dark:from-gray-900 dark:to-gray-800"> <Container>
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8"> <div className="flex flex-col items-center justify-center space-y-8 text-center">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl"> <TypographyH1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl">
Q58 Connect Q58 Connect
</h1> </TypographyH1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300"> <TypographyLead className="max-w-[600px]">
Q58论坛的OAuth 2.0, 使 Q58
使Q58论坛账号快速登录您的应用 </TypographyLead>
</p> <div className="flex gap-4">
<div className="mt-10 flex flex-col justify-center space-y-4 sm:flex-row sm:space-x-6 sm:space-y-0"> <Link href="/sign-in">
<Link href="/dashboard"> <Button size="lg">
<Button size="lg" className="w-full sm:w-auto">
使
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
</div>
{/* Features Section */}
<div className="py-24">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
使
</h2>
<p className="mt-4 text-lg text-gray-600 dark:text-gray-300">
Q58
</p>
</div>
<div className="mt-20">
<div className="grid gap-12 lg:grid-cols-3">
{/* Step 1 */}
<div className="text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-[#25263A] text-xl font-bold text-white dark:bg-[#A0A1B2]">
1
</div>
<h3 className="mt-6 text-xl font-bold text-gray-900 dark:text-white">
</h3>
<p className="mt-4 text-gray-600 dark:text-gray-300">
Client ID Client Secret
</p>
</div>
{/* Step 2 */}
<div className="text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-[#25263A] text-xl font-bold text-white dark:bg-[#A0A1B2]">
2
</div>
<h3 className="mt-6 text-xl font-bold text-gray-900 dark:text-white">
</h3>
<p className="mt-4 text-gray-600 dark:text-gray-300">
OAuth 2.0
</p>
</div>
{/* Step 3 */}
<div className="text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-[#25263A] text-xl font-bold text-white dark:bg-[#A0A1B2]">
3
</div>
<h3 className="mt-6 text-xl font-bold text-gray-900 dark:text-white">
使 使
</h3> <ArrowRight className="ml-2 h-5 w-5" />
<p className="mt-4 text-gray-600 dark:text-gray-300"> </Button>
使 </Link>
</p>
</div>
</div> </div>
</div> </div>
</Container>
</Section>
{/* API Example */} <Section>
<div className="mt-20"> <Container>
<div className="rounded-xl bg-gray-900 p-8"> <div className="grid gap-8 md:grid-cols-3">
<h3 className="mb-4 text-xl font-bold text-white"> <Card>
OAuth 2.0 <CardHeader>
</h3> <CardTitle></CardTitle>
<div className="mb-4 rounded-lg border border-yellow-600 bg-yellow-600/10 p-4 text-yellow-600"> <CardDescription>
<p className="text-sm"> Q58
<strong></strong>{" "} </CardDescription>
使 AJAX/Fetch </CardHeader>
<CardContent>
</p> <ul className="space-y-2">
</div> <li className="flex items-center gap-2">
<pre className="overflow-x-auto text-sm text-gray-300"> <CheckCircle2 className="h-5 w-5 text-green-500" />
<code>{`// 1. 重定向到授权页面 <span> OAuth 2.0 </span>
window.location.href = 'https://connect.q58.club/oauth/authorize?' + new URLSearchParams({ </li>
response_type: 'code', // 必填,固定值 'code' <li className="flex items-center gap-2">
client_id: 'your_client_id',// 必填在控制台获取的客户端ID <CheckCircle2 className="h-5 w-5 text-green-500" />
redirect_uri: 'https://your-app.com/callback' // 必填,授权后的回调地址 <span></span>
}); </li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span>SDK</span>
</li>
</ul>
</CardContent>
</Card>
// 2. 获取访问令牌 <Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span>HTTPS </span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span>+</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span>CSRF攻击</span>
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span></span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span></span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span></span>
</li>
</ul>
</CardContent>
</Card>
</div>
</Container>
</Section>
<Section>
<Container>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Q58 Connect
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="auth" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="auth"></TabsTrigger>
<TabsTrigger value="callback"></TabsTrigger>
<TabsTrigger value="userinfo"></TabsTrigger>
</TabsList>
<TabsContent value="auth" className="mt-4">
<Card>
<CardHeader>
<CardTitle>1. </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
<code className="text-sm">
{`const authUrl = 'https://connect.q58.club/oauth/authorize?' +
new URLSearchParams({
response_type: 'code', // 必填,固定值
client_id: 'your_client_id', // 必填您的应用ID
redirect_uri: 'https://your-app.com/callback',
state: 'random_state', // 建议提供防CSRF攻击
scope: 'read_profile' // 可选默认read_profile
});
window.location.href = authUrl;`}
</code>
</pre>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="callback" className="mt-4">
<Card>
<CardHeader>
<CardTitle>2. </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
<code className="text-sm">
{`// 获取访问令牌
const response = await fetch('https://connect.q58.club/api/oauth/access_token', { const response = await fetch('https://connect.q58.club/api/oauth/access_token', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded'
}, },
body: new URLSearchParams({ body: new URLSearchParams({
code: '授权码', // 上一步回调地址获取的 code 参数 code: '授权码', // 回调参数中的code
redirect_uri: 'https://your-app.com/callback' // 必须与授权请求中的一致 redirect_uri: 'https://your-app.com/callback'
}) })
}); });
// 返回数据示例: const { access_token, expires_in } = await response.json();`}
{ </code>
"access_token": "at_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // 访问令牌,以 at_ 开头 </pre>
"token_type": "bearer", // 令牌类型 </CardContent>
"expires_in": 604800 // 令牌有效期(秒)默认7天 </Card>
} </TabsContent>
<TabsContent value="userinfo" className="mt-4">
// 3. 获取用户信息 <Card>
const userInfo = await fetch('https://connect.q58.club/api/oauth/user', { <CardHeader>
<CardTitle>3. </CardTitle>
<CardDescription>
使访
</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
<code className="text-sm">
{`const userInfo = await fetch('https://connect.q58.club/api/oauth/user', {
headers: { headers: {
'Authorization': \`Bearer \${access_token}\` // 使用上一步获取的访问令牌 'Authorization': \`Bearer \${access_token}\`
} }
}).then(res => res.json()); }).then(res => res.json());
// 返回数据示例: // 返回数据示例:
{ {
"id": "user_xxxxxx", // 用户唯一标识 "id": "user_xxx",
"email": "user@example.com", // 用户邮箱 "email": "user@example.com",
"username": "username", // 用户名 "username": "username",
"name": "用户昵称", // 用户昵称 "name": "用户昵称",
"avatar_url": "https://...", // 头像URL "avatar_url": "https://...",
"admin": false, // 是否是管理员 "groups": ["group1", "group2"]
"moderator": false, // 是否是版主 }`}
"groups": ["group1", "group2"] // 用户所属的论坛用户组 </code>
}`}</code> </pre>
</pre> </CardContent>
<div className="mt-4 space-y-2 text-sm text-gray-400"> </Card>
<p> </TabsContent>
<strong></strong> </Tabs>
</p> </CardContent>
<ul className="list-inside list-disc space-y-1"> </Card>
<li>read_profile - </li> </Container>
<li>groups - </li> </Section>
<li>admin - </li>
</ul> <Section>
<p className="mt-4"> <Container>
<strong></strong> <div className="text-center">
</p> <TypographyH2></TypographyH2>
<ul className="list-inside list-disc space-y-1"> <TypographyMuted className="mt-2">
<li>(code)使</li> 使 Q58 Connect
<li>10</li> </TypographyMuted>
<li>access_token 7</li> <div className="mt-4">
<li>(redirect_uri)</li> <Link href="/sign-in">
<li>使 HTTPS </li> <Button size="lg"></Button>
</ul> </Link>
<p className="mt-4">
<strong></strong>
</p>
<ul className="list-inside list-disc space-y-1">
<li>400 Invalid code params - </li>
<li>400 Invalid code credentials - </li>
<li>400 Invalid redirect uri - </li>
<li>401 Invalid access token - 访</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </Container>
</div> </Section>
</main> </main>
<footer className="bg-white py-8 shadow-inner dark:bg-gray-800"> <footer className="border-t bg-white py-8 dark:bg-gray-800">
<div className="mx-auto max-w-7xl px-4 text-center text-gray-600 dark:text-gray-400 sm:px-6 lg:px-8"> <Container>
© 2024{" "} <div className="text-center text-gray-600 dark:text-gray-400">
<a © 2024{" "}
href="https://q58.club" <a
className="text-[#25263A] hover:underline dark:text-[#A0A1B2]" href="https://q58.club"
> className="text-[#25263A] hover:underline dark:text-[#A0A1B2]"
Q58论坛 >
</a> Q58论坛
. </a>
</div> .
</div>
</Container>
</footer> </footer>
</div> </div>
); );

View File

@ -42,6 +42,21 @@ export default async function AuthorizePage({
); );
} }
// 验证 response_type
if (searchParams.response_type !== "code") {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<ErrorCard
title="参数错误"
description="不支持的授权类型"
redirectUri={searchParams.redirect_uri}
error="unsupported_response_type"
errorDescription="仅支持 code 授权类型"
/>
</div>
);
}
const client = await getClientByClientId(searchParams.client_id); const client = await getClientByClientId(searchParams.client_id);
if (!client) { if (!client) {
return ( return (

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { handleAuthorizeAction } from "@/actions/authorizing"; import { handleAuthorizeAction } from "@/actions/authorizing";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ErrorCard } from "@/components/auth/error-card"; import { ErrorCard } from "@/components/auth/error-card";
interface AuthorizingProps { interface AuthorizingProps {
@ -20,22 +21,29 @@ export function Authorizing({
redirectUri, redirectUri,
}: AuthorizingProps) { }: AuthorizingProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
const authorize = async () => { const authorize = async () => {
const result = await handleAuthorizeAction(oauth, clientId, scope); try {
if (result.error) { const result = await handleAuthorizeAction(oauth, clientId, scope);
setError(result.error); if (result.error) {
} else if (result.redirectUrl) { setError(result.error);
const url = await result.redirectUrl; } else if (result.redirectUrl) {
window.location.href = url; const url = await result.redirectUrl;
window.location.href = url;
} else {
setError("授权响应无效");
}
} catch (err) {
console.error("授权过程出错:", err);
setError(err instanceof Error ? err.message : "授权过程发生未知错误");
} finally {
setIsLoading(false);
} }
}; };
authorize().catch((err) => { authorize();
console.error("授权过程出错:", err);
setError("授权过程发生错误,请稍后重试");
});
}, [oauth, clientId, scope]); }, [oauth, clientId, scope]);
if (error) { if (error) {
@ -53,11 +61,18 @@ export function Authorizing({
} }
return ( return (
<div className="flex min-h-screen items-center justify-center"> <Card className="w-full max-w-md">
<div className="text-center"> <CardHeader className="space-y-4 text-center">
<div className="mb-4 text-2xl font-semibold">...</div> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-blue-100">
<div className="text-gray-500"></div> <div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
</div> </div>
</div> <CardTitle className="text-2xl font-semibold"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-center text-gray-500">
{isLoading ? "请稍候,我们正在处理您的授权请求" : "正在跳转..."}
</p>
</CardContent>
</Card>
); );
} }

View File

@ -0,0 +1,20 @@
import { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type ContainerProps = HTMLAttributes<HTMLElement> & {
as?: "div" | "section" | "article" | "main" | "header" | "footer";
};
export function Container({
className,
as: Component = "div",
...props
}: ContainerProps) {
return (
<Component
className={cn("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8", className)}
{...props}
/>
);
}

View File

@ -0,0 +1,15 @@
import { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type SectionProps = HTMLAttributes<HTMLElement> & {
as?: "section" | "div" | "article" | "main" | "header" | "footer";
};
export function Section({
className,
as: Component = "section",
...props
}: SectionProps) {
return <Component className={cn("py-16 md:py-24", className)} {...props} />;
}

View File

@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,95 @@
import { cn } from "@/lib/utils";
export function TypographyH1({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h1
className={cn(
"scroll-m-20 text-4xl font-bold tracking-tight lg:text-5xl",
className,
)}
{...props}
/>
);
}
export function TypographyH2({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(
"scroll-m-20 text-3xl font-semibold tracking-tight",
className,
)}
{...props}
/>
);
}
export function TypographyH3({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn(
"scroll-m-20 text-2xl font-semibold tracking-tight",
className,
)}
{...props}
/>
);
}
export function TypographyP({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn("leading-7 [&:not(:first-child)]:mt-6", className)}
{...props}
/>
);
}
export function TypographyLead({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-xl text-muted-foreground", className)} {...props} />
);
}
export function TypographyLarge({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("text-lg font-semibold", className)} {...props} />;
}
export function TypographySmall({
className,
...props
}: React.HTMLAttributes<HTMLElement>) {
return (
<small
className={cn("text-sm font-medium leading-none", className)}
{...props}
/>
);
}
export function TypographyMuted({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
}

View File

@ -5,6 +5,17 @@ import WordArray from "crypto-js/lib-typedarrays";
import { getClientByClientId } from "@/lib/dto/client"; import { getClientByClientId } from "@/lib/dto/client";
import { createCode } from "@/lib/dto/code"; import { createCode } from "@/lib/dto/code";
function generateAuthCode(): string {
// 生成一个 48 字节的随机字符串
const randomBytes = WordArray.random(48);
// 转换为 base64 并移除可能的特殊字符
return randomBytes
.toString()
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
export async function getAuthorizeUrl(params: URLSearchParams) { export async function getAuthorizeUrl(params: URLSearchParams) {
// client // client
const client = await getClientByClientId(params.get("client_id") as string); const client = await getClientByClientId(params.get("client_id") as string);
@ -17,14 +28,14 @@ export async function getAuthorizeUrl(params: URLSearchParams) {
if (params.has("state")) { if (params.has("state")) {
redirect_uri.searchParams.append("state", params.get("state") || ""); redirect_uri.searchParams.append("state", params.get("state") || "");
} }
const code = WordArray.random(32).toString(); const code = generateAuthCode();
redirect_uri.searchParams.append("code", code); redirect_uri.searchParams.append("code", code);
// storage code // storage code with shorter expiration time
try { try {
await createCode({ await createCode({
code, code,
expiresAt: new Date(Date.now() + 10 * 60 * 1000), expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5分钟过期
clientId: client.id, clientId: client.id,
userId: client.userId, userId: client.userId,
}); });