feat: align tenant site builder with real api

This commit is contained in:
2026-08-01 17:27:51 +08:00
parent 015d6649a1
commit d326d7c1bb
15 changed files with 135 additions and 51 deletions

View File

@@ -1,2 +1,2 @@
VITE_DATA_MODE=mock
VITE_API_BASE_URL=
VITE_DEV_API_TARGET=http://localhost:5090

View File

@@ -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`
## 与旧系统的边界

View File

@@ -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<CurrentUser>('/api/browser-auth/activation/complete', {
apiRequest<BrowserAuthenticationResult>('/api/browser-auth/activation/complete', {
method: 'POST',
body: JSON.stringify(request),
}),
login: (request: LoginRequest) => apiRequest<CurrentUser>('/api/browser-auth/login/password', {
login: (request: LoginRequest) => apiRequest<BrowserAuthenticationResult>('/api/browser-auth/login/password', {
method: 'POST',
body: JSON.stringify(request),
body: JSON.stringify({ ...request, realm: 'Tenant' }),
}),
logout: () => apiRequest<void>('/api/browser-auth/logout', { method: 'POST' }),
};

View File

@@ -1,6 +1,40 @@
import type { BackofficeBootstrap } from '../contracts';
import { apiRequest } from './http';
export const backofficeApi = {
bootstrap: () => apiRequest<BackofficeBootstrap>('/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<string, { path: string; requiresSiteSettings?: boolean }> = {
'tenant.dashboard': { path: '/manage' },
'tenant.site-content': { path: '/manage/site', requiresSiteSettings: true },
};
export const backofficeApi = {
bootstrap: async (): Promise<BackofficeBootstrap> => {
const response = await apiRequest<BackofficeUiBootstrapResponse>('/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,
}];
}),
};
},
};

View File

@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -20,8 +21,13 @@ export async function apiRequest<T>(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',

View File

@@ -91,7 +91,6 @@ function RuntimeRoutes() {
>
<Suspense fallback={<FullScreenLoading />}>
<Routes>
<Route path="/activate/:activationId" element={<ActivationPage />} />
<Route path="/manage/login" element={<TenantLoginPage />} />
<Route path="/manage/*" element={<ManageGate />} />
{DemoLauncher && <Route path="/__demo" element={<DemoLauncher />} />}
@@ -106,9 +105,12 @@ function RuntimeRoutes() {
export function App() {
return (
<BrowserRouter>
<RuntimeProvider>
<RuntimeRoutes />
</RuntimeProvider>
<Suspense fallback={<FullScreenLoading />}>
<Routes>
<Route path="/activate/:activationId" element={<ActivationPage />} />
<Route path="/*" element={<RuntimeProvider><RuntimeRoutes /></RuntimeProvider>} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string> = {
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() {
<div className="mb-7 flex items-center gap-4">
<div
className="grid h-12 w-12 place-items-center rounded-2xl text-white"
style={{ background: runtime?.theme.primaryColor }}
style={{ background: "#3157d5" }}
>
<KeyRound size={24} />
</div>
<div>
<Typography.Text type="secondary">
{runtime?.tenantName}
{window.location.hostname}
</Typography.Text>
<Typography.Title level={3} style={{ margin: 0 }}>

View File

@@ -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' ? <SettingOutlined /> : <AppstoreOutlined />,
icon: item.code === 'tenant.site-content' ? <SettingOutlined /> : <AppstoreOutlined />,
label: item.name,
})), [bootstrap.menus]);

View File

@@ -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', () => {

2
src/vite-env.d.ts vendored
View File

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

View File

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