feat(dashboard-fresh): implement multi-step login API and Deno native runtime config
- Add step-based login flow (check_email, login, verify_mfa) - Create Deno native runtime configuration loader - Fix all component imports to include file extensions - Add comprehensive API documentation
This commit is contained in:
parent
2a3e80c44f
commit
66721ea54a
@ -1,7 +0,0 @@
|
||||
import MarkdownHomepage from '../../../ui/pages/homepage'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
export default function MarkdownDemoPage() {
|
||||
return <MarkdownHomepage />
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import ArticleFeed from '@components/home/ArticleFeed'
|
||||
import ProductMatrix from '@components/home/ProductMatrix'
|
||||
import Sidebar from '@components/home/Sidebar'
|
||||
import MarkdownHomepage from '../ui/pages/homepage'
|
||||
import { isFeatureEnabled } from '@lib/featureToggles'
|
||||
|
||||
import { getActiveTemplate } from '../src/templateRegistry'
|
||||
|
||||
export default function HomePage() {
|
||||
if (!isFeatureEnabled('cmsExperience', '/homepage/dynamic')) {
|
||||
return <MarkdownHomepage />
|
||||
}
|
||||
|
||||
const template = getActiveTemplate()
|
||||
const HomePageTemplate = template.pages.home
|
||||
|
||||
return (
|
||||
<HomePageTemplate
|
||||
slots={{
|
||||
ProductMatrix,
|
||||
ArticleFeed,
|
||||
Sidebar,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface Crumb {
|
||||
label: string
|
||||
href: string
|
||||
@ -12,9 +10,9 @@ export default function Breadcrumbs({ items }: { items: Crumb[] }) {
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} className="flex items-center gap-1">
|
||||
{idx > 0 && <span>/</span>}
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
<a href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { formatDate } from '../../lib/format'
|
||||
import { formatSegmentLabel } from '../../lib/download-data'
|
||||
import { useMemo, useState } from 'preact/hooks'
|
||||
import { useLanguage } from '@i18n/LanguageProvider.tsx'
|
||||
import { translations } from '@i18n/translations.ts'
|
||||
import { formatDate } from '../../lib/format.ts'
|
||||
import { formatSegmentLabel } from '../../lib/download-data.ts'
|
||||
|
||||
interface Section {
|
||||
key: string
|
||||
@ -51,7 +48,7 @@ export default function CardGrid({ sections }: { sections: Section[] }) {
|
||||
</div>
|
||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
|
||||
{filtered.map((section) => (
|
||||
<Link
|
||||
<a
|
||||
key={section.key}
|
||||
href={section.href}
|
||||
className="mb-4 block break-inside-avoid rounded-3xl border bg-white p-5 shadow-sm ring-1 ring-gray-100 transition hover:-translate-y-1 hover:shadow-lg"
|
||||
@ -79,7 +76,7 @@ export default function CardGrid({ sections }: { sections: Section[] }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Copy } from 'lucide-react'
|
||||
import { Copy } from 'lucide-preact'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { formatSegmentLabel, type DownloadSection } from '../../lib/download-data'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import CardGrid from './CardGrid'
|
||||
import { useMemo, useState } from 'preact/hooks'
|
||||
import { formatSegmentLabel, type DownloadSection } from '../../lib/download-data.ts'
|
||||
import { useLanguage } from '@i18n/LanguageProvider.tsx'
|
||||
import { translations } from '@i18n/translations.ts'
|
||||
import CardGrid from './CardGrid.tsx'
|
||||
|
||||
interface DownloadBrowserProps {
|
||||
sectionsMap: Record<string, DownloadSection[]>
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Breadcrumbs, { type Crumb } from './Breadcrumbs'
|
||||
import CardGrid from './CardGrid'
|
||||
import FileTable from './FileTable'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { formatDate } from '@lib/format'
|
||||
import { formatSegmentLabel, type DownloadSection } from '@lib/download-data'
|
||||
import type { DirListing } from '../../types/download'
|
||||
import { useMemo } from 'preact/hooks'
|
||||
import Breadcrumbs, { type Crumb } from './Breadcrumbs.tsx'
|
||||
import CardGrid from './CardGrid.tsx'
|
||||
import FileTable from './FileTable.tsx'
|
||||
import { useLanguage } from '@i18n/LanguageProvider.tsx'
|
||||
import { translations } from '@i18n/translations.ts'
|
||||
import { formatDate } from '@lib/format.ts'
|
||||
import { formatSegmentLabel, type DownloadSection } from '@lib/download-data.ts'
|
||||
import type { DirListing } from '../../types/download.ts'
|
||||
|
||||
type DownloadListingContentProps = {
|
||||
segments: string[]
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { useLanguage } from '@i18n/LanguageProvider.tsx'
|
||||
import { translations } from '@i18n/translations.ts'
|
||||
|
||||
export default function DownloadNotFound() {
|
||||
const { language } = useLanguage()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { useLanguage } from '@i18n/LanguageProvider.tsx'
|
||||
import { translations } from '@i18n/translations.ts'
|
||||
|
||||
type DownloadSummaryProps = {
|
||||
topLevelCount: number
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import Breadcrumbs, { Crumb } from './Breadcrumbs'
|
||||
import CopyButton from './CopyButton'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { formatBytes, formatDate } from '../../lib/format'
|
||||
import type { DirListing } from '../../types/download'
|
||||
import { useMemo, useState } from 'preact/hooks'
|
||||
import Breadcrumbs, { Crumb } from './Breadcrumbs.tsx'
|
||||
import CopyButton from './CopyButton.tsx'
|
||||
import { useLanguage } from '@i18n/LanguageProvider.tsx'
|
||||
import { translations } from '@i18n/translations.ts'
|
||||
import { formatBytes, formatDate } from '../../lib/format.ts'
|
||||
import type { DirListing } from '../../types/download.ts'
|
||||
|
||||
interface FileTableProps {
|
||||
listing: DirListing
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import 'server-only'
|
||||
/**
|
||||
* Runtime Configuration Loader - Entry Point
|
||||
*
|
||||
* Loads environment-specific configuration from YAML files.
|
||||
* This is a server-only module for Deno Fresh.
|
||||
*/
|
||||
|
||||
// Prevent browser imports
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new Error('runtime-loader.ts is server-only and cannot be imported in the browser.')
|
||||
}
|
||||
|
||||
export * from '../server/runtime-loader'
|
||||
// Export Deno native runtime loader
|
||||
export * from '../server/runtime-loader.deno.ts'
|
||||
|
||||
@ -28,6 +28,10 @@
|
||||
"preact": "https://esm.sh/preact@10.22.0",
|
||||
"preact/": "https://esm.sh/preact@10.22.0/",
|
||||
"preact/hooks": "https://esm.sh/preact@10.22.0/hooks",
|
||||
"preact/jsx-runtime": "https://esm.sh/preact@10.22.0/jsx-runtime",
|
||||
"preact/jsx-dev-runtime": "https://esm.sh/preact@10.22.0/jsx-dev-runtime",
|
||||
"react/jsx-runtime": "https://esm.sh/preact@10.22.0/jsx-runtime",
|
||||
"react/jsx-dev-runtime": "https://esm.sh/preact@10.22.0/jsx-dev-runtime",
|
||||
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.3.1",
|
||||
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
|
||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
||||
@ -35,6 +39,7 @@
|
||||
"@cms/": "./cms/",
|
||||
"@components/": "./components/",
|
||||
"@islands/": "./islands/",
|
||||
"@i18n/": "./i18n/",
|
||||
"@lib/": "./lib/",
|
||||
"@types/": "./types/",
|
||||
"@server/": "./server/",
|
||||
|
||||
212
dashboard-fresh/docs/ENVIRONMENT_SETUP.md
Normal file
212
dashboard-fresh/docs/ENVIRONMENT_SETUP.md
Normal file
@ -0,0 +1,212 @@
|
||||
# 环境配置指南
|
||||
|
||||
## 1. 如何切换到 SIT 环境
|
||||
|
||||
### 方法一:使用环境变量(推荐)
|
||||
|
||||
在项目根目录设置环境变量:
|
||||
|
||||
```bash
|
||||
# 切换到 SIT 环境
|
||||
export RUNTIME_ENV=sit
|
||||
|
||||
# 或者使用其他支持的别名
|
||||
export RUNTIME_ENV=staging
|
||||
export RUNTIME_ENV=dev
|
||||
export RUNTIME_ENV=development
|
||||
|
||||
# 然后启动服务
|
||||
deno task dev
|
||||
```
|
||||
|
||||
### 方法二:创建运行时配置文件
|
||||
|
||||
在项目根目录创建 `.runtime-env-config.yaml`:
|
||||
|
||||
```yaml
|
||||
# .runtime-env-config.yaml
|
||||
environment: sit
|
||||
region: default
|
||||
```
|
||||
|
||||
支持的位置(按优先级排序):
|
||||
1. `RUNTIME_ENV_CONFIG_PATH` 环境变量指定的路径
|
||||
2. `dashboard/config/.runtime-env-config.yaml`
|
||||
3. `config/.runtime-env-config.yaml`
|
||||
4. `./.runtime-env-config.yaml`
|
||||
|
||||
### 方法三:使用 .env 文件
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
RUNTIME_ENV=sit
|
||||
RUNTIME_REGION=default
|
||||
|
||||
# 可选:覆盖特定服务 URL
|
||||
AUTH_URL=https://dev-accounts.svc.plus
|
||||
API_BASE_URL=https://dev-api.svc.plus
|
||||
DASHBOARD_URL=https://dev-console.svc.plus
|
||||
```
|
||||
|
||||
## 2. 支持的环境值
|
||||
|
||||
### Environment(环境)
|
||||
|
||||
| 值 | 映射到 | 说明 |
|
||||
|---|---|---|
|
||||
| `prod`, `production`, `release`, `main`, `live` | **prod** | 生产环境 |
|
||||
| `sit`, `staging`, `test`, `qa`, `uat`, `dev`, `development`, `preview`, `preprod` | **sit** | 测试/开发环境 |
|
||||
|
||||
**默认环境:** `prod`
|
||||
|
||||
### Region(区域)
|
||||
|
||||
| 值 | 说明 | 配置文件 |
|
||||
|---|---|---|
|
||||
| `default` | 默认区域 | `runtime-service-config.base.yaml` + `runtime-service-config.sit.yaml` |
|
||||
| `cn` / `china` | 中国区 | 使用 `regions.cn` 配置 |
|
||||
| `global` | 全球区 | 使用 `regions.global` 配置 |
|
||||
|
||||
**默认区域:** `default`
|
||||
|
||||
## 3. 配置文件说明
|
||||
|
||||
### SIT 环境配置
|
||||
|
||||
`config/runtime-service-config.sit.yaml`:
|
||||
|
||||
```yaml
|
||||
# SIT 环境覆盖配置
|
||||
apiBaseUrl: https://dev-api.svc.plus
|
||||
authUrl: https://dev-accounts.svc.plus
|
||||
dashboardUrl: https://dev-console.svc.plus
|
||||
logLevel: debug
|
||||
```
|
||||
|
||||
### PROD 环境配置
|
||||
|
||||
`config/runtime-service-config.prod.yaml`:
|
||||
|
||||
```yaml
|
||||
# 生产环境配置(带区域支持)
|
||||
logLevel: warn
|
||||
regions:
|
||||
cn:
|
||||
apiBaseUrl: https://cn-api.svc.plus
|
||||
authUrl: https://cn-accounts.svc.plus
|
||||
dashboardUrl: https://cn-console.svc.plus
|
||||
global:
|
||||
apiBaseUrl: https://global-api.svc.plus
|
||||
authUrl: https://global-accounts.svc.plus
|
||||
dashboardUrl: https://global-console.svc.plus
|
||||
```
|
||||
|
||||
### 基础配置
|
||||
|
||||
`config/runtime-service-config.base.yaml`:
|
||||
|
||||
```yaml
|
||||
# 所有环境的基础配置
|
||||
apiBaseUrl: https://api.svc.plus
|
||||
authUrl: https://accounts.svc.plus
|
||||
dashboardUrl: https://console.svc.plus
|
||||
internalApiBaseUrl: http://127.0.0.1:8090
|
||||
logLevel: info
|
||||
```
|
||||
|
||||
## 4. 环境变量覆盖优先级
|
||||
|
||||
环境变量具有最高优先级,可以覆盖配置文件:
|
||||
|
||||
```bash
|
||||
# 这些环境变量会覆盖配置文件中的值
|
||||
export AUTH_URL=https://custom-auth.example.com
|
||||
export ACCOUNT_SERVICE_URL=https://custom-auth.example.com
|
||||
export API_BASE_URL=https://custom-api.example.com
|
||||
export DASHBOARD_URL=https://custom-dashboard.example.com
|
||||
```
|
||||
|
||||
**优先级顺序:**
|
||||
1. 环境变量(最高)
|
||||
2. 区域特定配置
|
||||
3. 环境特定配置
|
||||
4. 基础配置(最低)
|
||||
|
||||
## 5. 验证当前环境
|
||||
|
||||
启动服务时,会在日志中看到当前环境信息:
|
||||
|
||||
```bash
|
||||
$ deno task dev
|
||||
|
||||
[runtime-config] Loading SIT environment, default region
|
||||
[runtime-config] Loaded: authUrl=https://dev-accounts.svc.plus, apiBaseUrl=https://dev-api.svc.plus
|
||||
```
|
||||
|
||||
## 6. 完整示例
|
||||
|
||||
### 示例 1:本地开发(SIT 环境)
|
||||
|
||||
```bash
|
||||
# 设置环境
|
||||
export RUNTIME_ENV=sit
|
||||
|
||||
# 启动开发服务器
|
||||
deno task dev
|
||||
|
||||
# 或使用 make
|
||||
make dev
|
||||
```
|
||||
|
||||
### 示例 2:生产环境(中国区)
|
||||
|
||||
```bash
|
||||
# 设置环境和区域
|
||||
export RUNTIME_ENV=prod
|
||||
export RUNTIME_REGION=cn
|
||||
|
||||
# 启动服务
|
||||
deno task start
|
||||
```
|
||||
|
||||
### 示例 3:自定义配置
|
||||
|
||||
```bash
|
||||
# 完全自定义服务地址
|
||||
export RUNTIME_ENV=sit
|
||||
export AUTH_URL=https://my-custom-auth.example.com
|
||||
export API_BASE_URL=https://my-custom-api.example.com
|
||||
|
||||
deno task dev
|
||||
```
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
### Q: 如何确认当前使用的是哪个环境?
|
||||
A: 查看启动日志中的 `[runtime-config]` 信息,会显示当前环境和配置。
|
||||
|
||||
### Q: 环境变量不生效?
|
||||
A: 确保:
|
||||
1. 环境变量在启动服务之前设置
|
||||
2. 使用正确的环境变量名(见上述列表)
|
||||
3. 检查是否有配置文件覆盖
|
||||
|
||||
### Q: 如何在 Docker 中设置环境?
|
||||
A: 在 `docker-compose.yml` 或 Dockerfile 中设置环境变量:
|
||||
```yaml
|
||||
environment:
|
||||
- RUNTIME_ENV=sit
|
||||
- RUNTIME_REGION=default
|
||||
```
|
||||
|
||||
### Q: 支持 .env 文件吗?
|
||||
A: Deno Fresh 项目需要手动加载 .env 文件,建议直接使用环境变量或 YAML 配置文件。
|
||||
|
||||
## 8. 相关文件
|
||||
|
||||
- 配置加载器:`server/runtime-loader.deno.ts`
|
||||
- 配置入口:`config/runtime-loader.ts`
|
||||
- 配置文件目录:`config/runtime-service-config.*.yaml`
|
||||
- 登录 API:`routes/api/auth/login.ts`
|
||||
@ -233,3 +233,81 @@ cat routes/api/auth/login.ts
|
||||
**下一步:** 迁移认证 API 或认证页面
|
||||
|
||||
**最后更新:** 2025-11-04
|
||||
|
||||
|
||||
📊 app/ 目录迁移情况总结
|
||||
|
||||
✅ 已迁移到 routes/ (约 18%)
|
||||
|
||||
| app/ 路径 | routes/ 路径 | 状态 |
|
||||
|--------------------------|-------------------|----------------|
|
||||
| page.tsx (首页) | index.tsx | ✅ 已迁移 |
|
||||
| (auth)/login/page.tsx | login.tsx | ✅ 已迁移 |
|
||||
| (auth)/register/page.tsx | register.tsx | ✅ 已迁移 |
|
||||
| panel/page.tsx | panel/index.tsx | ✅ 已迁移 |
|
||||
| panel/account/page.tsx | panel/account.tsx | ✅ 已迁移 |
|
||||
| panel/mail/page.tsx | panel/mail.tsx | ✅ 已迁移 |
|
||||
| 404/page.tsx | _404.tsx | ✅ 已迁移 |
|
||||
| API 路由 | api/* | ✅ 部分迁移 (12/29) |
|
||||
|
||||
❌ 未迁移 (约 82%)
|
||||
|
||||
认证相关
|
||||
|
||||
- (auth)/email-verification/page.tsx - 邮箱验证页面
|
||||
|
||||
租户/邮件系统 (4 个页面)
|
||||
|
||||
- (tenant)/[tenantId]/mail/page.tsx - 邮件列表
|
||||
- (tenant)/[tenantId]/mail/compose/page.tsx - 撰写邮件
|
||||
- (tenant)/[tenantId]/mail/message/[id]/page.tsx - 邮件详情
|
||||
- (tenant)/[tenantId]/mail/settings/page.tsx - 邮件设置
|
||||
|
||||
文档系统 (3 个页面)
|
||||
|
||||
- docs/page.tsx - 文档首页
|
||||
- docs/[collection]/page.tsx - 文档集合
|
||||
- docs/[collection]/[version]/page.tsx - 文档版本
|
||||
|
||||
下载中心 (2 个页面)
|
||||
|
||||
- download/page.tsx - 下载首页
|
||||
- download/[...segments]/page.tsx - 动态下载路径
|
||||
|
||||
演示/Demo (3 个页面)
|
||||
|
||||
- demo/page.tsx - 演示首页
|
||||
- demo/theme/page.tsx - 主题演示
|
||||
- demo/markdown/page.tsx - ⚠️ 已损坏(引用了已删除的 ui/)
|
||||
|
||||
云 IaC (3 个页面)
|
||||
|
||||
- cloud_iac/page.tsx - IaC 首页
|
||||
- cloud_iac/[provider]/page.tsx - 云供应商
|
||||
- cloud_iac/[provider]/[service]/page.tsx - 云服务
|
||||
|
||||
洞察工作台
|
||||
|
||||
- insight/page.tsx - 洞察工作台
|
||||
|
||||
其他
|
||||
|
||||
- logout/page.tsx - 登出页面
|
||||
|
||||
用户面板子页面 (6 个)
|
||||
|
||||
- panel/agent/page.tsx - Agent 管理
|
||||
- panel/api/page.tsx - API 管理
|
||||
- panel/appearance/page.tsx - 外观设置
|
||||
- panel/ldp/page.tsx - LDP 页面
|
||||
- panel/management/page.tsx - 管理页面
|
||||
- panel/subscription/page.tsx - 订阅管理
|
||||
|
||||
---
|
||||
🎯 结论
|
||||
|
||||
❌ app/ 目录的功能还没有完全迁移!
|
||||
|
||||
- 已迁移: ~18% (主要是首页、登录注册、部分面板页面)
|
||||
- 未迁移: ~82% (大量功能模块)
|
||||
- API 迁移: 41.4% (12/29 个端点)
|
||||
436
dashboard-fresh/docs/IMPLEMENTATION_SUMMARY.md
Normal file
436
dashboard-fresh/docs/IMPLEMENTATION_SUMMARY.md
Normal file
@ -0,0 +1,436 @@
|
||||
# 登录与 MFA 优化 - 实现总结
|
||||
|
||||
## 📋 当前 Git 状态
|
||||
|
||||
### 最新 Commit
|
||||
|
||||
```
|
||||
Commit: d5a9e32694976fdc3db98597b393b0e823dd50d3
|
||||
Author: Haitao Pan <manbuzhe2009@qq.com>
|
||||
Date: 2025-11-05 09:18:56 +0800
|
||||
|
||||
refactor(dashboard-fresh): extract user menu into standalone component
|
||||
- Create islands/UserMenu.tsx with self-contained user menu functionality
|
||||
- Refactor islands/Navbar.tsx to use UserMenu component
|
||||
- Support both desktop and mobile layouts with single component
|
||||
```
|
||||
|
||||
### 本次修改统计
|
||||
|
||||
```
|
||||
28 files changed, 530 insertions(+), 590 deletions(-)
|
||||
```
|
||||
|
||||
**主要变更:**
|
||||
- ✅ 新增 Deno 原生运行时配置加载器
|
||||
- ✅ 重构登录 API 支持分步骤流程
|
||||
- ✅ 修复所有组件导入扩展名问题
|
||||
- ✅ 修复 JSX Runtime 映射问题
|
||||
- ✅ 更新 Lucide 图标库依赖
|
||||
|
||||
---
|
||||
|
||||
## 🎯 实现的功能
|
||||
|
||||
### 1. Deno 原生运行时配置加载器
|
||||
|
||||
**新文件:** `server/runtime-loader.deno.ts`
|
||||
|
||||
**特点:**
|
||||
- ✅ 纯 Deno 实现,无 Node.js 依赖
|
||||
- ✅ 支持 SIT/PROD 环境自动切换
|
||||
- ✅ 支持多区域配置(default/cn/global)
|
||||
- ✅ 环境变量覆盖支持
|
||||
- ✅ 配置缓存机制
|
||||
- ✅ 清晰的日志输出
|
||||
|
||||
**核心导出:**
|
||||
```typescript
|
||||
export async function loadRuntimeConfig(): Promise<RuntimeConfig>
|
||||
export async function getAuthUrl(): Promise<string>
|
||||
export async function getApiBaseUrl(): Promise<string>
|
||||
export async function getDashboardUrl(): Promise<string>
|
||||
```
|
||||
|
||||
### 2. 分步骤登录 API
|
||||
|
||||
**更新文件:** `routes/api/auth/login.ts`
|
||||
|
||||
**新增功能:**
|
||||
|
||||
#### Step 1: 检查邮箱 (`?step=check_email`)
|
||||
```typescript
|
||||
POST /api/auth/login?step=check_email
|
||||
{ "email": "user@example.com" }
|
||||
→ { "success": true, "exists": true, "mfaEnabled": false }
|
||||
```
|
||||
|
||||
#### Step 2: 用户登录 (`?step=login`)
|
||||
```typescript
|
||||
POST /api/auth/login?step=login
|
||||
{ "email": "user@example.com", "password": "...", "remember": true }
|
||||
→ { "success": true, "needMfa": false } + session cookie
|
||||
```
|
||||
|
||||
#### Step 3: MFA 验证 (`?step=verify_mfa`)
|
||||
```typescript
|
||||
POST /api/auth/login?step=verify_mfa
|
||||
{ "totp": "123456" }
|
||||
→ { "success": true, "needMfa": false } + session cookie
|
||||
```
|
||||
|
||||
**技术亮点:**
|
||||
- ✅ 统一的 `proxy()` 函数封装外部 API 调用
|
||||
- ✅ 标准化的 JSON 响应格式
|
||||
- ✅ 完善的错误处理和日志输出
|
||||
- ✅ 向后兼容旧版 API
|
||||
- ✅ Cookie 管理优化
|
||||
|
||||
### 3. 修复的问题
|
||||
|
||||
#### JSX Runtime 导入错误
|
||||
**问题:** `Import "react/jsx-runtime" not a dependency`
|
||||
|
||||
**修复:** 在 `deno.jsonc` 中添加映射
|
||||
```jsonc
|
||||
{
|
||||
"imports": {
|
||||
"react/jsx-runtime": "https://esm.sh/preact@10.22.0/jsx-runtime",
|
||||
"react/jsx-dev-runtime": "https://esm.sh/preact@10.22.0/jsx-dev-runtime",
|
||||
"preact/jsx-runtime": "https://esm.sh/preact@10.22.0/jsx-runtime",
|
||||
"preact/jsx-dev-runtime": "https://esm.sh/preact@10.22.0/jsx-dev-runtime"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 缺少文件扩展名
|
||||
**问题:** Deno 要求所有导入必须包含文件扩展名
|
||||
|
||||
**修复:** 为所有组件添加正确的扩展名
|
||||
```typescript
|
||||
// 修复前
|
||||
import Breadcrumbs from './Breadcrumbs'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
|
||||
// 修复后
|
||||
import Breadcrumbs from './Breadcrumbs.tsx'
|
||||
import { useLanguage } from '@i18n/LanguageProvider.tsx'
|
||||
```
|
||||
|
||||
**修复的文件:**
|
||||
- `components/download/DownloadListingContent.tsx`
|
||||
- `components/download/CardGrid.tsx`
|
||||
- `components/download/FileTable.tsx`
|
||||
- `components/download/DownloadBrowser.tsx`
|
||||
- `components/download/DownloadSummary.tsx`
|
||||
- `components/download/DownloadNotFound.tsx`
|
||||
|
||||
#### Lucide 图标库依赖
|
||||
**问题:** 使用了 `lucide-react` 但项目是 Preact
|
||||
|
||||
**修复:** 改用 `lucide-preact`
|
||||
```typescript
|
||||
// 修复前
|
||||
import { Copy } from 'lucide-react'
|
||||
|
||||
// 修复后
|
||||
import { Copy } from 'lucide-preact'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 新增文件
|
||||
|
||||
### 文档
|
||||
1. `docs/ENVIRONMENT_SETUP.md` - 环境配置完整指南
|
||||
2. `docs/LOGIN_API_GUIDE.md` - 登录 API 使用文档
|
||||
3. `docs/IMPLEMENTATION_SUMMARY.md` - 本文档
|
||||
|
||||
### 代码
|
||||
1. `server/runtime-loader.deno.ts` - Deno 原生配置加载器
|
||||
|
||||
### 修改文件
|
||||
1. `config/runtime-loader.ts` - 更新为使用 Deno 加载器
|
||||
2. `routes/api/auth/login.ts` - 完全重构的登录 API
|
||||
3. `deno.jsonc` - 添加 JSX runtime 映射
|
||||
4. `components/download/*.tsx` - 修复导入扩展名
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 切换到 SIT 环境
|
||||
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export RUNTIME_ENV=sit
|
||||
|
||||
# 启动开发服务器
|
||||
deno task dev
|
||||
```
|
||||
|
||||
### 2. 验证环境配置
|
||||
|
||||
启动时查看日志输出:
|
||||
```
|
||||
[runtime-config] Loading SIT environment, default region
|
||||
[runtime-config] Loaded: authUrl=https://dev-accounts.svc.plus, apiBaseUrl=https://dev-api.svc.plus
|
||||
🍋 Fresh ready
|
||||
Local: http://localhost:8004/
|
||||
```
|
||||
|
||||
### 3. 测试登录 API
|
||||
|
||||
```bash
|
||||
# 测试检查邮箱
|
||||
curl -X POST http://localhost:8004/api/auth/login?step=check_email \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com"}'
|
||||
|
||||
# 测试登录
|
||||
curl -X POST http://localhost:8004/api/auth/login?step=login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"YOUR_PASSWORD"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 环境配置快速参考
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 值 | 说明 |
|
||||
|--------|---|------|
|
||||
| `RUNTIME_ENV` | `sit` / `prod` | 环境选择 |
|
||||
| `RUNTIME_REGION` | `default` / `cn` / `global` | 区域选择 |
|
||||
| `AUTH_URL` | URL | 覆盖认证服务地址 |
|
||||
| `API_BASE_URL` | URL | 覆盖 API 服务地址 |
|
||||
| `DASHBOARD_URL` | URL | 覆盖控制台地址 |
|
||||
|
||||
### 配置文件优先级
|
||||
|
||||
```
|
||||
环境变量(最高)
|
||||
↓
|
||||
区域特定配置 (regions.cn / regions.global)
|
||||
↓
|
||||
环境特定配置 (runtime-service-config.sit.yaml)
|
||||
↓
|
||||
基础配置 (runtime-service-config.base.yaml)
|
||||
```
|
||||
|
||||
### SIT 环境配置
|
||||
|
||||
`config/runtime-service-config.sit.yaml`:
|
||||
```yaml
|
||||
apiBaseUrl: https://dev-api.svc.plus
|
||||
authUrl: https://dev-accounts.svc.plus
|
||||
dashboardUrl: https://dev-console.svc.plus
|
||||
logLevel: debug
|
||||
```
|
||||
|
||||
### PROD 环境配置
|
||||
|
||||
`config/runtime-service-config.prod.yaml`:
|
||||
```yaml
|
||||
logLevel: warn
|
||||
regions:
|
||||
cn:
|
||||
apiBaseUrl: https://cn-api.svc.plus
|
||||
authUrl: https://cn-accounts.svc.plus
|
||||
global:
|
||||
apiBaseUrl: https://global-api.svc.plus
|
||||
authUrl: https://global-accounts.svc.plus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 API 端点速查
|
||||
|
||||
### 登录流程
|
||||
|
||||
| 步骤 | 端点 | 方法 | 请求体 | 响应 |
|
||||
|-----|------|------|--------|------|
|
||||
| 检查邮箱 | `/api/auth/login?step=check_email` | POST | `{email}` | `{exists, mfaEnabled}` |
|
||||
| 登录 | `/api/auth/login?step=login` | POST | `{email, password, remember}` | `{success, needMfa}` + cookies |
|
||||
| MFA 验证 | `/api/auth/login?step=verify_mfa` | POST | `{totp}` | `{success}` + cookies |
|
||||
| 登出 | `/api/auth/login` | DELETE | - | `{success}` + clear cookies |
|
||||
|
||||
### 错误代码
|
||||
|
||||
| 代码 | 说明 |
|
||||
|-----|------|
|
||||
| `missing_email` | 未提供邮箱 |
|
||||
| `missing_credentials` | 缺少邮箱或密码 |
|
||||
| `missing_totp_code` | 未提供 TOTP 代码 |
|
||||
| `missing_mfa_token` | 缺少 MFA 令牌 |
|
||||
| `authentication_failed` | 认证失败 |
|
||||
| `mfa_required` | 需要 MFA 验证 |
|
||||
| `mfa_verification_failed` | MFA 验证失败 |
|
||||
| `account_service_unreachable` | 服务不可达 |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试清单
|
||||
|
||||
### ✅ 已验证
|
||||
|
||||
- [x] Deno 开发服务器成功启动
|
||||
- [x] 所有导入路径正确解析
|
||||
- [x] JSX 组件正常编译
|
||||
- [x] 配置加载器类型检查通过
|
||||
- [x] 登录 API 类型检查通过
|
||||
|
||||
### 🔲 待测试
|
||||
|
||||
- [ ] 实际调用后端认证服务
|
||||
- [ ] MFA 完整流程测试
|
||||
- [ ] Cookie 设置和清除
|
||||
- [ ] 环境切换功能
|
||||
- [ ] 区域配置切换
|
||||
- [ ] 错误处理流程
|
||||
- [ ] 日志输出格式
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考文档
|
||||
|
||||
1. **环境配置指南**:`docs/ENVIRONMENT_SETUP.md`
|
||||
- 如何切换环境
|
||||
- 配置文件结构
|
||||
- 环境变量说明
|
||||
|
||||
2. **登录 API 指南**:`docs/LOGIN_API_GUIDE.md`
|
||||
- API 完整文档
|
||||
- 使用示例
|
||||
- 前端集成代码
|
||||
|
||||
3. **架构说明**:
|
||||
- 配置加载器:`server/runtime-loader.deno.ts`
|
||||
- 登录处理器:`routes/api/auth/login.ts`
|
||||
|
||||
---
|
||||
|
||||
## 🎨 代码风格
|
||||
|
||||
### 遵循的原则
|
||||
|
||||
1. **Deno 原生优先**:不使用 Node.js API
|
||||
2. **类型安全**:所有函数都有完整类型定义
|
||||
3. **错误处理**:统一的错误格式和日志
|
||||
4. **文档完善**:所有 public API 都有 JSDoc
|
||||
5. **代码简洁**:避免过度抽象,保持可读性
|
||||
|
||||
### 日志规范
|
||||
|
||||
```typescript
|
||||
// 信息日志
|
||||
console.log('[login-proxy] → /api/auth/check_email', { email })
|
||||
|
||||
// 成功日志
|
||||
console.log('[login] ✓ Login successful')
|
||||
|
||||
// 错误日志
|
||||
console.error('[login] ✗ Authentication failed:', errorCode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要注意事项
|
||||
|
||||
### 安全
|
||||
|
||||
1. ⚠️ **密码不会出现在日志中**
|
||||
2. ⚠️ **所有 Cookie 都设置了 HttpOnly 和 Secure**
|
||||
3. ⚠️ **MFA 令牌仅用于临时验证**
|
||||
4. ⚠️ **生产环境必须使用 HTTPS**
|
||||
|
||||
### 兼容性
|
||||
|
||||
1. ✅ **向后兼容**:未指定 step 时默认为 login
|
||||
2. ✅ **旧客户端**:仍可使用 `POST /api/auth/login`
|
||||
3. ⚠️ **推荐迁移**:使用新的分步骤 API
|
||||
|
||||
### 性能
|
||||
|
||||
1. ✅ **配置缓存**:运行时配置只加载一次
|
||||
2. ✅ **超时控制**:所有外部请求都有 10 秒超时
|
||||
3. ✅ **异步加载**:配置文件异步读取
|
||||
|
||||
---
|
||||
|
||||
## 📞 问题排查
|
||||
|
||||
### 问题 1: 环境变量不生效
|
||||
|
||||
**检查:**
|
||||
```bash
|
||||
# 确认环境变量已设置
|
||||
echo $RUNTIME_ENV
|
||||
echo $RUNTIME_REGION
|
||||
|
||||
# 查看启动日志
|
||||
deno task dev | grep runtime-config
|
||||
```
|
||||
|
||||
### 问题 2: 导入错误
|
||||
|
||||
**检查:**
|
||||
```bash
|
||||
# 类型检查
|
||||
deno check routes/api/auth/login.ts
|
||||
|
||||
# 查看具体错误
|
||||
deno cache --reload routes/api/auth/login.ts
|
||||
```
|
||||
|
||||
### 问题 3: 认证服务不可达
|
||||
|
||||
**检查:**
|
||||
```bash
|
||||
# 测试连接
|
||||
curl https://dev-accounts.svc.plus/api/auth/check_email \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步计划
|
||||
|
||||
### 建议优化
|
||||
|
||||
1. **单元测试**:为登录 API 添加完整的单元测试
|
||||
2. **集成测试**:端到端的登录流程测试
|
||||
3. **性能监控**:添加 API 响应时间追踪
|
||||
4. **错误追踪**:集成错误追踪服务(如 Sentry)
|
||||
5. **API 限流**:防止暴力破解攻击
|
||||
|
||||
### 功能扩展
|
||||
|
||||
1. **OAuth 登录**:支持第三方登录(Google, GitHub)
|
||||
2. **密码重置**:完整的密码重置流程
|
||||
3. **邮箱验证**:新用户邮箱验证
|
||||
4. **会话管理**:多设备登录管理
|
||||
5. **审计日志**:登录活动追踪
|
||||
|
||||
---
|
||||
|
||||
## 👥 贡献者
|
||||
|
||||
- **Haitao Pan** - 初始实现和重构
|
||||
- **Claude** - API 优化和文档
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目遵循项目主许可证。
|
||||
|
||||
---
|
||||
|
||||
**生成时间:** 2025-11-05
|
||||
**版本:** 1.0.0
|
||||
**Deno 版本:** 运行 `deno --version` 查看
|
||||
**Fresh 版本:** 1.7.3
|
||||
526
dashboard-fresh/docs/LOGIN_API_GUIDE.md
Normal file
526
dashboard-fresh/docs/LOGIN_API_GUIDE.md
Normal file
@ -0,0 +1,526 @@
|
||||
# 登录 API 使用指南
|
||||
|
||||
## API 端点
|
||||
|
||||
`POST /api/auth/login?step={step}`
|
||||
|
||||
支持三个步骤的登录流程:
|
||||
|
||||
### Step 1: 检查邮箱 (`check_email`)
|
||||
### Step 2: 用户登录 (`login`)
|
||||
### Step 3: 验证 MFA (`verify_mfa`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 检查邮箱是否存在及 MFA 状态
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /api/auth/login?step=check_email
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### 成功响应 (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"exists": true,
|
||||
"mfaEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
- `success`: 请求是否成功
|
||||
- `error`: 错误信息(成功时为 null)
|
||||
- `exists`: 邮箱是否存在
|
||||
- `mfaEnabled`: 该用户是否启用了 MFA
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "missing_email",
|
||||
"exists": false,
|
||||
"mfaEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### 错误代码
|
||||
|
||||
| 错误代码 | 说明 |
|
||||
|---------|------|
|
||||
| `missing_email` | 未提供邮箱地址 |
|
||||
| `check_email_failed` | 邮箱检查失败 |
|
||||
| `account_service_unreachable` | 认证服务不可达 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户登录
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /api/auth/login?step=login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePassword123",
|
||||
"remember": true
|
||||
}
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
- `email`: 用户邮箱(必填)
|
||||
- `password`: 用户密码(必填)
|
||||
- `remember`: 是否保持登录状态(可选,默认 false)
|
||||
|
||||
### 成功响应 - 无需 MFA (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"needMfa": false
|
||||
}
|
||||
```
|
||||
|
||||
**设置的 Cookie:**
|
||||
- `session`: 会话令牌
|
||||
- 清除 `mfa_token`(如果存在)
|
||||
|
||||
### 成功响应 - 需要 MFA (401 Unauthorized)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "mfa_required",
|
||||
"needMfa": true
|
||||
}
|
||||
```
|
||||
|
||||
**设置的 Cookie:**
|
||||
- `mfa_token`: MFA 验证令牌
|
||||
- 清除 `session`
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "authentication_failed",
|
||||
"needMfa": false
|
||||
}
|
||||
```
|
||||
|
||||
### 错误代码
|
||||
|
||||
| 错误代码 | 状态码 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `missing_credentials` | 400 | 缺少邮箱或密码 |
|
||||
| `authentication_failed` | 401 | 认证失败(邮箱或密码错误) |
|
||||
| `mfa_required` | 401 | 需要 MFA 验证 |
|
||||
| `mfa_setup_required` | 401 | 需要设置 MFA |
|
||||
| `account_service_unreachable` | 502 | 认证服务不可达 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 验证 MFA 代码
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /api/auth/login?step=verify_mfa
|
||||
Content-Type: application/json
|
||||
Cookie: mfa_token=<mfa_token>
|
||||
|
||||
{
|
||||
"totp": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
或者直接在请求体中提供 token:
|
||||
|
||||
```json
|
||||
{
|
||||
"totp": "123456",
|
||||
"token": "mfa_token_from_previous_step"
|
||||
}
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
- `totp` 或 `code`: 6 位 TOTP 验证码(必填)
|
||||
- `token`: MFA 令牌(可选,如果未在 Cookie 中提供)
|
||||
|
||||
### 成功响应 (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"needMfa": false
|
||||
}
|
||||
```
|
||||
|
||||
**设置的 Cookie:**
|
||||
- `session`: 会话令牌
|
||||
- 清除 `mfa_token`
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "mfa_verification_failed",
|
||||
"needMfa": true
|
||||
}
|
||||
```
|
||||
|
||||
### 错误代码
|
||||
|
||||
| 错误代码 | 状态码 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `missing_totp_code` | 400 | 未提供 TOTP 代码 |
|
||||
| `missing_mfa_token` | 401 | 缺少 MFA 令牌 |
|
||||
| `mfa_verification_failed` | 401 | MFA 验证失败 |
|
||||
| `account_service_unreachable` | 502 | 认证服务不可达 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 清除会话(登出)
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
DELETE /api/auth/login
|
||||
```
|
||||
|
||||
### 响应 (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"needMfa": false
|
||||
}
|
||||
```
|
||||
|
||||
**清除的 Cookie:**
|
||||
- `session`
|
||||
- `mfa_token`
|
||||
|
||||
---
|
||||
|
||||
## 完整登录流程示例
|
||||
|
||||
### 场景 1: 无 MFA 的简单登录
|
||||
|
||||
```javascript
|
||||
// 1. 检查邮箱
|
||||
const checkResponse = await fetch('/api/auth/login?step=check_email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'user@example.com' })
|
||||
})
|
||||
const checkData = await checkResponse.json()
|
||||
console.log(checkData)
|
||||
// { success: true, exists: true, mfaEnabled: false }
|
||||
|
||||
// 2. 登录
|
||||
const loginResponse = await fetch('/api/auth/login?step=login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123',
|
||||
remember: true
|
||||
})
|
||||
})
|
||||
const loginData = await loginResponse.json()
|
||||
console.log(loginData)
|
||||
// { success: true, error: null, needMfa: false }
|
||||
// ✅ 登录成功,session cookie 已设置
|
||||
```
|
||||
|
||||
### 场景 2: 带 MFA 的登录流程
|
||||
|
||||
```javascript
|
||||
// 1. 检查邮箱
|
||||
const checkResponse = await fetch('/api/auth/login?step=check_email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'user@example.com' })
|
||||
})
|
||||
const checkData = await checkResponse.json()
|
||||
console.log(checkData)
|
||||
// { success: true, exists: true, mfaEnabled: true }
|
||||
|
||||
// 2. 登录(第一步)
|
||||
const loginResponse = await fetch('/api/auth/login?step=login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // 重要:保存 cookies
|
||||
body: JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123'
|
||||
})
|
||||
})
|
||||
const loginData = await loginResponse.json()
|
||||
console.log(loginData)
|
||||
// { success: false, error: "mfa_required", needMfa: true }
|
||||
// ⚠️ 需要 MFA 验证,mfa_token cookie 已设置
|
||||
|
||||
// 3. 验证 MFA 代码
|
||||
const mfaResponse = await fetch('/api/auth/login?step=verify_mfa', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // 重要:发送 mfa_token cookie
|
||||
body: JSON.stringify({
|
||||
totp: '123456' // 用户的 6 位 TOTP 代码
|
||||
})
|
||||
})
|
||||
const mfaData = await mfaResponse.json()
|
||||
console.log(mfaData)
|
||||
// { success: true, error: null, needMfa: false }
|
||||
// ✅ MFA 验证成功,session cookie 已设置
|
||||
```
|
||||
|
||||
### 场景 3: 登出
|
||||
|
||||
```javascript
|
||||
const logoutResponse = await fetch('/api/auth/login', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
})
|
||||
const logoutData = await logoutResponse.json()
|
||||
console.log(logoutData)
|
||||
// { success: true, error: null, needMfa: false }
|
||||
// ✅ 登出成功,所有认证 cookies 已清除
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端集成建议
|
||||
|
||||
### React/Preact 示例
|
||||
|
||||
```tsx
|
||||
import { useState } from 'preact/hooks'
|
||||
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
const [needMfa, setNeedMfa] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleLogin = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Step 1: Check email
|
||||
const checkRes = await fetch('/api/auth/login?step=check_email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
})
|
||||
const checkData = await checkRes.json()
|
||||
|
||||
if (!checkData.exists) {
|
||||
setError('邮箱不存在')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Login
|
||||
const loginRes = await fetch('/api/auth/login?step=login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password, remember: true })
|
||||
})
|
||||
const loginData = await loginRes.json()
|
||||
|
||||
if (loginData.success) {
|
||||
// 登录成功,跳转到仪表板
|
||||
window.location.href = '/panel'
|
||||
} else if (loginData.needMfa) {
|
||||
// 需要 MFA 验证
|
||||
setNeedMfa(true)
|
||||
} else {
|
||||
setError(loginData.error || '登录失败')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('网络错误')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMfaVerify = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login?step=verify_mfa', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ totp: mfaCode })
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
window.location.href = '/panel'
|
||||
} else {
|
||||
setError(data.error || 'MFA 验证失败')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('网络错误')
|
||||
}
|
||||
}
|
||||
|
||||
if (needMfa) {
|
||||
return (
|
||||
<form onSubmit={handleMfaVerify}>
|
||||
<h2>双因素认证</h2>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入 6 位验证码"
|
||||
value={mfaCode}
|
||||
onInput={(e) => setMfaCode(e.currentTarget.value)}
|
||||
maxLength={6}
|
||||
/>
|
||||
<button type="submit">验证</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLogin}>
|
||||
<h2>登录</h2>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<button type="submit">登录</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术实现细节
|
||||
|
||||
### 代理函数 (proxy)
|
||||
|
||||
内部使用统一的代理函数来调用外部认证 API:
|
||||
|
||||
```typescript
|
||||
async function proxy<T>(
|
||||
endpoint: string,
|
||||
body: Record<string, unknown>,
|
||||
timeout = 10000
|
||||
): Promise<{ ok: boolean; status: number; data: T }>
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- 自动从运行时配置加载 `authUrl`
|
||||
- 统一超时控制(默认 10 秒)
|
||||
- 统一日志输出
|
||||
- 统一错误处理
|
||||
|
||||
### 后端 API 映射
|
||||
|
||||
| 前端端点 | 后端 API | 方法 |
|
||||
|---------|---------|------|
|
||||
| `?step=check_email` | `${authUrl}/api/auth/check_email` | POST |
|
||||
| `?step=login` | `${authUrl}/api/auth/login` | POST |
|
||||
| `?step=verify_mfa` | `${authUrl}/api/auth/verify_mfa` | POST |
|
||||
|
||||
### Cookie 管理
|
||||
|
||||
- **Session Cookie**: `session`
|
||||
- 存储会话令牌
|
||||
- HttpOnly, Secure
|
||||
- 过期时间根据 remember 参数调整
|
||||
|
||||
- **MFA Cookie**: `mfa_token`
|
||||
- 临时存储 MFA 令牌
|
||||
- HttpOnly, Secure
|
||||
- 短期有效
|
||||
|
||||
### 日志输出
|
||||
|
||||
所有请求都会输出结构化日志:
|
||||
|
||||
```
|
||||
[login-proxy] → /api/auth/check_email { email: 'user@example.com' }
|
||||
[login-proxy] ← /api/auth/check_email [200] { ok: true, hasData: true }
|
||||
[login] ✓ Login successful
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **所有请求必须使用 HTTPS**(生产环境)
|
||||
2. **密码永远不会被记录在日志中**
|
||||
3. **MFA 令牌仅用于临时验证,验证后立即清除**
|
||||
4. **Session Cookie 设置了 HttpOnly 和 Secure 标志**
|
||||
5. **所有输入都经过规范化和验证**
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
- API 实现:`routes/api/auth/login.ts`
|
||||
- 认证工具:`lib/authGateway.deno.ts`
|
||||
- 运行时配置:`server/runtime-loader.deno.ts`
|
||||
- 环境配置:参见 `docs/ENVIRONMENT_SETUP.md`
|
||||
|
||||
---
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
### 向后兼容
|
||||
|
||||
如果请求未指定 `step` 参数,API 会默认使用 `login` 行为:
|
||||
|
||||
```http
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "YOUR_PASSWORD"
|
||||
}
|
||||
```
|
||||
|
||||
这与旧版 API 行为保持一致,确保现有客户端继续工作。
|
||||
|
||||
### 推荐做法
|
||||
|
||||
新的集成应使用明确的 `step` 参数:
|
||||
- ✅ `POST /api/auth/login?step=login`
|
||||
- ❌ `POST /api/auth/login` (虽然可用,但不推荐)
|
||||
@ -17,21 +17,34 @@ import * as $api_downloads from './routes/api/downloads.ts'
|
||||
import * as $api_ping from './routes/api/ping.ts'
|
||||
import * as $api_render_markdown from './routes/api/render-markdown.ts'
|
||||
import * as $api_templates from './routes/api/templates.ts'
|
||||
import * as $download_segments_index from './routes/download/[...segments]/index.tsx'
|
||||
import * as $download_index from './routes/download/index.tsx'
|
||||
import * as $email_verification from './routes/email-verification.tsx'
|
||||
import * as $index from './routes/index.tsx'
|
||||
import * as $login from './routes/login.tsx'
|
||||
import * as $logout from './routes/logout.tsx'
|
||||
import * as $navbar_demo from './routes/navbar-demo.tsx'
|
||||
import * as $panel_account from './routes/panel/account.tsx'
|
||||
import * as $panel_agent from './routes/panel/agent.tsx'
|
||||
import * as $panel_api from './routes/panel/api.tsx'
|
||||
import * as $panel_appearance from './routes/panel/appearance.tsx'
|
||||
import * as $panel_index from './routes/panel/index.tsx'
|
||||
import * as $panel_ldp from './routes/panel/ldp.tsx'
|
||||
import * as $panel_mail from './routes/panel/mail.tsx'
|
||||
import * as $panel_management from './routes/panel/management.tsx'
|
||||
import * as $panel_subscription from './routes/panel/subscription.tsx'
|
||||
import * as $register from './routes/register.tsx'
|
||||
import * as $AccountDropdown from './islands/AccountDropdown.tsx'
|
||||
import * as $AskAIButton from './islands/AskAIButton.tsx'
|
||||
import * as $Counter from './islands/Counter.tsx'
|
||||
import * as $EmailVerificationForm from './islands/EmailVerificationForm.tsx'
|
||||
import * as $LoginForm from './islands/LoginForm.tsx'
|
||||
import * as $LogoutHandler from './islands/LogoutHandler.tsx'
|
||||
import * as $MobileMenu from './islands/MobileMenu.tsx'
|
||||
import * as $Navbar from './islands/Navbar.tsx'
|
||||
import * as $RegisterForm from './islands/RegisterForm.tsx'
|
||||
import * as $SearchDialog from './islands/SearchDialog.tsx'
|
||||
import * as $UserMenu from './islands/UserMenu.tsx'
|
||||
import * as $home_ShowcaseCarousel from './islands/home/ShowcaseCarousel.tsx'
|
||||
import * as $panel_Header from './islands/panel/Header.tsx'
|
||||
import * as $panel_PanelLayout from './islands/panel/PanelLayout.tsx'
|
||||
@ -55,23 +68,36 @@ const manifest = {
|
||||
'./routes/api/ping.ts': $api_ping,
|
||||
'./routes/api/render-markdown.ts': $api_render_markdown,
|
||||
'./routes/api/templates.ts': $api_templates,
|
||||
'./routes/download/[...segments]/index.tsx': $download_segments_index,
|
||||
'./routes/download/index.tsx': $download_index,
|
||||
'./routes/email-verification.tsx': $email_verification,
|
||||
'./routes/index.tsx': $index,
|
||||
'./routes/login.tsx': $login,
|
||||
'./routes/logout.tsx': $logout,
|
||||
'./routes/navbar-demo.tsx': $navbar_demo,
|
||||
'./routes/panel/account.tsx': $panel_account,
|
||||
'./routes/panel/agent.tsx': $panel_agent,
|
||||
'./routes/panel/api.tsx': $panel_api,
|
||||
'./routes/panel/appearance.tsx': $panel_appearance,
|
||||
'./routes/panel/index.tsx': $panel_index,
|
||||
'./routes/panel/ldp.tsx': $panel_ldp,
|
||||
'./routes/panel/mail.tsx': $panel_mail,
|
||||
'./routes/panel/management.tsx': $panel_management,
|
||||
'./routes/panel/subscription.tsx': $panel_subscription,
|
||||
'./routes/register.tsx': $register,
|
||||
},
|
||||
islands: {
|
||||
'./islands/AccountDropdown.tsx': $AccountDropdown,
|
||||
'./islands/AskAIButton.tsx': $AskAIButton,
|
||||
'./islands/Counter.tsx': $Counter,
|
||||
'./islands/EmailVerificationForm.tsx': $EmailVerificationForm,
|
||||
'./islands/LoginForm.tsx': $LoginForm,
|
||||
'./islands/LogoutHandler.tsx': $LogoutHandler,
|
||||
'./islands/MobileMenu.tsx': $MobileMenu,
|
||||
'./islands/Navbar.tsx': $Navbar,
|
||||
'./islands/RegisterForm.tsx': $RegisterForm,
|
||||
'./islands/SearchDialog.tsx': $SearchDialog,
|
||||
'./islands/UserMenu.tsx': $UserMenu,
|
||||
'./islands/home/ShowcaseCarousel.tsx': $home_ShowcaseCarousel,
|
||||
'./islands/panel/Header.tsx': $panel_Header,
|
||||
'./islands/panel/PanelLayout.tsx': $panel_PanelLayout,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { createContext } from 'preact'
|
||||
import { useContext, useEffect, useState } from 'preact/hooks'
|
||||
import type { ComponentChildren } from 'preact'
|
||||
|
||||
export type Language = 'en' | 'zh'
|
||||
|
||||
@ -16,7 +16,7 @@ const LanguageContext = createContext<LanguageContextType>({
|
||||
setLanguage: () => {},
|
||||
})
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
export function LanguageProvider({ children }: { children: ComponentChildren }) {
|
||||
const [language, setLanguage] = useState<Language>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
379
dashboard-fresh/islands/EmailVerificationForm.tsx
Normal file
379
dashboard-fresh/islands/EmailVerificationForm.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
/**
|
||||
* EmailVerificationForm Island - Client-side email verification logic
|
||||
*
|
||||
* Handles email verification code input and submission
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout.tsx'
|
||||
|
||||
const VERIFICATION_CODE_LENGTH = 6
|
||||
const RESEND_COOLDOWN_SECONDS = 60
|
||||
|
||||
type AlertState = { type: 'error' | 'success' | 'info'; message: string }
|
||||
|
||||
interface EmailVerificationFormProps {
|
||||
email: string
|
||||
status: string | null
|
||||
error: string | null
|
||||
language: 'zh' | 'en'
|
||||
}
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
zh: {
|
||||
badge: '邮箱验证',
|
||||
title: '验证您的邮箱',
|
||||
description: '我们已向 {{email}} 发送了验证码',
|
||||
emailFallback: '您的邮箱',
|
||||
form: {
|
||||
codeLabel: '验证码',
|
||||
codePlaceholder: '请输入 6 位验证码',
|
||||
helper: '验证码已发送到您的邮箱,请查收',
|
||||
submit: '验证邮箱',
|
||||
submitting: '验证中...',
|
||||
},
|
||||
resend: {
|
||||
label: '重新发送验证码',
|
||||
resending: '发送中...',
|
||||
},
|
||||
alerts: {
|
||||
verificationSent: '验证码已发送',
|
||||
verificationResent: '验证码已重新发送',
|
||||
verificationReady: '验证成功!正在跳转...',
|
||||
verificationFailed: '验证码无效或已过期',
|
||||
codeRequired: '请输入验证码',
|
||||
missingEmail: '缺少邮箱地址',
|
||||
genericError: '操作失败,请稍后重试',
|
||||
},
|
||||
switchAction: {
|
||||
text: '已有账号?',
|
||||
link: '立即登录',
|
||||
},
|
||||
footnote: '验证码有效期为 10 分钟',
|
||||
bottomNote: '如未收到验证码,请检查垃圾邮件箱',
|
||||
},
|
||||
en: {
|
||||
badge: 'Email Verification',
|
||||
title: 'Verify Your Email',
|
||||
description: 'We sent a verification code to {{email}}',
|
||||
emailFallback: 'your email',
|
||||
form: {
|
||||
codeLabel: 'Verification Code',
|
||||
codePlaceholder: 'Enter 6-digit code',
|
||||
helper: 'Check your email for the verification code',
|
||||
submit: 'Verify Email',
|
||||
submitting: 'Verifying...',
|
||||
},
|
||||
resend: {
|
||||
label: 'Resend Code',
|
||||
resending: 'Sending...',
|
||||
},
|
||||
alerts: {
|
||||
verificationSent: 'Verification code sent',
|
||||
verificationResent: 'Verification code resent',
|
||||
verificationReady: 'Verification successful! Redirecting...',
|
||||
verificationFailed: 'Invalid or expired code',
|
||||
codeRequired: 'Please enter verification code',
|
||||
missingEmail: 'Email address missing',
|
||||
genericError: 'Operation failed, please try again',
|
||||
},
|
||||
switchAction: {
|
||||
text: 'Already have an account?',
|
||||
link: 'Sign in',
|
||||
},
|
||||
footnote: 'Verification code valid for 10 minutes',
|
||||
bottomNote: 'If you didn\'t receive the code, check your spam folder',
|
||||
},
|
||||
}
|
||||
|
||||
export default function EmailVerificationForm({
|
||||
email,
|
||||
status: statusParam,
|
||||
error: errorParam,
|
||||
language,
|
||||
}: EmailVerificationFormProps) {
|
||||
const t = translations[language]
|
||||
const redirectTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
const descriptionEmail = email || t.emailFallback || ''
|
||||
const description = useMemo(() => {
|
||||
if (!t.description.includes('{{email}}')) {
|
||||
return t.description
|
||||
}
|
||||
return t.description.replace('{{email}}', descriptionEmail)
|
||||
}, [descriptionEmail, t.description])
|
||||
|
||||
const initialAlert = useMemo<AlertState | null>(() => {
|
||||
if (statusParam === 'sent') {
|
||||
return { type: 'info', message: t.alerts.verificationSent }
|
||||
}
|
||||
if (statusParam === 'resent') {
|
||||
return {
|
||||
type: 'success',
|
||||
message: t.alerts.verificationResent ?? t.alerts.verificationSent,
|
||||
}
|
||||
}
|
||||
if (errorParam) {
|
||||
const normalized = errorParam
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
const errorMap: Record<string, string> = {
|
||||
missing_verification: t.alerts.codeRequired,
|
||||
verification_failed: t.alerts.verificationFailed,
|
||||
invalid_code: t.alerts.verificationFailed,
|
||||
invalid_email: t.alerts.missingEmail,
|
||||
code_required: t.alerts.codeRequired,
|
||||
}
|
||||
const message = errorMap[normalized] ?? t.alerts.genericError
|
||||
return { type: normalized === 'already_verified' ? 'success' : 'error', message }
|
||||
}
|
||||
if (!email) {
|
||||
return { type: 'info', message: t.alerts.missingEmail }
|
||||
}
|
||||
return null
|
||||
}, [email, errorParam, statusParam, t.alerts])
|
||||
|
||||
const [alert, setAlert] = useState<AlertState | null>(initialAlert)
|
||||
const [code, setCode] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const [resendCooldown, setResendCooldown] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setAlert(initialAlert)
|
||||
}, [initialAlert])
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
setResendCooldown((previous) => Math.max(previous - 1, 0))
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [resendCooldown])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (redirectTimeoutRef.current !== null) {
|
||||
globalThis.clearTimeout(redirectTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCodeChange = useCallback((event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const digitsOnly = target.value.replace(/\D/g, '').slice(0, VERIFICATION_CODE_LENGTH)
|
||||
setCode(digitsOnly)
|
||||
}, [])
|
||||
|
||||
const hasEmail = email.length > 0
|
||||
const isSubmitDisabled = isSubmitting || !hasEmail || code.length !== VERIFICATION_CODE_LENGTH
|
||||
const isResendDisabled = isResending || resendCooldown > 0 || !hasEmail
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: Event) => {
|
||||
event.preventDefault()
|
||||
if (isSubmitting) {
|
||||
return
|
||||
}
|
||||
if (!hasEmail) {
|
||||
setAlert({ type: 'error', message: t.alerts.missingEmail })
|
||||
return
|
||||
}
|
||||
if (code.length !== VERIFICATION_CODE_LENGTH) {
|
||||
setAlert({ type: 'error', message: t.alerts.codeRequired })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setAlert(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, code }),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
success?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed'
|
||||
const normalized = errorCode
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
if (normalized === 'already_verified') {
|
||||
const message = t.alerts.verificationReady ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message })
|
||||
redirectTimeoutRef.current = globalThis.setTimeout(() => {
|
||||
globalThis.location.href = '/login?registered=1'
|
||||
}, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
missing_verification: t.alerts.codeRequired,
|
||||
invalid_code: t.alerts.verificationFailed,
|
||||
verification_failed: t.alerts.verificationFailed,
|
||||
invalid_email: t.alerts.missingEmail,
|
||||
code_expired: t.alerts.verificationFailed,
|
||||
}
|
||||
const message = errorMap[normalized] ?? t.alerts.genericError
|
||||
setAlert({ type: 'error', message })
|
||||
return
|
||||
}
|
||||
|
||||
const successMessage = t.alerts.verificationReady ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message: successMessage })
|
||||
setCode('')
|
||||
redirectTimeoutRef.current = globalThis.setTimeout(() => {
|
||||
globalThis.location.href = '/login?registered=1'
|
||||
}, 1200)
|
||||
} catch (error) {
|
||||
console.error('Email verification request failed', error)
|
||||
setAlert({ type: 'error', message: t.alerts.genericError })
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[code, email, hasEmail, isSubmitting, t.alerts],
|
||||
)
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (isResending || !hasEmail) {
|
||||
if (!hasEmail) {
|
||||
setAlert({ type: 'error', message: t.alerts.missingEmail })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setIsResending(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-email/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
success?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed'
|
||||
const normalized = errorCode
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
if (normalized === 'already_verified') {
|
||||
const message = t.alerts.verificationReady ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message })
|
||||
redirectTimeoutRef.current = globalThis.setTimeout(() => {
|
||||
globalThis.location.href = '/login?registered=1'
|
||||
}, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
invalid_email: t.alerts.missingEmail,
|
||||
verification_failed: t.alerts.verificationFailed,
|
||||
rate_limited: t.alerts.genericError,
|
||||
}
|
||||
const message = errorMap[normalized] ?? t.alerts.genericError
|
||||
setAlert({ type: 'error', message })
|
||||
return
|
||||
}
|
||||
|
||||
const successMessage = t.alerts.verificationResent ?? t.alerts.verificationSent
|
||||
setAlert({ type: 'success', message: successMessage })
|
||||
setResendCooldown(RESEND_COOLDOWN_SECONDS)
|
||||
} catch (error) {
|
||||
console.error('Email verification resend failed', error)
|
||||
setAlert({ type: 'error', message: t.alerts.genericError })
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}, [email, hasEmail, isResending, t.alerts])
|
||||
|
||||
const resendLabel = isResending
|
||||
? t.resend.resending ?? t.resend.label
|
||||
: resendCooldown > 0
|
||||
? `${t.resend.label} (${resendCooldown}s)`
|
||||
: t.resend.label
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
mode="register"
|
||||
badge={t.badge}
|
||||
title={t.title}
|
||||
description={description}
|
||||
alert={alert}
|
||||
switchAction={{ text: t.switchAction.text, linkLabel: t.switchAction.link, href: '/login' }}
|
||||
footnote={t.footnote}
|
||||
bottomNote={t.bottomNote}
|
||||
>
|
||||
<form class="space-y-5" onSubmit={handleSubmit} noValidate>
|
||||
<div class="space-y-2">
|
||||
<label htmlFor="verification-code" class="text-sm font-medium text-slate-600">
|
||||
{t.form.codeLabel}
|
||||
</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t.form.codePlaceholder}
|
||||
class="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
value={code}
|
||||
onInput={handleCodeChange}
|
||||
disabled={isSubmitting || !hasEmail}
|
||||
aria-describedby="verification-code-help"
|
||||
/>
|
||||
{t.form.helper ? (
|
||||
<p id="verification-code-help" class="text-xs text-slate-500">
|
||||
{t.form.helper}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
class="inline-flex w-full items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isResendDisabled}
|
||||
>
|
||||
{resendLabel}
|
||||
</button>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
31
dashboard-fresh/islands/LogoutHandler.tsx
Normal file
31
dashboard-fresh/islands/LogoutHandler.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* LogoutHandler Island - Client-side logout logic
|
||||
*
|
||||
* Handles automatic redirect after logout
|
||||
*/
|
||||
|
||||
import { useEffect } from 'preact/hooks'
|
||||
|
||||
export default function LogoutHandler() {
|
||||
useEffect(() => {
|
||||
// Clear any client-side storage
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('auth_token')
|
||||
sessionStorage.clear()
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear storage:', error)
|
||||
}
|
||||
|
||||
// Redirect to home after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Login API Handler - Fresh + Deno
|
||||
*
|
||||
* POST /api/auth/login - Authenticate user
|
||||
* GET /api/auth/login - Method not allowed
|
||||
* POST /api/auth/login?step=check_email - Check if email exists and MFA is enabled
|
||||
* POST /api/auth/login?step=login - Authenticate user with email and password
|
||||
* POST /api/auth/login?step=verify_mfa - Verify MFA code
|
||||
* DELETE /api/auth/login - Clear MFA and session cookies
|
||||
*/
|
||||
|
||||
@ -16,20 +17,34 @@ import {
|
||||
getCookies,
|
||||
MFA_COOKIE_NAME,
|
||||
} from '@/lib/authGateway.deno.ts'
|
||||
import { getAccountServiceApiBaseUrl } from '@/server/serviceConfig.deno.ts'
|
||||
import { getAuthUrl } from '@/config/runtime-loader.ts'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface CheckEmailPayload {
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
email?: string
|
||||
password?: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
interface VerifyMfaPayload {
|
||||
totp?: string
|
||||
code?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
interface AccountLoginResponse {
|
||||
interface CheckEmailResponse {
|
||||
exists: boolean
|
||||
mfa_enabled: boolean
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token?: string
|
||||
expiresAt?: string
|
||||
error?: string
|
||||
@ -38,115 +53,389 @@ interface AccountLoginResponse {
|
||||
mfaEnabled?: boolean
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
interface VerifyMfaResponse {
|
||||
token?: string
|
||||
expiresAt?: string
|
||||
error?: string
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean
|
||||
error?: string | null
|
||||
needMfa?: boolean
|
||||
mfaEnabled?: boolean
|
||||
exists?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type LoginStep = 'check_email' | 'login' | 'verify_mfa'
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Normalize email address
|
||||
*/
|
||||
function normalizeEmail(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
function normalizeCode(value: unknown) {
|
||||
/**
|
||||
* Normalize TOTP code (6 digits only)
|
||||
*/
|
||||
function normalizeCode(value: unknown): string {
|
||||
return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standard JSON response
|
||||
*/
|
||||
function jsonResponse(data: ApiResponse, status = 200, headers?: HeadersInit): Response {
|
||||
const responseHeaders = new Headers(headers)
|
||||
if (!responseHeaders.has('Content-Type')) {
|
||||
responseHeaders.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error response
|
||||
*/
|
||||
function errorResponse(
|
||||
error: string,
|
||||
status = 400,
|
||||
additionalData?: Partial<ApiResponse>,
|
||||
): Response {
|
||||
return jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
error,
|
||||
needMfa: false,
|
||||
...additionalData,
|
||||
},
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic proxy function to call external auth API
|
||||
*
|
||||
* @param endpoint - API endpoint path (e.g., '/api/auth/check_email')
|
||||
* @param body - Request payload
|
||||
* @param timeout - Request timeout in milliseconds
|
||||
* @returns Response from external API
|
||||
*/
|
||||
async function proxy<T>(
|
||||
endpoint: string,
|
||||
body: Record<string, unknown>,
|
||||
timeout = 10000,
|
||||
): Promise<{ ok: boolean; status: number; data: T }> {
|
||||
const authUrl = await getAuthUrl()
|
||||
const url = `${authUrl}${endpoint}`
|
||||
|
||||
console.log(`[login-proxy] → ${endpoint}`, { email: body.email || 'N/A' })
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({})) as T
|
||||
|
||||
console.log(`[login-proxy] ← ${endpoint} [${response.status}]`, {
|
||||
ok: response.ok,
|
||||
hasData: !!data,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[login-proxy] ✗ ${endpoint} failed:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Step Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Step 1: Check if email exists and MFA is enabled
|
||||
*/
|
||||
async function handleCheckEmail(payload: CheckEmailPayload): Promise<Response> {
|
||||
const email = normalizeEmail(payload?.email)
|
||||
|
||||
if (!email) {
|
||||
return errorResponse('missing_email', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, status, data } = await proxy<CheckEmailResponse>(
|
||||
'/api/auth/check_email',
|
||||
{ email },
|
||||
)
|
||||
|
||||
if (ok) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
error: null,
|
||||
exists: data.exists,
|
||||
mfaEnabled: data.mfa_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
return errorResponse('check_email_failed', status, {
|
||||
exists: false,
|
||||
mfaEnabled: false,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[login] check_email error:', error)
|
||||
return errorResponse('account_service_unreachable', 502)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Login with email and password
|
||||
*/
|
||||
async function handleLogin(payload: LoginPayload): Promise<Response> {
|
||||
console.log('[login/handleLogin] Starting login process')
|
||||
|
||||
const email = normalizeEmail(payload?.email)
|
||||
const password = typeof payload?.password === 'string' ? payload.password : ''
|
||||
const remember = Boolean(payload?.remember)
|
||||
|
||||
console.log('[login/handleLogin] Email:', email || '(empty)')
|
||||
console.log('[login/handleLogin] Has password:', !!password)
|
||||
console.log('[login/handleLogin] Remember:', remember)
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('[login/handleLogin] ✗ Missing credentials')
|
||||
return errorResponse('missing_credentials', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[login/handleLogin] Calling proxy to backend...')
|
||||
const { ok, status, data } = await proxy<LoginResponse>('/api/auth/login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
console.log('[login/handleLogin] Backend response - ok:', ok, 'status:', status)
|
||||
|
||||
// Successful login with token
|
||||
if (ok && data?.token) {
|
||||
const maxAgeFromBackend = deriveMaxAgeFromExpires(data.expiresAt)
|
||||
const effectiveMaxAge = remember
|
||||
? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30)
|
||||
: maxAgeFromBackend
|
||||
|
||||
const headers = new Headers()
|
||||
applySessionCookie(headers, data.token, effectiveMaxAge)
|
||||
clearMfaCookie(headers)
|
||||
|
||||
console.log('[login/handleLogin] ✓ Login successful, token set')
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
success: true,
|
||||
error: null,
|
||||
needMfa: false,
|
||||
},
|
||||
200,
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
// MFA required
|
||||
const errorCode = typeof data?.error === 'string' ? data.error : 'authentication_failed'
|
||||
const needsMfa = Boolean(
|
||||
data?.needMfa ||
|
||||
errorCode === 'mfa_required' ||
|
||||
errorCode === 'mfa_setup_required',
|
||||
)
|
||||
|
||||
if (needsMfa && data?.mfaToken) {
|
||||
const headers = new Headers()
|
||||
applyMfaCookie(headers, data.mfaToken)
|
||||
clearSessionCookie(headers)
|
||||
|
||||
console.log('[login/handleLogin] → MFA required, mfa_token set')
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
error: errorCode,
|
||||
needMfa: true,
|
||||
},
|
||||
401,
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
// Authentication failed
|
||||
const headers = new Headers()
|
||||
clearSessionCookie(headers)
|
||||
clearMfaCookie(headers)
|
||||
|
||||
console.log('[login/handleLogin] ✗ Authentication failed:', errorCode)
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
error: errorCode,
|
||||
needMfa: false,
|
||||
},
|
||||
status || 401,
|
||||
headers,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[login/handleLogin] ✗ Exception:', error)
|
||||
|
||||
const headers = new Headers()
|
||||
clearSessionCookie(headers)
|
||||
clearMfaCookie(headers)
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
error: 'account_service_unreachable',
|
||||
needMfa: false,
|
||||
},
|
||||
502,
|
||||
headers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Verify MFA code
|
||||
*/
|
||||
async function handleVerifyMfa(
|
||||
payload: VerifyMfaPayload,
|
||||
req: Request,
|
||||
): Promise<Response> {
|
||||
const totpCode = normalizeCode(payload?.totp ?? payload?.code)
|
||||
const mfaToken = payload?.token
|
||||
|
||||
if (!totpCode) {
|
||||
return errorResponse('missing_totp_code', 400)
|
||||
}
|
||||
|
||||
// Get MFA token from cookie if not in payload
|
||||
let effectiveMfaToken = mfaToken
|
||||
if (!effectiveMfaToken) {
|
||||
const cookies = getCookies(req)
|
||||
effectiveMfaToken = cookies.get(MFA_COOKIE_NAME)
|
||||
}
|
||||
|
||||
if (!effectiveMfaToken) {
|
||||
return errorResponse('missing_mfa_token', 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, status, data } = await proxy<VerifyMfaResponse>('/api/auth/verify_mfa', {
|
||||
totpCode,
|
||||
mfaToken: effectiveMfaToken,
|
||||
})
|
||||
|
||||
// MFA verification successful
|
||||
if (ok && data?.token) {
|
||||
const maxAgeFromBackend = deriveMaxAgeFromExpires(data.expiresAt)
|
||||
|
||||
const headers = new Headers()
|
||||
applySessionCookie(headers, data.token, maxAgeFromBackend)
|
||||
clearMfaCookie(headers)
|
||||
|
||||
console.log('[login] ✓ MFA verification successful')
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
success: true,
|
||||
error: null,
|
||||
needMfa: false,
|
||||
},
|
||||
200,
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
// MFA verification failed
|
||||
const errorCode = typeof data?.error === 'string' ? data.error : 'mfa_verification_failed'
|
||||
|
||||
console.log('[login] ✗ MFA verification failed:', errorCode)
|
||||
|
||||
return errorResponse(errorCode, status || 401, { needMfa: true })
|
||||
} catch (error) {
|
||||
console.error('[login] verify_mfa error:', error)
|
||||
return errorResponse('account_service_unreachable', 502)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Handlers
|
||||
// ============================================================================
|
||||
|
||||
export const handler: Handlers = {
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate user with email and password
|
||||
* Handle multi-step login flow based on step parameter
|
||||
*/
|
||||
async POST(req, _ctx) {
|
||||
let payload: LoginPayload
|
||||
console.log('[login] ===== Request received =====')
|
||||
console.log('[login] Method:', req.method)
|
||||
console.log('[login] URL:', req.url)
|
||||
|
||||
// Parse step from query parameter
|
||||
const url = new URL(req.url)
|
||||
const step = url.searchParams.get('step') as LoginStep | null
|
||||
console.log('[login] Step parameter:', step || 'null (backward compatibility mode)')
|
||||
|
||||
// Parse request payload
|
||||
let payload: CheckEmailPayload | LoginPayload | VerifyMfaPayload
|
||||
try {
|
||||
payload = (await req.json()) as LoginPayload
|
||||
payload = await req.json()
|
||||
console.log('[login] Payload parsed, keys:', Object.keys(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to decode login payload', error)
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'invalid_request', needMfa: false }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
console.error('[login] Failed to parse request body:', error)
|
||||
return errorResponse('invalid_request', 400)
|
||||
}
|
||||
|
||||
const email = normalizeEmail(payload?.email)
|
||||
const password = typeof payload?.password === 'string' ? payload.password : ''
|
||||
const totpCode = normalizeCode(payload?.totp ?? payload?.code)
|
||||
const remember = Boolean(payload?.remember)
|
||||
// Route to appropriate step handler
|
||||
switch (step) {
|
||||
case 'check_email':
|
||||
console.log('[login] → Routing to handleCheckEmail')
|
||||
return await handleCheckEmail(payload as CheckEmailPayload)
|
||||
|
||||
if (!email || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'missing_credentials', needMfa: false }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
case 'login':
|
||||
console.log('[login] → Routing to handleLogin')
|
||||
return await handleLogin(payload as LoginPayload)
|
||||
|
||||
case 'verify_mfa':
|
||||
console.log('[login] → Routing to handleVerifyMfa')
|
||||
return await handleVerifyMfa(payload as VerifyMfaPayload, req)
|
||||
|
||||
default:
|
||||
// Backward compatibility: if no step specified, assume login
|
||||
if (!step) {
|
||||
console.log('[login] → Backward compatibility: routing to handleLogin')
|
||||
return await handleLogin(payload as LoginPayload)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const loginBody: Record<string, string> = { email, password }
|
||||
if (totpCode) {
|
||||
loginBody.totpCode = totpCode
|
||||
}
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(loginBody),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
const data = (await response.json().catch(() => ({}))) as AccountLoginResponse
|
||||
|
||||
if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
|
||||
const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt)
|
||||
const effectiveMaxAge = remember ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30) : maxAgeFromBackend
|
||||
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
applySessionCookie(headers, data.token, effectiveMaxAge)
|
||||
clearMfaCookie(headers)
|
||||
|
||||
return new Response(JSON.stringify({ success: true, error: null, needMfa: false }), {
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const errorCode = typeof data?.error === 'string' ? data.error : 'authentication_failed'
|
||||
const needsMfa = Boolean(data?.needMfa || errorCode === 'mfa_required' || errorCode === 'mfa_setup_required')
|
||||
|
||||
if ((response.status === 401 || response.status === 403 || needsMfa) && typeof data?.mfaToken === 'string') {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
applyMfaCookie(headers, data.mfaToken)
|
||||
clearSessionCookie(headers)
|
||||
|
||||
return new Response(JSON.stringify({ success: false, error: errorCode, needMfa: true }), {
|
||||
status: 401,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const statusCode = response.status || 401
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
clearSessionCookie(headers)
|
||||
clearMfaCookie(headers)
|
||||
|
||||
return new Response(JSON.stringify({ success: false, error: errorCode, needMfa: false }), {
|
||||
status: statusCode,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Account service login proxy failed', error)
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
clearSessionCookie(headers)
|
||||
clearMfaCookie(headers)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'account_service_unreachable', needMfa: false }),
|
||||
{
|
||||
status: 502,
|
||||
headers,
|
||||
}
|
||||
)
|
||||
console.error('[login] ✗ Invalid step parameter:', step)
|
||||
return errorResponse('invalid_step', 400)
|
||||
}
|
||||
},
|
||||
|
||||
@ -155,16 +444,7 @@ export const handler: Handlers = {
|
||||
* Method not allowed
|
||||
*/
|
||||
GET(_req, _ctx) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'method_not_allowed', needMfa: false }),
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Allow: 'POST',
|
||||
},
|
||||
}
|
||||
)
|
||||
return errorResponse('method_not_allowed', 405)
|
||||
},
|
||||
|
||||
/**
|
||||
@ -173,15 +453,23 @@ export const handler: Handlers = {
|
||||
*/
|
||||
DELETE(req, _ctx) {
|
||||
const cookies = getCookies(req)
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' })
|
||||
const headers = new Headers()
|
||||
|
||||
if (cookies.has(MFA_COOKIE_NAME)) {
|
||||
clearMfaCookie(headers)
|
||||
}
|
||||
clearSessionCookie(headers)
|
||||
|
||||
return new Response(JSON.stringify({ success: true, error: null, needMfa: false }), {
|
||||
console.log('[login] ✓ Session cleared')
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
success: true,
|
||||
error: null,
|
||||
needMfa: false,
|
||||
},
|
||||
200,
|
||||
headers,
|
||||
})
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
144
dashboard-fresh/routes/download/[...segments]/index.tsx
Normal file
144
dashboard-fresh/routes/download/[...segments]/index.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Download Listing - Fresh + Deno
|
||||
*
|
||||
* Dynamic download listing page for specific paths
|
||||
*/
|
||||
|
||||
import { Head } from '$fresh/runtime.ts'
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
|
||||
import DownloadListingContent from '@/components/download/DownloadListingContent.tsx'
|
||||
import DownloadNotFound from '@/components/download/DownloadNotFound.tsx'
|
||||
import {
|
||||
buildSectionsForListing,
|
||||
countFiles,
|
||||
findListing,
|
||||
formatSegmentLabel,
|
||||
} from '@/lib/download-data.ts'
|
||||
import { getDownloadListings } from '@/lib/download-manifest.ts'
|
||||
import type { DirListing } from '@/types/download.ts'
|
||||
|
||||
interface DownloadListingData {
|
||||
found: boolean
|
||||
segments: string[]
|
||||
title?: string
|
||||
subdirectorySections?: any[]
|
||||
fileListing?: DirListing
|
||||
totalFiles?: number
|
||||
latestModified?: string
|
||||
relativePath?: string
|
||||
remotePath?: string
|
||||
}
|
||||
|
||||
function getLatestModified(listing: DirListing): string | undefined {
|
||||
let latest: string | undefined
|
||||
for (const entry of listing.entries) {
|
||||
if (entry.lastModified && (!latest || entry.lastModified > latest)) {
|
||||
latest = entry.lastModified
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
export const handler: Handlers<DownloadListingData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
const allListings = getDownloadListings()
|
||||
const rawSegments = ctx.params.segments ? ctx.params.segments.split('/') : []
|
||||
|
||||
const segments = rawSegments
|
||||
.map((segment) => segment.trim().replace(/\/+$/g, ''))
|
||||
.filter((segment) => segment.length > 0)
|
||||
|
||||
// Empty segments means root redirect
|
||||
if (segments.length === 0) {
|
||||
return ctx.render({
|
||||
found: false,
|
||||
segments: [],
|
||||
})
|
||||
}
|
||||
|
||||
const listing = findListing(allListings, segments)
|
||||
|
||||
// Listing not found
|
||||
if (!listing) {
|
||||
return ctx.render({
|
||||
found: false,
|
||||
segments,
|
||||
})
|
||||
}
|
||||
|
||||
const subdirectorySections = buildSectionsForListing(listing, allListings, segments)
|
||||
const fileEntries = listing.entries.filter((entry) => entry.type === 'file')
|
||||
const fileListing: DirListing = { path: listing.path, entries: fileEntries }
|
||||
|
||||
const totalFiles = countFiles(listing, allListings)
|
||||
const latestModified = getLatestModified(listing)
|
||||
const displayTitle = formatSegmentLabel(segments[segments.length - 1] ?? '')
|
||||
const relativePath = segments.join('/')
|
||||
const remotePath = `https://dl.svc.plus/${listing.path}`
|
||||
|
||||
return ctx.render({
|
||||
found: true,
|
||||
segments,
|
||||
title: displayTitle,
|
||||
subdirectorySections,
|
||||
fileListing,
|
||||
totalFiles,
|
||||
latestModified,
|
||||
relativePath,
|
||||
remotePath,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default function DownloadListing({ data }: PageProps<DownloadListingData>) {
|
||||
const {
|
||||
found,
|
||||
segments,
|
||||
title,
|
||||
subdirectorySections,
|
||||
fileListing,
|
||||
totalFiles,
|
||||
latestModified,
|
||||
relativePath,
|
||||
remotePath,
|
||||
} = data
|
||||
|
||||
if (!found) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Download Not Found - CloudNative Suite</title>
|
||||
</Head>
|
||||
<main class="px-4 py-10 md:px-8">
|
||||
<DownloadNotFound />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title} - Download - CloudNative Suite</title>
|
||||
<meta name="description" content={`Download ${title} packages and resources`} />
|
||||
</Head>
|
||||
|
||||
<main class="px-4 py-10 md:px-8">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<DownloadListingContent
|
||||
segments={segments}
|
||||
title={title!}
|
||||
subdirectorySections={subdirectorySections!}
|
||||
fileListing={fileListing!}
|
||||
totalFiles={totalFiles!}
|
||||
latestModified={latestModified}
|
||||
relativePath={relativePath!}
|
||||
remotePath={remotePath!}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
79
dashboard-fresh/routes/download/index.tsx
Normal file
79
dashboard-fresh/routes/download/index.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Download Center Home - Fresh + Deno
|
||||
*
|
||||
* Download center landing page with summary and browser
|
||||
*/
|
||||
|
||||
import { Head } from '$fresh/runtime.ts'
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { isFeatureEnabled } from '@/lib/featureToggles.ts'
|
||||
|
||||
import DownloadBrowser from '@/components/download/DownloadBrowser.tsx'
|
||||
import DownloadSummary from '@/components/download/DownloadSummary.tsx'
|
||||
import { buildDownloadSections, countFiles, findListing } from '@/lib/download-data.ts'
|
||||
import { getDownloadListings } from '@/lib/download-manifest.ts'
|
||||
|
||||
interface DownloadHomeData {
|
||||
topLevelCount: number
|
||||
totalCollections: number
|
||||
totalFiles: number
|
||||
sectionsMap: ReturnType<typeof buildDownloadSections>
|
||||
}
|
||||
|
||||
export const handler: Handlers<DownloadHomeData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
// Check if download module is enabled
|
||||
if (!isFeatureEnabled('appModules', '/download')) {
|
||||
return new Response('', {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
})
|
||||
}
|
||||
|
||||
const allListings = getDownloadListings()
|
||||
const sectionsMap = buildDownloadSections(allListings)
|
||||
const rootListing = findListing(allListings, [])
|
||||
const topLevelDirectories = rootListing?.entries.filter((entry) => entry.type === 'dir') ?? []
|
||||
|
||||
const totalCollections = Object.values(sectionsMap).reduce(
|
||||
(total, sections) => total + sections.length,
|
||||
0,
|
||||
)
|
||||
const totalFiles = topLevelDirectories.reduce((total, entry) => {
|
||||
const listing = findListing(allListings, [entry.name])
|
||||
return total + (listing ? countFiles(listing, allListings) : 0)
|
||||
}, 0)
|
||||
|
||||
return ctx.render({
|
||||
topLevelCount: topLevelDirectories.length,
|
||||
totalCollections,
|
||||
totalFiles,
|
||||
sectionsMap,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default function DownloadHome({ data }: PageProps<DownloadHomeData>) {
|
||||
const { topLevelCount, totalCollections, totalFiles, sectionsMap } = data
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Download Center - CloudNative Suite</title>
|
||||
<meta name="description" content="Download packages, tools and resources for CloudNative Suite" />
|
||||
</Head>
|
||||
|
||||
<main class="px-4 py-10 md:px-8">
|
||||
<div class="mx-auto max-w-7xl space-y-8">
|
||||
<DownloadSummary
|
||||
topLevelCount={topLevelCount}
|
||||
totalCollections={totalCollections}
|
||||
totalFiles={totalFiles}
|
||||
/>
|
||||
<DownloadBrowser sectionsMap={sectionsMap} />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
70
dashboard-fresh/routes/email-verification.tsx
Normal file
70
dashboard-fresh/routes/email-verification.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Email Verification Page - Fresh + Deno
|
||||
*
|
||||
* Email verification page for user registration
|
||||
*/
|
||||
|
||||
import { Head } from '$fresh/runtime.ts'
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
|
||||
// Import Islands
|
||||
import EmailVerificationForm from '@/islands/EmailVerificationForm.tsx'
|
||||
|
||||
interface EmailVerificationData {
|
||||
email: string
|
||||
status: string | null
|
||||
error: string | null
|
||||
language: 'zh' | 'en'
|
||||
}
|
||||
|
||||
export const handler: Handlers<EmailVerificationData, FreshState> = {
|
||||
async GET(req, ctx) {
|
||||
const url = new URL(req.url)
|
||||
const searchParams = url.searchParams
|
||||
|
||||
// Try to get email from various query parameters
|
||||
const emailKeys = ['email', 'address', 'identifier', 'account']
|
||||
let email = ''
|
||||
for (const key of emailKeys) {
|
||||
const value = searchParams.get(key)
|
||||
if (value && value.trim().length > 0) {
|
||||
email = value.trim().toLowerCase()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const status = searchParams.get('status')
|
||||
const error = searchParams.get('error')
|
||||
const language = 'zh' // TODO: Get from cookie or state
|
||||
|
||||
return ctx.render({
|
||||
email,
|
||||
status,
|
||||
error,
|
||||
language,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default function EmailVerificationPage({ data }: PageProps<EmailVerificationData>) {
|
||||
const { email, status, error, language } = data
|
||||
|
||||
const title = language === 'zh' ? '验证邮箱' : 'Verify Email'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title} - CloudNative Suite</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</Head>
|
||||
|
||||
<EmailVerificationForm
|
||||
email={email}
|
||||
status={status}
|
||||
error={error}
|
||||
language={language}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
105
dashboard-fresh/routes/logout.tsx
Normal file
105
dashboard-fresh/routes/logout.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Logout Page - Fresh + Deno
|
||||
*
|
||||
* User logout page with automatic redirect
|
||||
*/
|
||||
|
||||
import { Head } from '$fresh/runtime.ts'
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { deleteCookie } from '$std/http/cookie.ts'
|
||||
|
||||
// Import Islands for client-side interactivity
|
||||
import Navbar from '@/islands/Navbar.tsx'
|
||||
import LogoutHandler from '@/islands/LogoutHandler.tsx'
|
||||
|
||||
interface LogoutPageData {
|
||||
language: 'zh' | 'en'
|
||||
user: { username?: string; email?: string } | null
|
||||
}
|
||||
|
||||
export const handler: Handlers<LogoutPageData, FreshState> = {
|
||||
async GET(req, ctx) {
|
||||
const language = 'zh' // TODO: Get from cookie or state
|
||||
|
||||
// Perform server-side logout
|
||||
const headers = new Headers()
|
||||
|
||||
// Delete session cookie
|
||||
deleteCookie(headers, 'xc_session', {
|
||||
path: '/',
|
||||
domain: new URL(req.url).hostname,
|
||||
})
|
||||
|
||||
// Delete auth token cookie if exists
|
||||
deleteCookie(headers, 'auth_token', {
|
||||
path: '/',
|
||||
domain: new URL(req.url).hostname,
|
||||
})
|
||||
|
||||
return ctx.render({
|
||||
language,
|
||||
user: null,
|
||||
}, { headers })
|
||||
},
|
||||
}
|
||||
|
||||
export default function LogoutPage({ data }: PageProps<LogoutPageData>) {
|
||||
const { language } = data
|
||||
|
||||
const title = language === 'zh' ? '退出登录' : 'Logout'
|
||||
const signingOut = language === 'zh' ? '正在安全退出,请稍候…' : 'Signing you out safely. One moment…'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title} - CloudNative Suite</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</Head>
|
||||
|
||||
{/* Fixed Navbar */}
|
||||
<Navbar language={language} user={null} pathname="/logout" />
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
class="flex min-h-screen flex-col bg-gray-50"
|
||||
style="padding-top: var(--app-shell-nav-offset, 4rem)"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-center px-4 pb-16 pt-28 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md rounded-3xl bg-white p-10 text-center shadow-xl ring-1 ring-gray-100">
|
||||
{/* Spinning icon */}
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-purple-100 text-purple-600">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="mt-6 text-2xl font-semibold text-gray-900">{title}</h1>
|
||||
<p class="mt-3 text-sm text-gray-600">{signingOut}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Client-side redirect handler */}
|
||||
<LogoutHandler />
|
||||
</>
|
||||
)
|
||||
}
|
||||
36
dashboard-fresh/routes/panel/agent.tsx
Normal file
36
dashboard-fresh/routes/panel/agent.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Agent Page - Fresh + Deno
|
||||
*
|
||||
* Agent management panel page (extension-based)
|
||||
*/
|
||||
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { resolveExtensionRouteComponent } from '@/src/extensions/loader.ts'
|
||||
|
||||
interface AgentPageData {
|
||||
Component: any
|
||||
}
|
||||
|
||||
export const handler: Handlers<AgentPageData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
try {
|
||||
const Component = await resolveExtensionRouteComponent('/panel/agent')
|
||||
return ctx.render({ Component })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('disabled')) {
|
||||
// Extension is disabled, redirect to panel home
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: '/panel' },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default function AgentPage({ data }: PageProps<AgentPageData>) {
|
||||
const { Component } = data
|
||||
return <Component />
|
||||
}
|
||||
36
dashboard-fresh/routes/panel/api.tsx
Normal file
36
dashboard-fresh/routes/panel/api.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* API Page - Fresh + Deno
|
||||
*
|
||||
* API management panel page (extension-based)
|
||||
*/
|
||||
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { resolveExtensionRouteComponent } from '@/src/extensions/loader.ts'
|
||||
|
||||
interface ApiPageData {
|
||||
Component: any
|
||||
}
|
||||
|
||||
export const handler: Handlers<ApiPageData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
try {
|
||||
const Component = await resolveExtensionRouteComponent('/panel/api')
|
||||
return ctx.render({ Component })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('disabled')) {
|
||||
// Extension is disabled, redirect to panel home
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: '/panel' },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default function ApiPage({ data }: PageProps<ApiPageData>) {
|
||||
const { Component } = data
|
||||
return <Component />
|
||||
}
|
||||
36
dashboard-fresh/routes/panel/appearance.tsx
Normal file
36
dashboard-fresh/routes/panel/appearance.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Appearance Page - Fresh + Deno
|
||||
*
|
||||
* Appearance settings panel page (extension-based)
|
||||
*/
|
||||
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { resolveExtensionRouteComponent } from '@/src/extensions/loader.ts'
|
||||
|
||||
interface AppearancePageData {
|
||||
Component: any
|
||||
}
|
||||
|
||||
export const handler: Handlers<AppearancePageData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
try {
|
||||
const Component = await resolveExtensionRouteComponent('/panel/appearance')
|
||||
return ctx.render({ Component })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('disabled')) {
|
||||
// Extension is disabled, redirect to panel home
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: '/panel' },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default function AppearancePage({ data }: PageProps<AppearancePageData>) {
|
||||
const { Component } = data
|
||||
return <Component />
|
||||
}
|
||||
36
dashboard-fresh/routes/panel/ldp.tsx
Normal file
36
dashboard-fresh/routes/panel/ldp.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* LDP Page - Fresh + Deno
|
||||
*
|
||||
* LDP (Log Data Platform) panel page (extension-based)
|
||||
*/
|
||||
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { resolveExtensionRouteComponent } from '@/src/extensions/loader.ts'
|
||||
|
||||
interface LdpPageData {
|
||||
Component: any
|
||||
}
|
||||
|
||||
export const handler: Handlers<LdpPageData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
try {
|
||||
const Component = await resolveExtensionRouteComponent('/panel/ldp')
|
||||
return ctx.render({ Component })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('disabled')) {
|
||||
// Extension is disabled, redirect to panel home
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: '/panel' },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default function LdpPage({ data }: PageProps<LdpPageData>) {
|
||||
const { Component } = data
|
||||
return <Component />
|
||||
}
|
||||
36
dashboard-fresh/routes/panel/management.tsx
Normal file
36
dashboard-fresh/routes/panel/management.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Management Page - Fresh + Deno
|
||||
*
|
||||
* User management panel page (extension-based)
|
||||
*/
|
||||
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { resolveExtensionRouteComponent } from '@/src/extensions/loader.ts'
|
||||
|
||||
interface ManagementPageData {
|
||||
Component: any
|
||||
}
|
||||
|
||||
export const handler: Handlers<ManagementPageData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
try {
|
||||
const Component = await resolveExtensionRouteComponent('/panel/management')
|
||||
return ctx.render({ Component })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('disabled')) {
|
||||
// Extension is disabled, redirect to panel home
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: '/panel' },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default function ManagementPage({ data }: PageProps<ManagementPageData>) {
|
||||
const { Component } = data
|
||||
return <Component />
|
||||
}
|
||||
36
dashboard-fresh/routes/panel/subscription.tsx
Normal file
36
dashboard-fresh/routes/panel/subscription.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Subscription Page - Fresh + Deno
|
||||
*
|
||||
* Subscription management panel page (extension-based)
|
||||
*/
|
||||
|
||||
import { Handlers, PageProps } from '$fresh/server.ts'
|
||||
import { FreshState } from '@/middleware.ts'
|
||||
import { resolveExtensionRouteComponent } from '@/src/extensions/loader.ts'
|
||||
|
||||
interface SubscriptionPageData {
|
||||
Component: any
|
||||
}
|
||||
|
||||
export const handler: Handlers<SubscriptionPageData, FreshState> = {
|
||||
async GET(_req, ctx) {
|
||||
try {
|
||||
const Component = await resolveExtensionRouteComponent('/panel/subscription')
|
||||
return ctx.render({ Component })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('disabled')) {
|
||||
// Extension is disabled, redirect to panel home
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: '/panel' },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default function SubscriptionPage({ data }: PageProps<SubscriptionPageData>) {
|
||||
const { Component } = data
|
||||
return <Component />
|
||||
}
|
||||
13
dashboard-fresh/scripts/dev-local.sh
Executable file
13
dashboard-fresh/scripts/dev-local.sh
Executable file
@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Start development server with local backend
|
||||
echo "🚀 Starting Fresh development server..."
|
||||
echo " Environment: SIT"
|
||||
echo " Backend: http://localhost:8080"
|
||||
echo ""
|
||||
|
||||
export RUNTIME_ENV=sit
|
||||
export AUTH_URL=http://localhost:8080
|
||||
export API_BASE_URL=http://localhost:8090
|
||||
|
||||
deno task dev
|
||||
17
dashboard-fresh/scripts/test-login.sh
Executable file
17
dashboard-fresh/scripts/test-login.sh
Executable file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Test login API
|
||||
echo "Testing login API..."
|
||||
echo ""
|
||||
|
||||
curl -X POST http://localhost:8003/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"email": "demo@svc.plus",
|
||||
"password": "demo",
|
||||
"remember": true
|
||||
}' \
|
||||
2>&1
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
199
dashboard-fresh/server/runtime-loader.deno.ts
Normal file
199
dashboard-fresh/server/runtime-loader.deno.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Deno Native Runtime Configuration Loader
|
||||
*
|
||||
* Loads environment-specific configuration from YAML files.
|
||||
* Supports SIT and PROD environments with region-based overrides.
|
||||
*/
|
||||
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
export type RuntimeEnvironment = 'prod' | 'sit'
|
||||
export type RuntimeRegion = 'default' | 'cn' | 'global'
|
||||
|
||||
export interface RuntimeConfig {
|
||||
apiBaseUrl: string
|
||||
authUrl: string
|
||||
dashboardUrl: string
|
||||
internalApiBaseUrl?: string
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error'
|
||||
environment: RuntimeEnvironment
|
||||
region: RuntimeRegion
|
||||
}
|
||||
|
||||
interface YamlConfig {
|
||||
apiBaseUrl?: string
|
||||
authUrl?: string
|
||||
dashboardUrl?: string
|
||||
internalApiBaseUrl?: string
|
||||
logLevel?: string
|
||||
regions?: {
|
||||
[key: string]: {
|
||||
apiBaseUrl?: string
|
||||
authUrl?: string
|
||||
dashboardUrl?: string
|
||||
internalApiBaseUrl?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for parsed configurations
|
||||
const configCache = new Map<string, RuntimeConfig>()
|
||||
|
||||
/**
|
||||
* Detect runtime environment from Deno.env
|
||||
*/
|
||||
function detectEnvironment(): RuntimeEnvironment {
|
||||
const env = Deno.env.get('RUNTIME_ENV') ||
|
||||
Deno.env.get('NODE_ENV') ||
|
||||
Deno.env.get('DENO_ENV') ||
|
||||
'prod'
|
||||
|
||||
const normalized = env.trim().toLowerCase()
|
||||
|
||||
if (['sit', 'staging', 'test', 'dev', 'development'].includes(normalized)) {
|
||||
return 'sit'
|
||||
}
|
||||
|
||||
return 'prod'
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect runtime region from Deno.env
|
||||
*/
|
||||
function detectRegion(): RuntimeRegion {
|
||||
const region = Deno.env.get('RUNTIME_REGION') ||
|
||||
Deno.env.get('REGION') ||
|
||||
'default'
|
||||
|
||||
const normalized = region.trim().toLowerCase()
|
||||
|
||||
if (normalized === 'cn' || normalized === 'china') {
|
||||
return 'cn'
|
||||
}
|
||||
|
||||
if (normalized === 'global') {
|
||||
return 'global'
|
||||
}
|
||||
|
||||
return 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse YAML configuration file
|
||||
*/
|
||||
async function loadYamlConfig(filename: string): Promise<YamlConfig> {
|
||||
try {
|
||||
const configPath = new URL(`../config/${filename}`, import.meta.url).pathname
|
||||
const content = await Deno.readTextFile(configPath)
|
||||
const parsed = yaml.load(content) as YamlConfig
|
||||
return parsed || {}
|
||||
} catch (error) {
|
||||
console.warn(`[runtime-config] Failed to load ${filename}:`, error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge configuration objects
|
||||
*/
|
||||
function mergeConfig(base: YamlConfig, override: YamlConfig): YamlConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
// Don't merge regions - they're environment-specific
|
||||
regions: override.regions || base.regions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load runtime configuration based on environment and region
|
||||
*/
|
||||
export async function loadRuntimeConfig(): Promise<RuntimeConfig> {
|
||||
const environment = detectEnvironment()
|
||||
const region = detectRegion()
|
||||
|
||||
const cacheKey = `${environment}:${region}`
|
||||
|
||||
// Return cached config if available
|
||||
if (configCache.has(cacheKey)) {
|
||||
return configCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
console.info(`[runtime-config] Loading ${environment.toUpperCase()} environment, ${region} region`)
|
||||
|
||||
// Load base configuration
|
||||
const baseConfig = await loadYamlConfig('runtime-service-config.base.yaml')
|
||||
|
||||
// Load environment-specific configuration
|
||||
const envConfig = await loadYamlConfig(`runtime-service-config.${environment}.yaml`)
|
||||
|
||||
// Merge base and environment configs
|
||||
let merged = mergeConfig(baseConfig, envConfig)
|
||||
|
||||
// Apply region-specific overrides if applicable
|
||||
if (region !== 'default' && merged.regions && merged.regions[region]) {
|
||||
const regionOverrides = merged.regions[region]
|
||||
merged = {
|
||||
...merged,
|
||||
...regionOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Build final configuration with defaults
|
||||
const config: RuntimeConfig = {
|
||||
apiBaseUrl: merged.apiBaseUrl || 'https://api.svc.plus',
|
||||
authUrl: merged.authUrl || 'https://accounts.svc.plus',
|
||||
dashboardUrl: merged.dashboardUrl || 'https://console.svc.plus',
|
||||
internalApiBaseUrl: merged.internalApiBaseUrl,
|
||||
logLevel: (merged.logLevel as RuntimeConfig['logLevel']) || 'info',
|
||||
environment,
|
||||
region,
|
||||
}
|
||||
|
||||
// Cache the configuration
|
||||
configCache.set(cacheKey, config)
|
||||
|
||||
console.info(`[runtime-config] Loaded: authUrl=${config.authUrl}, apiBaseUrl=${config.apiBaseUrl}`)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication service base URL
|
||||
*/
|
||||
export async function getAuthUrl(): Promise<string> {
|
||||
// Check environment variable override first
|
||||
const envOverride = Deno.env.get('AUTH_URL') || Deno.env.get('ACCOUNT_SERVICE_URL')
|
||||
if (envOverride) {
|
||||
return envOverride.trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const config = await loadRuntimeConfig()
|
||||
return config.authUrl.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API service base URL
|
||||
*/
|
||||
export async function getApiBaseUrl(): Promise<string> {
|
||||
const envOverride = Deno.env.get('API_BASE_URL')
|
||||
if (envOverride) {
|
||||
return envOverride.trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const config = await loadRuntimeConfig()
|
||||
return config.apiBaseUrl.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard base URL
|
||||
*/
|
||||
export async function getDashboardUrl(): Promise<string> {
|
||||
const envOverride = Deno.env.get('DASHBOARD_URL')
|
||||
if (envOverride) {
|
||||
return envOverride.trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const config = await loadRuntimeConfig()
|
||||
return config.dashboardUrl.replace(/\/$/, '')
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DashboardExtension } from '../types'
|
||||
|
||||
import { userCenterExtension } from './user-center'
|
||||
import { userCenterExtension } from './user-center/index.ts'
|
||||
|
||||
export const builtinExtensions: DashboardExtension[] = [userCenterExtension]
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { Copy } from 'lucide-preact'
|
||||
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Copy, Download, QrCode } from 'lucide-react'
|
||||
import { Copy, Download, QrCode } from 'lucide-preact'
|
||||
import { toDataURL } from 'qrcode'
|
||||
|
||||
import Card from './Card'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Code, CreditCard, Home, Mail, Palette, Server, Settings, Shield, User } from 'lucide-react'
|
||||
import { Code, CreditCard, Home, Mail, Palette, Server, Settings, Shield, User } from 'lucide-preact'
|
||||
|
||||
import type { DashboardExtension } from '../../types'
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createFeatureFlag } from '@lib/featureFlags'
|
||||
import { createFeatureFlag } from '@lib/featureFlags.ts'
|
||||
|
||||
import { builtinExtensions } from './builtin'
|
||||
import { builtinExtensions } from './builtin/index.ts'
|
||||
import type {
|
||||
DashboardExtension,
|
||||
ExtensionRegistry,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,125 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { MarkdownRenderResult } from '../../api/render-markdown'
|
||||
|
||||
type MarkdownSectionProps = {
|
||||
src: string
|
||||
className?: string
|
||||
headingLevel?: keyof JSX.IntrinsicElements
|
||||
prefetched?: MarkdownRenderResult
|
||||
headingClassName?: string
|
||||
contentClassName?: string
|
||||
onMetaChange?: (meta: Record<string, unknown>) => void
|
||||
loadingFallback?: ReactNode
|
||||
errorFallback?: ReactNode
|
||||
}
|
||||
|
||||
type MarkdownSectionState = {
|
||||
data?: MarkdownRenderResult
|
||||
error?: string
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
async function fetchMarkdown(path: string): Promise<MarkdownRenderResult> {
|
||||
const response = await fetch(`/api/render-markdown?path=${encodeURIComponent(path)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = (await response.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(error ?? `Request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as MarkdownRenderResult
|
||||
}
|
||||
|
||||
function resolveHeading(meta: Record<string, unknown>, fallback: keyof JSX.IntrinsicElements) {
|
||||
const allowed = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
||||
const requested = typeof meta.heading === 'string' ? meta.heading.toLowerCase() : undefined
|
||||
return (requested && allowed.has(requested) ? (requested as keyof JSX.IntrinsicElements) : fallback)
|
||||
}
|
||||
|
||||
export default function MarkdownSection({
|
||||
src,
|
||||
className,
|
||||
headingLevel = 'h2',
|
||||
prefetched,
|
||||
headingClassName,
|
||||
contentClassName,
|
||||
onMetaChange,
|
||||
loadingFallback = <div className="text-sm text-slate-500">Loading content…</div>,
|
||||
errorFallback = <div className="text-sm text-red-500">Failed to load content.</div>,
|
||||
}: MarkdownSectionProps) {
|
||||
const initialState: MarkdownSectionState = useMemo(() => {
|
||||
if (prefetched && prefetched.path === src) {
|
||||
return { data: prefetched, loading: false }
|
||||
}
|
||||
return { loading: true }
|
||||
}, [prefetched, src])
|
||||
|
||||
const [state, setState] = useState<MarkdownSectionState>(initialState)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
if (prefetched && prefetched.path === src) {
|
||||
setState({ data: prefetched, loading: false })
|
||||
onMetaChange?.(prefetched.meta)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
|
||||
setState({ loading: true })
|
||||
fetchMarkdown(src)
|
||||
.then((data) => {
|
||||
if (!active) return
|
||||
setState({ data, loading: false })
|
||||
onMetaChange?.(data.meta)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return
|
||||
setState({ error: error.message, loading: false })
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [src, prefetched, onMetaChange])
|
||||
|
||||
if (state.loading) {
|
||||
return <section className={className}>{loadingFallback}</section>
|
||||
}
|
||||
|
||||
if (state.error || !state.data) {
|
||||
return <section className={className}>{errorFallback}</section>
|
||||
}
|
||||
|
||||
const { meta, html } = state.data
|
||||
const title = typeof meta.title === 'string' ? meta.title : undefined
|
||||
const resolvedHeading = resolveHeading(meta, headingLevel)
|
||||
|
||||
const HeadingTag = resolvedHeading
|
||||
|
||||
return (
|
||||
<section className={className} aria-label={title ?? undefined}>
|
||||
{title ? (
|
||||
<HeadingTag className={clsx('text-2xl font-semibold text-slate-900', headingClassName)}>
|
||||
{title}
|
||||
</HeadingTag>
|
||||
) : null}
|
||||
<div
|
||||
className={clsx('prose prose-slate mt-4 max-w-none', contentClassName)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export type { MarkdownRenderResult }
|
||||
@ -1,73 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MarkdownSection, { type MarkdownRenderResult } from '../MarkdownSection'
|
||||
|
||||
describe('MarkdownSection', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders prefetched content and notifies listeners', () => {
|
||||
const prefetched: MarkdownRenderResult = {
|
||||
path: 'homepage/intro.md',
|
||||
html: '<p>Prefetched body</p>',
|
||||
meta: { title: 'Intro Title', heading: 'h3' },
|
||||
}
|
||||
const onMetaChange = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<MarkdownSection src="homepage/intro.md" prefetched={prefetched} onMetaChange={onMetaChange} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole('region', { name: 'Intro Title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 3, name: 'Intro Title' })).toBeInTheDocument()
|
||||
expect(container.querySelector('.prose')).toHaveTextContent('Prefetched body')
|
||||
expect(onMetaChange).toHaveBeenCalledWith(prefetched.meta)
|
||||
})
|
||||
|
||||
it('fetches markdown content when no prefetched data is provided', async () => {
|
||||
const response: MarkdownRenderResult = {
|
||||
path: 'homepage/features.md',
|
||||
html: '<p>Loaded from network</p>',
|
||||
meta: { title: 'Loaded Title', heading: 'h4' },
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(response),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const onMetaChange = vi.fn()
|
||||
|
||||
render(
|
||||
<MarkdownSection src="homepage/features.md" onMetaChange={onMetaChange} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loading content…')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 4, name: 'Loaded Title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/render-markdown?path=homepage%2Ffeatures.md', expect.any(Object))
|
||||
expect(onMetaChange).toHaveBeenCalledWith(response.meta)
|
||||
expect(screen.queryByText('Loading content…')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the error fallback when the fetch fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ error: 'Something went wrong' }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(<MarkdownSection src="homepage/features.md" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load content.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Loading content…')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,14 +0,0 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { ContentCommitMeta } from '../../api/content-meta'
|
||||
import { getContentCommitMeta } from '../../api/content-meta'
|
||||
import type { MarkdownRenderResult } from '../../api/render-markdown'
|
||||
import { renderMarkdownFile } from '../../api/render-markdown'
|
||||
|
||||
export async function loadMarkdownSection(path: string): Promise<MarkdownRenderResult> {
|
||||
return renderMarkdownFile(path)
|
||||
}
|
||||
|
||||
export async function loadContentMeta(path: string): Promise<ContentCommitMeta> {
|
||||
return getContentCommitMeta(path)
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import MarkdownSection from '../components/MarkdownSection'
|
||||
import { useLanguage, type Language } from '../../i18n/LanguageProvider'
|
||||
|
||||
const SECTION_PATHS: Record<Language, {
|
||||
operations: string
|
||||
productSpotlight: string
|
||||
news: string
|
||||
support: string
|
||||
community: string
|
||||
resources: string
|
||||
}> = {
|
||||
zh: {
|
||||
operations: 'homepage/zh/operations.md',
|
||||
productSpotlight: 'homepage/zh/products.md',
|
||||
news: 'homepage/zh/news.md',
|
||||
support: 'homepage/zh/support.md',
|
||||
community: 'homepage/zh/community.md',
|
||||
resources: 'homepage/zh/resources.md',
|
||||
},
|
||||
en: {
|
||||
operations: 'homepage/en/operations.md',
|
||||
productSpotlight: 'homepage/en/products.md',
|
||||
news: 'homepage/en/news.md',
|
||||
support: 'homepage/en/support.md',
|
||||
community: 'homepage/en/community.md',
|
||||
resources: 'homepage/en/resources.md',
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_LANGUAGE: Language = 'zh'
|
||||
|
||||
export default function MarkdownHomepage() {
|
||||
const { language } = useLanguage()
|
||||
const sections = SECTION_PATHS[language] ?? SECTION_PATHS[DEFAULT_LANGUAGE]
|
||||
|
||||
return (
|
||||
<main className="flex flex-col bg-brand-surface text-brand-heading">
|
||||
<header className="bg-brand py-16 text-white">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-8">
|
||||
<MarkdownSection
|
||||
src={sections.operations}
|
||||
headingLevel="h1"
|
||||
className="flex flex-col gap-4"
|
||||
headingClassName="text-[36px] font-bold text-white"
|
||||
contentClassName="prose-invert prose-headings:text-white prose-strong:text-white text-white/90"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<section className="mx-auto flex w-full max-w-6xl flex-col gap-12 px-8 py-16">
|
||||
<MarkdownSection
|
||||
src={sections.productSpotlight}
|
||||
className="rounded-2xl border border-brand-border bg-white p-8 shadow-[0_4px_20px_rgba(0,0,0,0.04)]"
|
||||
headingClassName="text-2xl font-semibold text-brand-navy"
|
||||
contentClassName="prose prose-slate mt-6 max-w-none text-brand-heading/80"
|
||||
/>
|
||||
<div className="grid gap-12 lg:grid-cols-[minmax(0,2fr)_360px]">
|
||||
<MarkdownSection
|
||||
src={sections.news}
|
||||
className="rounded-2xl border border-brand-border bg-white p-8 shadow-[0_4px_20px_rgba(0,0,0,0.04)]"
|
||||
headingClassName="text-2xl font-semibold text-brand-navy"
|
||||
contentClassName="prose prose-slate mt-6 max-w-none text-brand-heading/80"
|
||||
/>
|
||||
<div className="flex flex-col gap-12">
|
||||
<MarkdownSection
|
||||
src={sections.support}
|
||||
className="rounded-2xl border border-brand-border bg-white p-8 shadow-[0_4px_20px_rgba(0,0,0,0.04)]"
|
||||
headingClassName="text-2xl font-semibold text-brand-navy"
|
||||
contentClassName="prose prose-slate mt-6 max-w-none text-brand-heading/80"
|
||||
/>
|
||||
<MarkdownSection
|
||||
src={sections.resources}
|
||||
className="rounded-2xl border border-brand-border bg-white p-8 shadow-[0_4px_20px_rgba(0,0,0,0.04)]"
|
||||
headingClassName="text-2xl font-semibold text-brand-navy"
|
||||
contentClassName="prose prose-slate mt-6 max-w-none text-brand-heading/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownSection
|
||||
src={sections.community}
|
||||
className="rounded-2xl border border-brand-border bg-white p-8 shadow-[0_4px_20px_rgba(0,0,0,0.04)]"
|
||||
headingClassName="text-2xl font-semibold text-brand-navy"
|
||||
contentClassName="prose prose-slate mt-6 max-w-none text-brand-heading/80"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import { Page, expect, test } from '@playwright/test'
|
||||
|
||||
async function expectAuthLayoutWithinViewport(page: Page) {
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) {
|
||||
throw new Error('Viewport size is not available')
|
||||
}
|
||||
|
||||
const authLayout = page.getByTestId('auth-layout')
|
||||
await expect(authLayout).toBeVisible()
|
||||
|
||||
const boundingBox = await authLayout.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return {
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
}
|
||||
})
|
||||
|
||||
const tolerance = 1
|
||||
expect.soft(boundingBox.top).toBeGreaterThanOrEqual(-tolerance)
|
||||
expect.soft(boundingBox.left).toBeGreaterThanOrEqual(-tolerance)
|
||||
expect.soft(boundingBox.right).toBeLessThanOrEqual(viewport.width + tolerance)
|
||||
expect.soft(boundingBox.bottom).toBeLessThanOrEqual(viewport.height + tolerance)
|
||||
|
||||
await expect(page.locator('nav')).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.describe('Auth pages', () => {
|
||||
test('login form visible on desktop viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await page.goto('/login')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expectAuthLayoutWithinViewport(page)
|
||||
})
|
||||
|
||||
test('login form visible on mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 })
|
||||
await page.goto('/login')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expectAuthLayoutWithinViewport(page)
|
||||
})
|
||||
|
||||
test('register form visible on desktop viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await page.goto('/register')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expectAuthLayoutWithinViewport(page)
|
||||
})
|
||||
|
||||
test('register form visible on mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 })
|
||||
await page.goto('/register')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expectAuthLayoutWithinViewport(page)
|
||||
})
|
||||
})
|
||||
@ -1,33 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Marketing homepage experience', () => {
|
||||
test('renders localized markdown content and switches language dynamically', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: '云原生套件' })).toBeVisible()
|
||||
await expect(page.getByText('构建一体化的云原生工具集', { exact: false })).toBeVisible()
|
||||
|
||||
await expect(page.getByRole('heading', { level: 2, name: '产品专题' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '产品体验' }).first()).toHaveAttribute(
|
||||
'href',
|
||||
/\/demo\/?\?product=xcloudflow/
|
||||
)
|
||||
await expect(page.getByRole('link', { name: '下载链接' })).toHaveAttribute('href', /\/download\/?\?product=xcloudflow/)
|
||||
|
||||
await expect(page.getByRole('heading', { level: 2, name: '获取支持' })).toBeVisible()
|
||||
|
||||
const languageToggle = page.getByRole('combobox')
|
||||
await languageToggle.selectOption('en')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Cloud-Native Suite' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Product Spotlights' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Try the product' }).first()).toHaveAttribute(
|
||||
'href',
|
||||
/\/demo\/?\?product=xcloudflow/
|
||||
)
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Get Support' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Join the enterprise WeChat group via QR code', { exact: false })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user