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:
Haitao Pan 2025-11-05 13:07:58 +08:00
parent 2a3e80c44f
commit 66721ea54a
46 changed files with 2989 additions and 602 deletions

View File

@ -1,7 +0,0 @@
import MarkdownHomepage from '../../../ui/pages/homepage'
export const dynamic = 'force-static'
export default function MarkdownDemoPage() {
return <MarkdownHomepage />
}

View File

@ -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,
}}
/>
)
}

View File

@ -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>

View File

@ -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>

View File

@ -1,6 +1,5 @@
'use client'
import { Copy } from 'lucide-react'
import { Copy } from 'lucide-preact'
interface Props {
text: string

View File

@ -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[]>

View File

@ -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[]

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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/",

View 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`

View File

@ -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 个端点)

View 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

View 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` (虽然可用,但不推荐)

View File

@ -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,

View File

@ -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)

View 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>
)
}

View 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
}

View File

@ -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,
})
)
},
}

View 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>
</>
)
}

View 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>
</>
)
}

View 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}
/>
</>
)
}

View 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 />
</>
)
}

View 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 />
}

View 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 />
}

View 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 />
}

View 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 />
}

View 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 />
}

View 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 />
}

View 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

View 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."

View 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(/\/$/, '')
}

View File

@ -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]

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -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 }

View File

@ -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()
})
})

View File

@ -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)
}

View File

@ -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>
)
}

View File

@ -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)
})
})

View File

@ -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()
})
})