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` 来开始编辑页面。当您编辑文件时,页面会自动更新。
|
您可以通过修改 `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) 文件。
|
||||||
|
@ -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
30
pnpm-lock.yaml
generated
@ -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:
|
||||||
|
@ -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: "授权处理失败,请稍后重试" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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 { 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,
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user