diff --git a/.env.example b/.env.example index 5f63005..f53605f 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ VITE_DATA_MODE=mock -VITE_API_BASE_URL= +VITE_DEV_API_TARGET=http://localhost:5090 diff --git a/README.md b/README.md index 4a0fdce..a112f8e 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ npm run check:production ```dotenv VITE_DATA_MODE=mock -VITE_API_BASE_URL= +VITE_DEV_API_TARGET=http://localhost:5090 ``` - `mock`:仅 Development 动态加载 MSW;生产构建不会包含 Worker、启动器、示例 Token 或 Mock 状态。 -- `api`:使用同源 `/api`,也可在开发期通过 `VITE_API_BASE_URL` 代理到 .NET API。 +- `api`:浏览器固定使用同源 `/api`;Development 的 Vite 服务通过 `VITE_DEV_API_TARGET` 代理到 .NET API,并保留浏览器原始 Host。 页面只能调用 `src/api` 下的领域 Client,不能直接调用 `fetch`。前端请求不携带任意 `tenantId`;真实租户由 Host 与受保护 Session 决定。 @@ -42,7 +42,7 @@ VITE_API_BASE_URL= | `/manage/*` | 必须通过租户 Backoffice Bootstrap;无 Permission 的账号统一显示 404 | | `/__demo` | 仅开发环境存在的生命周期启动器 | -后台菜单来自 `GET /api/backoffice/tenant/bootstrap`。进入站点设计还必须包含 `tenant:settings:manage`,前端隐藏菜单不能替代后端 401/403。 +后台菜单来自 `GET /api/backoffice/tenant/ui-bootstrap`。进入站点设计还必须包含 `tenant:settings:manage`,前端隐藏菜单不能替代后端 401/403。 ## 前后端契约 @@ -51,11 +51,41 @@ VITE_API_BASE_URL= - `GET /api/runtime/bootstrap` - `POST /api/browser-auth/activation/complete` - `POST /api/browser-auth/login/password` -- `GET /api/backoffice/tenant/bootstrap` +- `GET /api/backoffice/tenant/ui-bootstrap` - `GET /api/tenant-onboarding/status` - `GET|PUT|POST /api/tenant-admin/frontend-config/**` -后端目前已有 `/api/auth/activation/complete`,但只返回 204。目标 Browser Auth 激活接口需要在完成密码设置后签发 HttpOnly Session Cookie,并返回当前用户摘要,前端才能安全地自动进入建站向导。 +Browser Auth 激活接口会在完成密码设置后签发 HttpOnly Session Cookie;后续写请求从 `__Host-tiku-csrf` Cookie 读取双提交 Token,并发送 `X-CSRF-Token`。 + +## 真实后端建站流程 + +先按后端 `docs/quickstart.md` 初始化空数据库,在平台端创建 `school.localhost` 租户并领取 Owner 激活链接。然后启动本项目: + +```bash +VITE_DATA_MODE=api \ +VITE_DEV_API_TARGET='http://localhost:5090' \ +npm run dev -- --host 0.0.0.0 --port 5180 +``` + +真实流程如下: + +1. 打开平台签发的完整 `/activate/:activationId#token=...` 链接; +2. Token 读入后立即从地址栏清除; +3. Owner 设置密码,后端签发 Secure/HttpOnly Session Cookie; +4. 自动进入 `/manage/onboarding`; +5. 完成品牌、模板、主题、模块、桌面/移动预览并发布; +6. 刷新 `/`,学生端读取已发布版本; +7. 退出后在 `/manage/login` 使用 Owner 账号重新登录。 + +平台管理员无需确认 Owner 密码或网站发布。站点未发布时的“管理员确认发布”指租户后台拥有站点配置权限的用户执行发布,不是平台二次审批。 + +## 常见问题 + +- 激活请求只有 OPTIONS、没有 POST:不要设置旧的 `VITE_API_BASE_URL`;使用 `VITE_DEV_API_TARGET` 并重启 Vite,浏览器请求必须保持同源 `/api`。 +- 激活失败后 Fragment 已清除:未消费时重新打开原始完整链接;已消费、过期或丢失时由平台撤销重签。 +- 发布后旧标签仍显示筹备页:刷新学生端,让 Runtime Bootstrap 重新读取已发布版本。 +- `school.localhost` 串租户或无法登录:确认代理为 `changeOrigin: false`,不要改用 `localhost:5180`,也不要在请求中发送 `tenantId`。 +- 后台没有菜单:菜单只映射后端 Bootstrap 已返回且当前前端已实现的 `tenant.dashboard` 和 `tenant.site-content`;站点配置还要求 `tenant:settings:manage`。 ## 与旧系统的边界 diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 4154e93..5973c7f 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -1,15 +1,15 @@ -import type { BrowserOwnerActivationRequest, CurrentUser, LoginRequest } from '../contracts'; +import type { BrowserAuthenticationResult, BrowserOwnerActivationRequest, LoginRequest } from '../contracts'; import { apiRequest } from './http'; export const authApi = { completeOwnerActivation: (request: BrowserOwnerActivationRequest) => - apiRequest('/api/browser-auth/activation/complete', { + apiRequest('/api/browser-auth/activation/complete', { method: 'POST', body: JSON.stringify(request), }), - login: (request: LoginRequest) => apiRequest('/api/browser-auth/login/password', { + login: (request: LoginRequest) => apiRequest('/api/browser-auth/login/password', { method: 'POST', - body: JSON.stringify(request), + body: JSON.stringify({ ...request, realm: 'Tenant' }), }), logout: () => apiRequest('/api/browser-auth/logout', { method: 'POST' }), }; diff --git a/src/api/backofficeApi.ts b/src/api/backofficeApi.ts index e9af071..3f3bd58 100644 --- a/src/api/backofficeApi.ts +++ b/src/api/backofficeApi.ts @@ -1,6 +1,40 @@ import type { BackofficeBootstrap } from '../contracts'; import { apiRequest } from './http'; -export const backofficeApi = { - bootstrap: () => apiRequest('/api/backoffice/tenant/bootstrap'), +interface BackofficeUiBootstrapResponse { + permissionCodes: string[]; + menus: Array<{ + code: string; + title: string; + path: string | null; + permissionCode: string | null; + }>; + enabledFeatures: string[]; +} + +const supportedMenuRoutes: Record = { + 'tenant.dashboard': { path: '/manage' }, + 'tenant.site-content': { path: '/manage/site', requiresSiteSettings: true }, +}; + +export const backofficeApi = { + bootstrap: async (): Promise => { + const response = await apiRequest('/api/backoffice/tenant/ui-bootstrap'); + return { + permissions: response.permissionCodes, + enabledFeatures: response.enabledFeatures, + menus: response.menus + .flatMap((item) => { + const route = supportedMenuRoutes[item.code]; + if (!route) return []; + if (route.requiresSiteSettings && !response.permissionCodes.includes('tenant:settings:manage')) return []; + return [{ + code: item.code, + name: item.title, + path: route.path, + requiredPermission: route.requiresSiteSettings ? 'tenant:settings:manage' : item.permissionCode, + }]; + }), + }; + }, }; diff --git a/src/api/http.ts b/src/api/http.ts index 086d4d0..b6b2c77 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -8,9 +8,10 @@ export class ApiError extends Error { } } -function apiBase(): string { - const configured = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') ?? ''; - return configured; +function readCookie(name: string): string | null { + const prefix = `${encodeURIComponent(name)}=`; + const entry = document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith(prefix)); + return entry ? decodeURIComponent(entry.slice(prefix.length)) : null; } export async function apiRequest(path: string, init: RequestInit = {}): Promise { @@ -20,8 +21,13 @@ export async function apiRequest(path: string, init: RequestInit = {}): Promi const host = window.localStorage.getItem('tiku-saas-demo-host') || window.location.hostname; headers.set('X-Demo-Host', host); } + const method = (init.method || 'GET').toUpperCase(); + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const csrf = readCookie('__Host-tiku-csrf'); + if (csrf) headers.set('X-CSRF-Token', csrf); + } - const response = await fetch(`${apiBase()}${path}`, { + const response = await fetch(path, { ...init, headers, credentials: 'include', diff --git a/src/app/App.tsx b/src/app/App.tsx index e1cf533..cd396a3 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -91,7 +91,6 @@ function RuntimeRoutes() { > }> - } /> } /> } /> {DemoLauncher && } />} @@ -106,9 +105,12 @@ function RuntimeRoutes() { export function App() { return ( - - - + }> + + } /> + } /> + + ); } diff --git a/src/contracts/auth.ts b/src/contracts/auth.ts index 69050fe..078c9cb 100644 --- a/src/contracts/auth.ts +++ b/src/contracts/auth.ts @@ -11,19 +11,24 @@ export interface LoginRequest { export interface CurrentUser { userId: string; - displayName: string; - identifierMasked: string; + phone?: string | null; + email?: string | null; + name?: string | null; +} + +export interface BrowserAuthenticationResult { + status: string; + user: CurrentUser | null; } export interface BackofficeMenuItem { code: string; name: string; path: string; - requiredPermission: string; + requiredPermission: string | null; } export interface BackofficeBootstrap { - user: CurrentUser; permissions: string[]; menus: BackofficeMenuItem[]; enabledFeatures: string[]; diff --git a/src/demo/DemoLauncher.tsx b/src/demo/DemoLauncher.tsx index 5f970c0..456c543 100644 --- a/src/demo/DemoLauncher.tsx +++ b/src/demo/DemoLauncher.tsx @@ -148,9 +148,9 @@ export default function DemoLauncher() { state.session = { user: { userId: state.tenantId, - displayName: + name: value === "student" ? "学生账号" : "运营账号", - identifierMasked: "us***@example.com", + email: "user@example.com", }, permissions: value === "owner" diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index cca9e01..fd9e5f6 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -28,7 +28,7 @@ export const handlers = [ http.post('/api/browser-auth/activation/complete', async ({ request }) => { try { const body = await request.json() as BrowserOwnerActivationRequest; - return HttpResponse.json(activateOwner(hostOf(request), body)); + return HttpResponse.json({ status: 'Authenticated', user: activateOwner(hostOf(request), body) }); } catch (error) { return problem(error); } @@ -43,11 +43,11 @@ export const handlers = [ } const next = updateTenant(host, current => { current.session = { - user: { userId: current.tenantId, displayName: '租户负责人', identifierMasked: 'ow***@academy.example' }, + user: { userId: current.tenantId, name: '租户负责人', email: 'owner@academy.example' }, permissions: ['tenant:dashboard:view', 'tenant:settings:manage', 'tenant:site-content:manage'], }; }); - return HttpResponse.json(next.session!.user); + return HttpResponse.json({ status: 'Authenticated', user: next.session!.user }); }), http.post('/api/browser-auth/logout', ({ request }) => { @@ -55,9 +55,19 @@ export const handlers = [ return new HttpResponse(null, { status: 204 }); }), - http.get('/api/backoffice/tenant/bootstrap', ({ request }) => { + http.get('/api/backoffice/tenant/ui-bootstrap', ({ request }) => { try { - return HttpResponse.json(backofficeOf(ensureTenant(hostOf(request)))); + const bootstrap = backofficeOf(ensureTenant(hostOf(request))); + return HttpResponse.json({ + permissionCodes: bootstrap.permissions, + enabledFeatures: bootstrap.enabledFeatures, + menus: bootstrap.menus.map(item => ({ + code: item.code, + title: item.name, + path: item.path, + permissionCode: item.requiredPermission, + })), + }); } catch (error) { return problem(error); } diff --git a/src/mocks/store.ts b/src/mocks/store.ts index d5e51bb..c17954d 100644 --- a/src/mocks/store.ts +++ b/src/mocks/store.ts @@ -125,8 +125,8 @@ export function activateOwner(host: string, request: BrowserOwnerActivationReque state.password = request.newPassword; activated = { userId: randomId(), - displayName: '租户负责人', - identifierMasked: 'ow***@academy.example', + name: '租户负责人', + email: 'owner@academy.example', }; state.session = { user: activated, permissions: [...OWNER_PERMISSIONS] }; }); @@ -150,11 +150,10 @@ export function backofficeOf(state: DemoTenantState): BackofficeBootstrap { if (!state.session) throw new DemoStoreError(401, 'authentication_required'); if (state.session.permissions.length === 0) throw new DemoStoreError(403, 'backoffice_access_denied'); const menus = [ - { code: 'dashboard', name: '工作台', path: '/manage', requiredPermission: 'tenant:dashboard:view' }, - { code: 'site', name: '站点设计', path: '/manage/site', requiredPermission: 'tenant:settings:manage' }, + { code: 'tenant.dashboard', name: '工作台', path: '/tenant/dashboard', requiredPermission: 'tenant:dashboard:view' }, + { code: 'tenant.site-content', name: '站点设计', path: '/tenant/site-content', requiredPermission: 'tenant:settings:manage' }, ].filter(menu => state.session!.permissions.includes(menu.requiredPermission)); return { - user: state.session.user, permissions: [...state.session.permissions], enabledFeatures: ['core.backoffice', 'site.content'], menus, diff --git a/src/tenant/ActivationPage.tsx b/src/tenant/ActivationPage.tsx index 0c1955b..866a571 100644 --- a/src/tenant/ActivationPage.tsx +++ b/src/tenant/ActivationPage.tsx @@ -1,9 +1,8 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Alert, Button, Card, Form, Input, Typography } from "antd"; import { KeyRound } from "lucide-react"; import { useNavigate, useParams } from "react-router"; import { ApiError, authApi } from "../api"; -import { useRuntime } from "../app/RuntimeProvider"; interface FormValues { password: string; @@ -26,23 +25,22 @@ function readAndClearToken(activationId: string): string { const errorMessages: Record = { activation_invalid: "建站密钥无效,请联系平台重新获取。", + owner_activation_invalid: "建站密钥无效或已经过期,请联系平台重新获取。", activation_expired: "建站密钥已经过期,请联系平台重新签发。", activation_consumed: "建站密钥已经使用,不能再次激活。", + owner_activation_consumed: "建站密钥已经使用,不能再次激活。", + owner_activation_host_mismatch: "该建站链接不属于当前域名,请使用平台提供的完整链接。", activation_password_invalid: "密码至少 8 位,并同时包含字母和数字。", + owner_activation_password_invalid: "密码不符合安全策略,请按提示重新设置。", }; export default function ActivationPage() { const { activationId = "" } = useParams(); - const { runtime } = useRuntime(); const navigate = useNavigate(); - const [token, setToken] = useState(""); + const [token] = useState(() => readAndClearToken(activationId)); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); - useEffect(() => { - setToken(readAndClearToken(activationId)); - }, [activationId]); - const submit = async (values: FormValues) => { if (!token) { setError("链接中没有可用的建站密钥,请联系平台重新获取。"); @@ -75,13 +73,13 @@ export default function ActivationPage() {
- {runtime?.tenantName} + {window.location.hostname} 激活管理员账号 diff --git a/src/tenant/TenantShell.tsx b/src/tenant/TenantShell.tsx index f9fc8a6..9679f87 100644 --- a/src/tenant/TenantShell.tsx +++ b/src/tenant/TenantShell.tsx @@ -17,7 +17,7 @@ export default function TenantShell() { const navigate = useNavigate(); const menuItems = useMemo(() => bootstrap.menus.map(item => ({ key: item.path, - icon: item.code === 'site' ? : , + icon: item.code === 'tenant.site-content' ? : , label: item.name, })), [bootstrap.menus]); diff --git a/src/tests/access.test.ts b/src/tests/access.test.ts index c1c48ee..0220df1 100644 --- a/src/tests/access.test.ts +++ b/src/tests/access.test.ts @@ -3,7 +3,7 @@ import type { BackofficeBootstrap } from '../contracts'; import { canManageSite, hasBackofficeAccess } from '../shared/access'; function bootstrap(permissions: string[]): BackofficeBootstrap { - return { user: { userId: '1', displayName: '用户', identifierMasked: '***' }, permissions, menus: [], enabledFeatures: [] }; + return { permissions, menus: [], enabledFeatures: [] }; } describe('backoffice access', () => { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8d9d6d4..ffeda8f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,7 +2,7 @@ interface ImportMetaEnv { readonly VITE_DATA_MODE?: 'mock' | 'api'; - readonly VITE_API_BASE_URL?: string; + readonly VITE_DEV_API_TARGET?: string; } interface ImportMeta { diff --git a/vite.config.ts b/vite.config.ts index 978c469..7d73fa4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); - const apiTarget = env.VITE_API_BASE_URL || 'http://localhost:5090'; + const apiTarget = env.VITE_DEV_API_TARGET || 'http://localhost:5090'; return { plugins: [react(), tailwindcss()], @@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => { host: true, port: 5180, proxy: env.VITE_DATA_MODE === 'api' - ? { '/api': { target: apiTarget, changeOrigin: true } } + ? { '/api': { target: apiTarget, changeOrigin: false } } : undefined, }, build: {