mirror of
https://github.com/woodchen-ink/Q58Connect.git
synced 2025-07-18 14:01:55 +08:00
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:
parent
5a31f79f75
commit
760bbdbafd
135
README.md
135
README.md
@ -26,141 +26,6 @@ pnpm turbo
|
||||
|
||||
您可以通过修改 `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) 文件。
|
||||
|
@ -27,6 +27,7 @@
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@ -38,6 +38,9 @@ dependencies:
|
||||
'@radix-ui/react-switch':
|
||||
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)
|
||||
'@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':
|
||||
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)
|
||||
@ -1088,6 +1091,33 @@ packages:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
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):
|
||||
resolution: {integrity: sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==}
|
||||
peerDependencies:
|
||||
|
@ -74,9 +74,11 @@ export async function handleAuthorizeAction(
|
||||
return { error: "请先登录" };
|
||||
}
|
||||
|
||||
const userId = currentUser.id;
|
||||
|
||||
// 获取用户信息
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: currentUser.id },
|
||||
where: { id: userId },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
@ -85,10 +87,7 @@ export async function handleAuthorizeAction(
|
||||
}
|
||||
|
||||
// 检查平台级权限
|
||||
const platformAuth = await checkPlatformAuthorization(
|
||||
clientId,
|
||||
currentUser.id,
|
||||
);
|
||||
const platformAuth = await checkPlatformAuthorization(clientId, userId);
|
||||
if (!platformAuth.allowed) {
|
||||
return { error: platformAuth.error };
|
||||
}
|
||||
@ -102,11 +101,12 @@ export async function handleAuthorizeAction(
|
||||
return { error: appAuth.error };
|
||||
}
|
||||
|
||||
// 检查或创建授权记录
|
||||
const existingAuth = await prisma.authorization.findUnique({
|
||||
// 使用事务处理授权记录的更新或创建
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existingAuth = await tx.authorization.findUnique({
|
||||
where: {
|
||||
userId_clientId: {
|
||||
userId: currentUser.id,
|
||||
userId,
|
||||
clientId,
|
||||
},
|
||||
},
|
||||
@ -114,10 +114,10 @@ export async function handleAuthorizeAction(
|
||||
|
||||
if (existingAuth) {
|
||||
// 更新最后使用时间
|
||||
await prisma.authorization.update({
|
||||
await tx.authorization.update({
|
||||
where: {
|
||||
userId_clientId: {
|
||||
userId: currentUser.id,
|
||||
userId,
|
||||
clientId,
|
||||
},
|
||||
},
|
||||
@ -127,14 +127,17 @@ export async function handleAuthorizeAction(
|
||||
});
|
||||
} else {
|
||||
// 创建新的授权记录
|
||||
await createAuthorization({
|
||||
userId: currentUser.id,
|
||||
await tx.authorization.create({
|
||||
data: {
|
||||
userId,
|
||||
clientId,
|
||||
scope,
|
||||
enabled: true,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 处理重定向
|
||||
const oauthParams = new URLSearchParams(atob(oauth));
|
||||
@ -149,6 +152,9 @@ export async function handleAuthorizeAction(
|
||||
return { redirectUrl };
|
||||
} catch (error) {
|
||||
console.error("授权处理失败:", error);
|
||||
if (error instanceof Error) {
|
||||
return { error: `授权处理失败: ${error.message}` };
|
||||
}
|
||||
return { error: "授权处理失败,请稍后重试" };
|
||||
}
|
||||
}
|
||||
|
@ -1,193 +1,260 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
|
||||
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 { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
// 动态导入 Logo 组件以避免服务器端渲染错误
|
||||
const DynamicLogo = dynamic(() => import("@/components/dynamic-logo"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function IndexPage() {
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<NavBar />
|
||||
<main className="flex-1">
|
||||
{/* Hero Section */}
|
||||
<div className="bg-gradient-to-b from-white to-gray-50 py-20 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl">
|
||||
<Section className="pb-0">
|
||||
<Container>
|
||||
<div className="flex flex-col items-center justify-center space-y-8 text-center">
|
||||
<TypographyH1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl">
|
||||
Q58 Connect
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300">
|
||||
基于Q58论坛的OAuth 2.0认证服务,
|
||||
让用户使用Q58论坛账号快速登录您的应用
|
||||
</p>
|
||||
<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="/dashboard">
|
||||
<Button size="lg" className="w-full sm:w-auto">
|
||||
</TypographyH1>
|
||||
<TypographyLead className="max-w-[600px]">
|
||||
使用 Q58 论坛账号,一键登录您的应用
|
||||
</TypographyLead>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/sign-in">
|
||||
<Button size="lg">
|
||||
开始使用
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
{/* 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>
|
||||
<Section>
|
||||
<Container>
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>简单集成</CardTitle>
|
||||
<CardDescription>
|
||||
只需几行代码,即可为您的应用添加 Q58 论坛账号登录功能
|
||||
</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>标准 OAuth 2.0 协议</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>示例代码和SDK</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-300">
|
||||
用户可以使用论坛账号一键登录您的应用,无需重新注册
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
});
|
||||
|
||||
{/* API Example */}
|
||||
<div className="mt-20">
|
||||
<div className="rounded-xl bg-gray-900 p-8">
|
||||
<h3 className="mb-4 text-xl font-bold text-white">
|
||||
OAuth 2.0 认证流程
|
||||
</h3>
|
||||
<div className="mb-4 rounded-lg border border-yellow-600 bg-yellow-600/10 p-4 text-yellow-600">
|
||||
<p className="text-sm">
|
||||
<strong>重要提示:</strong>{" "}
|
||||
授权请求必须通过浏览器重定向实现,不能使用 AJAX/Fetch
|
||||
等方式直接请求。
|
||||
</p>
|
||||
</div>
|
||||
<pre className="overflow-x-auto text-sm text-gray-300">
|
||||
<code>{`// 1. 重定向到授权页面
|
||||
window.location.href = 'https://connect.q58.club/oauth/authorize?' + new URLSearchParams({
|
||||
response_type: 'code', // 必填,固定值 'code'
|
||||
client_id: 'your_client_id',// 必填,在控制台获取的客户端ID
|
||||
redirect_uri: 'https://your-app.com/callback' // 必填,授权后的回调地址
|
||||
});
|
||||
|
||||
// 2. 获取访问令牌
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code: '授权码', // 上一步回调地址获取的 code 参数
|
||||
redirect_uri: 'https://your-app.com/callback' // 必须与授权请求中的一致
|
||||
code: '授权码', // 回调参数中的code
|
||||
redirect_uri: 'https://your-app.com/callback'
|
||||
})
|
||||
});
|
||||
|
||||
// 返回数据示例:
|
||||
{
|
||||
"access_token": "at_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // 访问令牌,以 at_ 开头
|
||||
"token_type": "bearer", // 令牌类型
|
||||
"expires_in": 604800 // 令牌有效期(秒),默认7天
|
||||
}
|
||||
|
||||
// 3. 获取用户信息
|
||||
const userInfo = await fetch('https://connect.q58.club/api/oauth/user', {
|
||||
const { access_token, expires_in } = await response.json();`}
|
||||
</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="userinfo" className="mt-4">
|
||||
<Card>
|
||||
<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: {
|
||||
'Authorization': \`Bearer \${access_token}\` // 使用上一步获取的访问令牌
|
||||
'Authorization': \`Bearer \${access_token}\`
|
||||
}
|
||||
}).then(res => res.json());
|
||||
|
||||
// 返回数据示例:
|
||||
{
|
||||
"id": "user_xxxxxx", // 用户唯一标识
|
||||
"email": "user@example.com", // 用户邮箱
|
||||
"username": "username", // 用户名
|
||||
"name": "用户昵称", // 用户昵称
|
||||
"avatar_url": "https://...", // 头像URL
|
||||
"admin": false, // 是否是管理员
|
||||
"moderator": false, // 是否是版主
|
||||
"groups": ["group1", "group2"] // 用户所属的论坛用户组
|
||||
}`}</code>
|
||||
"id": "user_xxx",
|
||||
"email": "user@example.com",
|
||||
"username": "username",
|
||||
"name": "用户昵称",
|
||||
"avatar_url": "https://...",
|
||||
"groups": ["group1", "group2"]
|
||||
}`}
|
||||
</code>
|
||||
</pre>
|
||||
<div className="mt-4 space-y-2 text-sm text-gray-400">
|
||||
<p>
|
||||
<strong>权限说明:</strong>
|
||||
</p>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>read_profile - 获取用户基本信息,包括邮箱、用户名等</li>
|
||||
<li>groups - 获取用户所属的论坛用户组信息</li>
|
||||
<li>admin - 获取用户的管理权限状态</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
<strong>安全说明:</strong>
|
||||
</p>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>授权码(code)是一次性的,使用后立即失效</li>
|
||||
<li>授权码有效期为10分钟</li>
|
||||
<li>access_token 有效期为7天,请在过期前重新获取</li>
|
||||
<li>请确保回调地址(redirect_uri)与应用注册时完全一致</li>
|
||||
<li>建议使用 HTTPS 确保数据传输安全</li>
|
||||
</ul>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Container>
|
||||
<div className="text-center">
|
||||
<TypographyH2>准备好了吗?</TypographyH2>
|
||||
<TypographyMuted className="mt-2">
|
||||
创建一个应用,开始使用 Q58 Connect
|
||||
</TypographyMuted>
|
||||
<div className="mt-4">
|
||||
<Link href="/sign-in">
|
||||
<Button size="lg">立即开始</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</main>
|
||||
|
||||
<footer className="bg-white py-8 shadow-inner 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">
|
||||
<footer className="border-t bg-white py-8 dark:bg-gray-800">
|
||||
<Container>
|
||||
<div className="text-center text-gray-600 dark:text-gray-400">
|
||||
© 2024{" "}
|
||||
<a
|
||||
href="https://q58.club"
|
||||
@ -197,6 +264,7 @@ const userInfo = await fetch('https://connect.q58.club/api/oauth/user', {
|
||||
</a>
|
||||
. 保留所有权利。
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
@ -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);
|
||||
if (!client) {
|
||||
return (
|
||||
|
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { handleAuthorizeAction } from "@/actions/authorizing";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ErrorCard } from "@/components/auth/error-card";
|
||||
|
||||
interface AuthorizingProps {
|
||||
@ -20,22 +21,29 @@ export function Authorizing({
|
||||
redirectUri,
|
||||
}: AuthorizingProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const authorize = async () => {
|
||||
try {
|
||||
const result = await handleAuthorizeAction(oauth, clientId, scope);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else if (result.redirectUrl) {
|
||||
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) => {
|
||||
console.error("授权过程出错:", err);
|
||||
setError("授权过程发生错误,请稍后重试");
|
||||
});
|
||||
authorize();
|
||||
}, [oauth, clientId, scope]);
|
||||
|
||||
if (error) {
|
||||
@ -53,11 +61,18 @@ export function Authorizing({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-2xl font-semibold">正在处理授权...</div>
|
||||
<div className="text-gray-500">请稍候,我们正在处理您的授权请求</div>
|
||||
</div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-blue-100">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-semibold">正在处理授权</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-gray-500">
|
||||
{isLoading ? "请稍候,我们正在处理您的授权请求" : "正在跳转..."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
20
src/components/ui/container.tsx
Normal file
20
src/components/ui/container.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/components/ui/section.tsx
Normal file
15
src/components/ui/section.tsx
Normal 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} />;
|
||||
}
|
55
src/components/ui/tabs.tsx
Normal file
55
src/components/ui/tabs.tsx
Normal 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 };
|
95
src/components/ui/typography.tsx
Normal file
95
src/components/ui/typography.tsx
Normal 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} />
|
||||
);
|
||||
}
|
@ -5,6 +5,17 @@ import WordArray from "crypto-js/lib-typedarrays";
|
||||
import { getClientByClientId } from "@/lib/dto/client";
|
||||
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) {
|
||||
// client
|
||||
const client = await getClientByClientId(params.get("client_id") as string);
|
||||
@ -17,14 +28,14 @@ export async function getAuthorizeUrl(params: URLSearchParams) {
|
||||
if (params.has("state")) {
|
||||
redirect_uri.searchParams.append("state", params.get("state") || "");
|
||||
}
|
||||
const code = WordArray.random(32).toString();
|
||||
const code = generateAuthCode();
|
||||
redirect_uri.searchParams.append("code", code);
|
||||
|
||||
// storage code
|
||||
// storage code with shorter expiration time
|
||||
try {
|
||||
await createCode({
|
||||
code,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5分钟过期
|
||||
clientId: client.id,
|
||||
userId: client.userId,
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user