feat: align tenant site builder with real api
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
VITE_DATA_MODE=mock
|
||||
VITE_API_BASE_URL=
|
||||
VITE_DEV_API_TARGET=http://localhost:5090
|
||||
|
||||
40
README.md
40
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`。
|
||||
|
||||
## 与旧系统的边界
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}>
|
||||
激活管理员账号
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
2
src/vite-env.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user