Commit db3476b4 authored by xuchentao's avatar xuchentao

feat: add multi-tenant MVP

parent 331123b5
PORT=3100
HOST=127.0.0.1
FRONTEND_ORIGIN=http://localhost
# 单用户登录账号固定为 user;生产环境请务必修改默认密码。
WEBAGENT_USER_PASSWORD=user
# development/test 默认初始化下面的测试账号;production 必须保持 false,否则服务拒绝启动。
WEBAGENT_INIT_TEST_DATA=true
WEBAGENT_DATABASE_PATH=.runtime/webagent.sqlite
WEBAGENT_SESSION_TTL_DAYS=30
WEBAGENT_TEST_ADMIN_USERNAME=admin
WEBAGENT_TEST_ADMIN_PASSWORD=admin123
WEBAGENT_TEST_TENANT_A_ID=tenant_default_a
WEBAGENT_TEST_TENANT_A_NAME=默认租户 A
WEBAGENT_TEST_TENANT_A_USERNAME=tenant_a
WEBAGENT_TEST_TENANT_A_PASSWORD=tenant123
WEBAGENT_TEST_TENANT_B_ID=tenant_default_b
WEBAGENT_TEST_TENANT_B_NAME=默认租户 B
WEBAGENT_TEST_TENANT_B_USERNAME=tenant_b
WEBAGENT_TEST_TENANT_B_PASSWORD=tenant123
# 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。
WEBAGENT_SITES_DIR=../WebAgent-sites
# 测试预览静态产物独立保存,便于直接查看,也不会随运行缓存清理。
......
......@@ -14,8 +14,17 @@ pnpm dev
- API:<http://localhost:3100>
- 站点预览端口:`4300-4399`
- Nginx 统一入口:<http://localhost>
- 测试预览链接:`http://你的主机/previews/site_xxx/`
- 生产站点链接:`http://你的主机/sites/site_xxx/`(用户手动发布后可用)
- 测试预览链接:`http://你的主机/previews/tenant_xxx/site_xxx/`
- 生产站点链接:`http://你的主机/sites/tenant_xxx/site_xxx/`(用户手动发布后可用)
开发环境首次启动会创建三个测试账号:系统管理员 `admin / admin123`,以及租户账号 `tenant_a / tenant123``tenant_b / tenant123`。账号值均可通过 `.env` 中的 `WEBAGENT_TEST_*` 配置覆盖;已有账号和密码不会在重启时被覆盖。生产环境默认不初始化测试数据,且在 `NODE_ENV=production` 时显式开启 `WEBAGENT_INIT_TEST_DATA=true` 会拒绝启动。
生产环境可通过 CLI 创建或重置系统管理员(重置密码会注销该管理员已有 session):
```bash
pnpm --filter @webagent/backend auth:admin create <username> <password>
pnpm --filter @webagent/backend auth:admin reset <username> <password>
```
生成的网站默认保存在 WebAgent 项目同级的 `WebAgent-sites/`。配置支持相对路径和绝对路径;相对路径以 WebAgent 项目根目录为基准:
......@@ -36,8 +45,8 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
- `/`:React 控制台静态文件
- `/api/`:反向代理到 `127.0.0.1:3100`
- `/previews/site_xxx/`:当前代码的测试预览,每次成功构建后更新
- `/sites/site_xxx/`:生产站点,只有用户执行“发布上线”后更新
- `/previews/tenant_xxx/site_xxx/`:当前代码的测试预览,每次成功构建后更新
- `/sites/tenant_xxx/site_xxx/`:生产站点,只有用户执行“发布上线”后更新
当前 Intel macOS Homebrew 配置位于 [deploy/nginx/webagent.conf](deploy/nginx/webagent.conf)。服务器路径变化时,需要同步调整配置中的 `root``alias` 绝对路径。
......
......@@ -7,7 +7,9 @@
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "NODE_ENV=test WEBAGENT_INIT_TEST_DATA=true tsx --test src/**/*.test.ts",
"auth:admin": "tsx src/auth/admin-cli.ts"
},
"dependencies": {
"@fastify/cors": "^11.0.1",
......
import { AuthError, AuthRepository } from "./auth-repository.js";
const [command, username, password] = process.argv.slice(2);
if (!command || !username || !password || !["create", "reset"].includes(command)) {
console.error("用法: pnpm --filter @webagent/backend auth:admin <create|reset> <username> <password>");
process.exitCode = 1;
} else {
const auth = new AuthRepository();
try {
if (command === "create") auth.createAdmin(username, password);
else auth.resetAdminPassword(username, password);
console.log(command === "create" ? `管理员 ${username} 已创建` : `管理员 ${username} 的密码已重置,旧 session 已注销`);
} catch (error) {
console.error(error instanceof AuthError ? error.message : error);
process.exitCode = 1;
} finally {
auth.close();
}
}
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { config } from "../config.js";
import { AuthRepository } from "./auth-repository.js";
test("initializes all default accounts once and persists sessions", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "webagent-auth-"));
const databasePath = path.join(directory, "auth.sqlite");
try {
const first = new AuthRepository(databasePath);
first.initializeTestData();
const admin = first.login(config.auth.testData.adminUsername, config.auth.testData.adminPassword);
const tenantA = first.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword);
const tenantB = first.login(config.auth.testData.tenantBUsername, config.auth.testData.tenantBPassword);
assert.equal(admin.session.accountType, "system_admin");
assert.equal(admin.session.tenantId, null);
assert.equal(tenantA.session.tenantId, config.auth.testData.tenantAId);
assert.equal(tenantB.session.tenantId, config.auth.testData.tenantBId);
first.close();
const restarted = new AuthRepository(databasePath);
restarted.initializeTestData();
assert.deepEqual(restarted.getSession(tenantA.token), tenantA.session);
assert.equal(restarted.listTenants().length, 2);
restarted.close();
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("does not overwrite passwords and invalidates a disabled tenant session", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "webagent-auth-"));
const databasePath = path.join(directory, "auth.sqlite");
try {
const auth = new AuthRepository(databasePath);
auth.initializeTestData();
auth.resetAdminPassword(config.auth.testData.adminUsername, "changed-password");
auth.initializeTestData();
assert.equal(auth.login(config.auth.testData.adminUsername, "changed-password").session.username, config.auth.testData.adminUsername);
assert.throws(() => auth.login(config.auth.testData.adminUsername, config.auth.testData.adminPassword));
const tenantSession = auth.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword);
auth.setTenantEnabled(config.auth.testData.tenantAId, false);
assert.equal(auth.getSession(tenantSession.token), undefined);
assert.throws(() => auth.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword));
auth.setTenantEnabled(config.auth.testData.tenantAId, true);
assert.equal(auth.login(config.auth.testData.tenantAUsername, config.auth.testData.tenantAPassword).session.tenantId, config.auth.testData.tenantAId);
auth.close();
} finally { await rm(directory, { recursive: true, force: true }); }
});
This diff is collapsed.
import crypto from "node:crypto";
const KEY_LENGTH = 64;
const SCRYPT_COST = 16384;
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16);
const digest = crypto.scryptSync(password, salt, KEY_LENGTH, { N: SCRYPT_COST, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
return ["scrypt", SCRYPT_COST, 8, 1, salt.toString("base64"), digest.toString("base64")].join("$");
}
export function verifyPassword(password: string, encoded: string): boolean {
const [algorithm, costText, blockSizeText, parallelText, saltText, digestText] = encoded.split("$");
if (algorithm !== "scrypt" || !costText || !blockSizeText || !parallelText || !saltText || !digestText) return false;
try {
const expected = Buffer.from(digestText, "base64");
const actual = crypto.scryptSync(password, Buffer.from(saltText, "base64"), expected.length, {
N: Number(costText), r: Number(blockSizeText), p: Number(parallelText), maxmem: 64 * 1024 * 1024,
});
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
......@@ -34,20 +34,20 @@ export class BuildManager {
}
}
async publishPreview(siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.previewsDir, siteId), distPath);
async publishPreview(tenantId: string, siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.previewsDir, tenantId, siteId), distPath);
}
async publishProduction(siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.productionDir, siteId), distPath);
async publishProduction(tenantId: string, siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.productionDir, tenantId, siteId), distPath);
}
async publishCustomDomain(siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.customDomainsDir, siteId), distPath);
async publishCustomDomain(tenantId: string, siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.customDomainsDir, tenantId, siteId), distPath);
}
async ensureProductionPlaceholder(siteId: string, siteName: string): Promise<string> {
const target = path.join(config.productionDir, siteId);
async ensureProductionPlaceholder(tenantId: string, siteId: string, siteName: string): Promise<string> {
const target = path.join(config.productionDir, tenantId, siteId);
const indexPath = path.join(target, "index.html");
if (await stat(indexPath).then((value) => value.isFile()).catch(() => false)) return target;
const safeName = siteName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
......
......@@ -20,10 +20,23 @@ const configuredCustomDomainsDir = process.env.WEBAGENT_CUSTOM_DOMAINS_DIR?.trim
const customDomainsDir = path.isAbsolute(configuredCustomDomainsDir)
? configuredCustomDomainsDir
: path.resolve(rootDir, configuredCustomDomainsDir);
const configuredDatabasePath = process.env.WEBAGENT_DATABASE_PATH?.trim() || ".runtime/webagent.sqlite";
const databasePath = path.isAbsolute(configuredDatabasePath)
? configuredDatabasePath
: path.resolve(rootDir, configuredDatabasePath);
const configuredDomainCheckInterval = Number(process.env.WEBAGENT_DOMAIN_CHECK_INTERVAL_MS || 60000);
const domainCheckIntervalMs = Number.isFinite(configuredDomainCheckInterval)
? Math.max(15000, configuredDomainCheckInterval)
: 60000;
const environment = process.env.NODE_ENV?.trim() || "development";
const testDataFlag = process.env.WEBAGENT_INIT_TEST_DATA?.trim();
const testDataEnabled = testDataFlag == null || testDataFlag === ""
? environment === "development" || environment === "test"
: testDataFlag === "true";
if (environment === "production" && testDataEnabled) {
throw new Error("生产环境禁止启用 WEBAGENT_INIT_TEST_DATA,请关闭测试账号初始化后再启动");
}
export const config = {
rootDir,
......@@ -33,12 +46,26 @@ export const config = {
previewsDir,
productionDir,
customDomainsDir,
environment,
host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
auth: {
username: "user",
password: process.env.WEBAGENT_USER_PASSWORD || "user",
databasePath,
sessionTtlDays: Math.max(1, Number(process.env.WEBAGENT_SESSION_TTL_DAYS || 30)),
testData: {
enabled: testDataEnabled,
adminUsername: process.env.WEBAGENT_TEST_ADMIN_USERNAME?.trim() || "admin",
adminPassword: process.env.WEBAGENT_TEST_ADMIN_PASSWORD || "admin123",
tenantAId: process.env.WEBAGENT_TEST_TENANT_A_ID?.trim() || "tenant_default_a",
tenantAName: process.env.WEBAGENT_TEST_TENANT_A_NAME?.trim() || "默认租户 A",
tenantAUsername: process.env.WEBAGENT_TEST_TENANT_A_USERNAME?.trim() || "tenant_a",
tenantAPassword: process.env.WEBAGENT_TEST_TENANT_A_PASSWORD || "tenant123",
tenantBId: process.env.WEBAGENT_TEST_TENANT_B_ID?.trim() || "tenant_default_b",
tenantBName: process.env.WEBAGENT_TEST_TENANT_B_NAME?.trim() || "默认租户 B",
tenantBUsername: process.env.WEBAGENT_TEST_TENANT_B_USERNAME?.trim() || "tenant_b",
tenantBPassword: process.env.WEBAGENT_TEST_TENANT_B_PASSWORD || "tenant123",
},
},
openai: {
apiKey: process.env.OPENAI_API_KEY || "",
......@@ -70,10 +97,10 @@ export const runtimePaths = {
domainMaps: path.join(config.runtimeDir, "nginx"),
};
export function getPublicPreviewUrl(siteId: string): string {
return `/previews/${siteId}/`;
export function getPublicPreviewUrl(tenantId: string, siteId: string): string {
return `/previews/${tenantId}/${siteId}/`;
}
export function getPublicProductionUrl(siteId: string): string {
return `/sites/${siteId}/`;
export function getPublicProductionUrl(tenantId: string, siteId: string): string {
return `/sites/${tenantId}/${siteId}/`;
}
......@@ -19,36 +19,37 @@ export class DomainDeploymentService {
private readonly routing: DomainRoutingConfig,
) {}
schedule(siteId: string, commit: string): Promise<void> {
const current = this.inFlight.get(siteId);
if (current) return current.then(() => this.schedule(siteId, commit));
const operation = this.deploy(siteId, commit).finally(() => this.inFlight.delete(siteId));
this.inFlight.set(siteId, operation);
schedule(tenantId: string, siteId: string, commit: string): Promise<void> {
const key = tenantId + "/" + siteId;
const current = this.inFlight.get(key);
if (current) return current.then(() => this.schedule(tenantId, siteId, commit));
const operation = this.deploy(tenantId, siteId, commit).finally(() => this.inFlight.delete(key));
this.inFlight.set(key, operation);
return operation;
}
private async deploy(siteId: string, commit: string): Promise<void> {
const bindings = await this.domains.list(siteId);
private async deploy(tenantId: string, siteId: string, commit: string): Promise<void> {
const bindings = await this.domains.list(tenantId, siteId);
if (!bindings.some((domain) => domain.ownershipStatus === "verified")) return;
const project = this.sites.getProjectPath(siteId);
const project = this.sites.getProjectPath(tenantId, siteId);
await this.git.assertCommit(project, commit);
const taskId = "domain_" + crypto.randomBytes(5).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "deploying", lastDeploymentError: undefined }
: {});
try {
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, "/");
await this.builds.publishCustomDomain(siteId, dist);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
await this.builds.publishCustomDomain(tenantId, siteId, dist);
await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "active", deployedCommit: commit, lastDeploymentError: undefined }
: {});
await this.routing.sync();
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
await this.domains.updateSite(tenantId, siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "failed", lastDeploymentError: details }
: {});
throw error;
......
......@@ -15,21 +15,26 @@ export class DomainError extends Error {
export class DomainRepository {
private mutationQueue: Promise<unknown> = Promise.resolve();
async list(siteId: string): Promise<StoredDomainBinding[]> {
async list(tenantId: string, siteId: string): Promise<StoredDomainBinding[]> {
this.assertTenantId(tenantId);
this.assertSiteId(siteId);
return this.readSite(siteId);
return this.readSite(tenantId, siteId);
}
async listAll(): Promise<StoredDomainBinding[]> {
const entries = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
const groups = await Promise.all(entries
.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name))
.map((entry) => this.readSite(entry.name)));
return groups.flat();
const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
const tenantGroups = await Promise.all(tenants
.filter((entry) => entry.isDirectory() && /^tenant_[a-z0-9_]+$/.test(entry.name))
.map(async (tenant) => {
const sites = await readdir(path.join(runtimePaths.sites, tenant.name), { withFileTypes: true }).catch(() => []);
return Promise.all(sites.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name))
.map((entry) => this.readSite(tenant.name, entry.name)));
}));
return tenantGroups.flat(2);
}
async get(siteId: string, domainId: string): Promise<StoredDomainBinding> {
const domain = (await this.list(siteId)).find((item) => item.domainId === domainId);
async get(tenantId: string, siteId: string, domainId: string): Promise<StoredDomainBinding> {
const domain = (await this.list(tenantId, siteId)).find((item) => item.domainId === domainId);
if (!domain) throw new DomainError("域名绑定不存在", 404);
return domain;
}
......@@ -38,13 +43,14 @@ export class DomainRepository {
return (await this.listAll()).find((item) => item.hostname === hostname);
}
async create(siteId: string, hostname: string, verificationToken: string, tlsStatus: StoredDomainBinding["tlsStatus"]): Promise<StoredDomainBinding> {
async create(tenantId: string, siteId: string, hostname: string, verificationToken: string, tlsStatus: StoredDomainBinding["tlsStatus"]): Promise<StoredDomainBinding> {
return this.mutate(async () => {
if ((await this.listAll()).some((item) => item.hostname === hostname)) throw new DomainError("该域名已经绑定到其他官网", 409);
const current = await this.readSite(siteId);
const current = await this.readSite(tenantId, siteId);
const now = new Date().toISOString();
const domain: StoredDomainBinding = {
domainId: "domain_" + crypto.randomBytes(6).toString("hex"),
tenantId,
siteId,
hostname,
isPrimary: current.length === 0,
......@@ -56,67 +62,69 @@ export class DomainRepository {
createdAt: now,
updatedAt: now,
};
await this.writeSite(siteId, [...current, domain]);
await this.writeSite(tenantId, siteId, [...current, domain]);
return domain;
});
}
async update(siteId: string, domainId: string, values: Partial<StoredDomainBinding>): Promise<StoredDomainBinding> {
async update(tenantId: string, siteId: string, domainId: string, values: Partial<StoredDomainBinding>): Promise<StoredDomainBinding> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
const current = await this.readSite(tenantId, siteId);
const index = current.findIndex((item) => item.domainId === domainId);
if (index < 0) throw new DomainError("域名绑定不存在", 404);
const next = { ...current[index], ...values, domainId, siteId, updatedAt: new Date().toISOString() };
const next = { ...current[index], ...values, domainId, tenantId, siteId, updatedAt: new Date().toISOString() };
current[index] = next;
await this.writeSite(siteId, current);
await this.writeSite(tenantId, siteId, current);
return next;
});
}
async updateSite(siteId: string, values: (domain: StoredDomainBinding) => Partial<StoredDomainBinding>): Promise<StoredDomainBinding[]> {
async updateSite(tenantId: string, siteId: string, values: (domain: StoredDomainBinding) => Partial<StoredDomainBinding>): Promise<StoredDomainBinding[]> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
const current = await this.readSite(tenantId, siteId);
const updatedAt = new Date().toISOString();
const next = current.map((domain) => ({ ...domain, ...values(domain), domainId: domain.domainId, siteId, updatedAt }));
await this.writeSite(siteId, next);
const next = current.map((domain) => ({ ...domain, ...values(domain), domainId: domain.domainId, tenantId, siteId, updatedAt }));
await this.writeSite(tenantId, siteId, next);
return next;
});
}
async setPrimary(siteId: string, domainId: string): Promise<StoredDomainBinding> {
async setPrimary(tenantId: string, siteId: string, domainId: string): Promise<StoredDomainBinding> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
const current = await this.readSite(tenantId, siteId);
if (!current.some((item) => item.domainId === domainId)) throw new DomainError("域名绑定不存在", 404);
const updatedAt = new Date().toISOString();
const next = current.map((item) => ({ ...item, isPrimary: item.domainId === domainId, updatedAt }));
await this.writeSite(siteId, next);
await this.writeSite(tenantId, siteId, next);
return next.find((item) => item.domainId === domainId)!;
});
}
async remove(siteId: string, domainId: string): Promise<void> {
async remove(tenantId: string, siteId: string, domainId: string): Promise<void> {
await this.mutate(async () => {
const current = await this.readSite(siteId);
const current = await this.readSite(tenantId, siteId);
const removed = current.find((item) => item.domainId === domainId);
if (!removed) throw new DomainError("域名绑定不存在", 404);
const next = current.filter((item) => item.domainId !== domainId);
if (removed.isPrimary && next[0]) next[0] = { ...next[0], isPrimary: true, updatedAt: new Date().toISOString() };
await this.writeSite(siteId, next);
await this.writeSite(tenantId, siteId, next);
});
}
private getPath(siteId: string): string {
private getPath(tenantId: string, siteId: string): string {
this.assertTenantId(tenantId);
this.assertSiteId(siteId);
return path.join(runtimePaths.sites, siteId, "metadata", "domains.json");
return path.join(runtimePaths.sites, tenantId, siteId, "metadata", "domains.json");
}
private async readSite(siteId: string): Promise<StoredDomainBinding[]> {
private async readSite(tenantId: string, siteId: string): Promise<StoredDomainBinding[]> {
try {
const domains = JSON.parse(await readFile(this.getPath(siteId), "utf8")) as StoredDomainBinding[];
const domains = JSON.parse(await readFile(this.getPath(tenantId, siteId), "utf8")) as StoredDomainBinding[];
return domains.map((domain) => {
const { lastError, ...stored } = domain;
return {
...stored,
tenantId,
tlsStatus: domain.tlsStatus || (config.domains.httpsMode === "cloudflare" ? "pending" : "managed"),
lastDeploymentError: domain.lastDeploymentError || lastError,
};
......@@ -127,8 +135,8 @@ export class DomainRepository {
}
}
private async writeSite(siteId: string, domains: StoredDomainBinding[]): Promise<void> {
const target = this.getPath(siteId);
private async writeSite(tenantId: string, siteId: string, domains: StoredDomainBinding[]): Promise<void> {
const target = this.getPath(tenantId, siteId);
await mkdir(path.dirname(target), { recursive: true });
const temporary = target + ".tmp";
await writeFile(temporary, JSON.stringify(domains, null, 2) + "\n", "utf8");
......@@ -144,4 +152,8 @@ export class DomainRepository {
private assertSiteId(siteId: string): void {
if (!/^site_[a-z0-9]+$/.test(siteId)) throw new DomainError("无效的 siteId", 400);
}
private assertTenantId(tenantId: string): void {
if (!/^tenant_[a-z0-9_]+$/.test(tenantId)) throw new DomainError("无效的 tenantId", 400);
}
}
......@@ -16,7 +16,7 @@ export class DomainRoutingConfig {
if (domain.isPrimary || !primaryBySite.has(domain.siteId)) primaryBySite.set(domain.siteId, domain.hostname);
}
const routes = active.map((domain) => `${domain.hostname} ${domain.siteId};`).join("\n");
const routes = active.map((domain) => `${domain.hostname} ${domain.tenantId}/${domain.siteId};`).join("\n");
const redirects = active
.filter((domain) => primaryBySite.get(domain.siteId) !== domain.hostname)
.map((domain) => `${domain.hostname} ${primaryBySite.get(domain.siteId)};`)
......
......@@ -16,10 +16,10 @@ export class DomainService {
private readonly provider: DomainProvider,
) {}
async list(siteId: string): Promise<DomainListResult> {
await this.sites.get(siteId);
async list(tenantId: string, siteId: string): Promise<DomainListResult> {
await this.sites.get(tenantId, siteId);
return {
domains: (await this.domains.list(siteId)).map((domain) => this.present(domain)),
domains: (await this.domains.list(tenantId, siteId)).map((domain) => this.present(domain)),
cnameTarget: config.domains.cnameTarget,
configured: !config.domains.cnameTarget.endsWith(".local") && this.provider.isConfigured(),
ipv4: config.domains.ipv4 || undefined,
......@@ -28,17 +28,17 @@ export class DomainService {
};
}
async add(siteId: string, input: string): Promise<DomainBinding> {
await this.sites.get(siteId);
async add(tenantId: string, siteId: string, input: string): Promise<DomainBinding> {
await this.sites.get(tenantId, siteId);
if (config.domains.cnameTarget.endsWith(".local")) throw new DomainError("域名接入尚未配置,请先设置 WEBAGENT_DOMAIN_CNAME_TARGET");
if (!this.provider.isConfigured()) throw new DomainError("域名 HTTPS Provider 尚未配置完整");
const hostname = this.normalizeHostname(input);
const verificationToken = "webagent-verification=" + crypto.randomBytes(18).toString("base64url");
return this.present(await this.domains.create(siteId, hostname, verificationToken, this.provider.initialTlsStatus()));
return this.present(await this.domains.create(tenantId, siteId, hostname, verificationToken, this.provider.initialTlsStatus()));
}
async verify(siteId: string, domainId: string): Promise<DomainBinding> {
const domain = await this.domains.get(siteId, domainId);
async verify(tenantId: string, siteId: string, domainId: string): Promise<DomainBinding> {
const domain = await this.domains.get(tenantId, siteId, domainId);
const [ownership, routing] = await Promise.all([
domain.ownershipStatus === "verified"
? Promise.resolve<{ valid: boolean; error?: string }>({ valid: true })
......@@ -46,7 +46,7 @@ export class DomainService {
this.checkRouting(domain.hostname),
]);
const errors = [ownership.error, routing.error].filter(Boolean);
let next = await this.domains.update(siteId, domainId, {
let next = await this.domains.update(tenantId, siteId, domainId, {
ownershipStatus: ownership.valid ? "verified" : "failed",
dnsStatus: routing.valid ? "valid" : "invalid",
lastCheckedAt: new Date().toISOString(),
......@@ -55,7 +55,7 @@ export class DomainService {
if (next.ownershipStatus === "verified") {
try {
const provider = await this.provider.sync(next);
next = await this.domains.update(siteId, domainId, {
next = await this.domains.update(tenantId, siteId, domainId, {
providerHostnameId: provider.providerHostnameId,
providerStatus: provider.providerStatus,
tlsStatus: provider.tlsStatus,
......@@ -64,7 +64,7 @@ export class DomainService {
lastTlsError: provider.error,
});
} catch (error) {
next = await this.domains.update(siteId, domainId, {
next = await this.domains.update(tenantId, siteId, domainId, {
tlsStatus: "failed",
lastTlsError: error instanceof Error ? error.message : String(error),
});
......@@ -73,16 +73,16 @@ export class DomainService {
return this.present(next);
}
async setPrimary(siteId: string, domainId: string): Promise<DomainBinding> {
const domain = this.present(await this.domains.setPrimary(siteId, domainId));
async setPrimary(tenantId: string, siteId: string, domainId: string): Promise<DomainBinding> {
const domain = this.present(await this.domains.setPrimary(tenantId, siteId, domainId));
await this.routing.sync();
return domain;
}
async remove(siteId: string, domainId: string): Promise<void> {
const domain = await this.domains.get(siteId, domainId);
async remove(tenantId: string, siteId: string, domainId: string): Promise<void> {
const domain = await this.domains.get(tenantId, siteId, domainId);
await this.provider.remove(domain);
await this.domains.remove(siteId, domainId);
await this.domains.remove(tenantId, siteId, domainId);
await this.routing.sync();
}
......
......@@ -13,8 +13,9 @@ const mimeTypes: Record<string, string> = {
export class PreviewProcessManager {
private servers = new Map<string, Server>();
async start(siteId: string, distPath: string, port: number): Promise<string> {
await this.stop(siteId);
async start(tenantId: string, siteId: string, distPath: string, port: number): Promise<string> {
const key = tenantId + "/" + siteId;
await this.stop(tenantId, siteId);
const root = path.resolve(distPath);
const server = http.createServer(async (request, response) => {
try {
......@@ -36,18 +37,19 @@ export class PreviewProcessManager {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
this.servers.set(siteId, server);
this.servers.set(key, server);
return this.getPreviewUrl(port);
}
async stop(siteId: string): Promise<void> {
const server = this.servers.get(siteId);
async stop(tenantId: string, siteId: string): Promise<void> {
const key = tenantId + "/" + siteId;
const server = this.servers.get(key);
if (!server) return;
await new Promise<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
});
this.servers.delete(siteId);
this.servers.delete(key);
}
getPreviewUrl(port: number): string { return "http://localhost:" + port; }
......
......@@ -31,6 +31,15 @@ export const updateDomainSchema = z.object({
isPrimary: z.literal(true),
});
export const createTenantSchema = z.object({
name: z.string().trim().min(2, "租户名称至少 2 个字符").max(80),
username: z.string().trim().min(3, "账号至少 3 个字符").max(50).regex(/^[a-zA-Z0-9_.-]+$/, "账号只能包含字母、数字、点、下划线和短横线"),
password: z.string().min(8, "密码至少 8 个字符").max(200),
});
export const tenantStatusSchema = z.object({ enabled: z.boolean() });
export const resetPasswordSchema = z.object({ password: z.string().min(8, "密码至少 8 个字符").max(200) });
export const sitePatchSchema = z.object({
summary: z.string().trim().min(2).max(200),
operations: z.array(z.discriminatedUnion("type", [
......
This diff is collapsed.
......@@ -16,15 +16,15 @@ export class CreateSiteService {
private readonly previews: PreviewProcessManager,
) {}
async execute(input: CreateSiteInput): Promise<SiteInfo> {
async execute(tenantId: string, input: CreateSiteInput): Promise<SiteInfo> {
await this.sites.ensureRuntime();
const siteId = "site_" + crypto.randomBytes(4).toString("hex");
const projectPath = this.sites.getProjectPath(siteId);
const siteId = "site_" + crypto.randomBytes(16).toString("hex");
const projectPath = this.sites.getProjectPath(tenantId, siteId);
const previewPort = await this.sites.allocatePreviewPort();
const now = new Date().toISOString();
const site: SiteInfo = {
siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0", environmentVersion: 3,
previewPort, previewUrl: getPublicPreviewUrl(siteId), currentCommit: "", publishStatus: "unpublished",
tenantId, siteId, name: input.name, industry: input.industry, status: "creating", templateVersion: "0.1.0", environmentVersion: 3,
previewPort, previewUrl: getPublicPreviewUrl(tenantId, siteId), currentCommit: "", publishStatus: "unpublished",
createdAt: now, updatedAt: now,
};
await mkdir(projectPath, { recursive: true });
......@@ -43,16 +43,16 @@ export class CreateSiteService {
const themePath = path.join(projectPath, "src/styles/theme.css");
const theme = (await readFile(themePath, "utf8")).replaceAll("#7028ff", input.brandColor.toLowerCase());
await writeFile(themePath, theme, "utf8");
const dist = await this.builds.build(projectPath, "create_" + siteId, getPublicPreviewUrl(siteId));
const dist = await this.builds.build(projectPath, "create_" + siteId, getPublicPreviewUrl(tenantId, siteId));
const commit = await this.git.init(projectPath);
await this.sites.update(siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, previewPort);
await this.builds.ensureProductionPlaceholder(siteId, input.name);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined });
await this.sites.update(tenantId, siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, previewPort);
await this.builds.ensureProductionPlaceholder(tenantId, siteId, input.name);
return await this.sites.update(tenantId, siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: message });
await this.sites.update(tenantId, siteId, { status: "failed", lastError: message });
throw error;
}
}
......
......@@ -11,13 +11,13 @@ export class SiteAgentService {
private readonly agent: AgentLoop,
) {}
async execute(siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(siteId);
const projectPath = this.sites.getProjectPath(siteId);
const workspace = this.sites.getDraftPath(siteId);
async execute(tenantId: string, siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(tenantId, siteId);
const projectPath = this.sites.getProjectPath(tenantId, siteId);
const workspace = this.sites.getDraftPath(tenantId, siteId);
const baseCommit = site.draftBaseCommit || site.currentCommit;
const creatingDraft = !site.draftBaseCommit;
await this.sites.update(siteId, { status: "building", lastError: undefined });
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
let generated: { patch: SitePatch; mode: "model" | "local" } | undefined;
try {
if (creatingDraft) await this.git.createPersistentWorktree(projectPath, workspace, baseCommit);
......@@ -26,7 +26,7 @@ export class SiteAgentService {
await applyPatch(workspace, generated.patch);
const changedFiles = generated.patch.operations.map((item) => item.path);
const updatedAt = new Date().toISOString();
await this.sites.update(siteId, {
await this.sites.update(tenantId, siteId, {
status: "ready", draftBaseCommit: baseCommit, draftUpdatedAt: updatedAt,
draftSummary: generated.patch.summary, lastError: undefined,
});
......@@ -35,7 +35,7 @@ export class SiteAgentService {
const details = error instanceof Error ? error.message : String(error);
const keepDraft = !creatingDraft || await this.git.hasChanges(workspace).catch(() => false);
if (!keepDraft) await this.git.removePersistentWorktree(projectPath, workspace);
await this.sites.update(siteId, {
await this.sites.update(tenantId, siteId, {
status: site.previewCommit ? "ready" : "failed", lastError: details,
draftBaseCommit: keepDraft ? baseCommit : undefined,
draftUpdatedAt: keepDraft ? new Date().toISOString() : undefined,
......
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { SiteInfo } from "@webagent/shared";
test("repository paths and queries are tenant-scoped", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "webagent-sites-"));
process.env.WEBAGENT_SITES_DIR = path.join(directory, "sites");
process.env.WEBAGENT_PREVIEWS_DIR = path.join(directory, "previews");
process.env.WEBAGENT_PRODUCTION_DIR = path.join(directory, "production");
process.env.WEBAGENT_CUSTOM_DOMAINS_DIR = path.join(directory, "domains");
try {
const { SiteRepository } = await import("./site-repository.js");
const sites = new SiteRepository();
await sites.ensureRuntime();
const now = new Date().toISOString();
const createStoredSite = (tenantId: string, siteId: string, name: string): SiteInfo => ({
tenantId, siteId, name, industry: "测试", status: "ready", templateVersion: "test", previewPort: 4300,
previewUrl: "", currentCommit: "commit", publishStatus: "unpublished", createdAt: now, updatedAt: now,
});
const tenantA = "tenant_test_a";
const tenantB = "tenant_test_b";
const siteA = "site_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const siteB = "site_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
await sites.save(createStoredSite(tenantA, siteA, "A"));
await sites.save(createStoredSite(tenantB, siteB, "B"));
assert.deepEqual((await sites.list(tenantA)).map((site) => site.siteId), [siteA]);
assert.deepEqual((await sites.list(tenantB)).map((site) => site.siteId), [siteB]);
await assert.rejects(() => sites.get(tenantA, siteB), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
assert.equal(sites.getProjectPath(tenantA, siteA), path.join(directory, "sites", tenantA, siteA, "project"));
assert.notEqual(sites.getSiteRoot(tenantA, siteA), sites.getSiteRoot(tenantB, siteA));
} finally { await rm(directory, { recursive: true, force: true }); }
});
This diff is collapsed.
This diff is collapsed.
......@@ -15,8 +15,8 @@ pnpm --filter @webagent/backend start
- 控制台:`http://主机地址/`
- API:`http://主机地址/api/`
- 测试预览:`http://主机地址/previews/site_xxx/`
- 生产站点:`http://主机地址/sites/site_xxx/`
- 测试预览:`http://主机地址/previews/tenant_xxx/site_xxx/`
- 生产站点:`http://主机地址/sites/tenant_xxx/site_xxx/`
## 自定义域名源站
......@@ -41,7 +41,7 @@ pnpm --filter @webagent/backend start
nginx -t && nginx -s reload
```
自定义域名内容位于 `WEBAGENT_CUSTOM_DOMAINS_DIR/site_xxx/`,使用 `SITE_BASE_PATH=/` 单独构建。该流程失败不会影响 `/sites/site_xxx/`
自定义域名内容位于 `WEBAGENT_CUSTOM_DOMAINS_DIR/tenant_xxx/site_xxx/`,使用 `SITE_BASE_PATH=/` 单独构建。该流程失败不会影响 `/sites/tenant_xxx/site_xxx/`
### Cloudflare for SaaS
......
......@@ -4,20 +4,25 @@
```text
WebAgent-sites/
└── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库
├── draft/ # 持久化工作草稿 Git Worktree,不进入正式版本历史
└── metadata/
└── site.json # WebAgent 管理信息,不交给 Agent 修改
└── tenant_xxxxxx/
└── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库
├── draft/ # 持久化工作草稿 Git Worktree,不进入正式版本历史
└── metadata/
├── site.json # WebAgent 管理信息,不交给 Agent 修改
└── domains.json # 当前租户站点的域名绑定
WebAgent-previews/
└── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
└── tenant_xxxxxx/
└── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
WebAgent-production/
└── site_xxxxxx/ # 用户确认发布的生产静态产物
└── tenant_xxxxxx/
└── site_xxxxxx/ # 用户确认发布的生产静态产物
WebAgent-custom-domains/
└── site_xxxxxx/ # 自定义域名根路径产物,使用 SITE_BASE_PATH=/ 构建
└── tenant_xxxxxx/
└── site_xxxxxx/ # 自定义域名根路径产物,使用 SITE_BASE_PATH=/ 构建
WebAgent/.runtime/
├── builds/
......@@ -39,6 +44,6 @@ WebAgent/.runtime/
4. 测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。
5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
7. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
7. 每个站点拥有强随机且稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
8. 自定义域名必须通过 TXT 所有权验证后才允许生成路由和申请证书。
9. 自定义域名构建或 DNS 故障不能覆盖、阻塞或改变 `/sites/site_xxx/` 生产环境。
9. 自定义域名构建或 DNS 故障不能覆盖、阻塞或改变 `/sites/tenant_xxx/site_xxx/` 生产环境。
This diff is collapsed.
import type { ChatResult, CreateSiteInput, DomainBinding, DomainListResult, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import type { ChatResult, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
const token = sessionStorage.getItem("webagent-session");
const token = localStorage.getItem("webagent-session");
if (token) headers.set("authorization", "Bearer " + token);
if (options?.body != null && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, {
......@@ -10,14 +10,18 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
headers,
});
const data = await response.json().catch(() => ({})) as { error?: string };
if (response.status === 401) {
localStorage.removeItem("webagent-session");
window.dispatchEvent(new Event("webagent-session-invalid"));
}
if (!response.ok) throw new Error(data.error || "请求失败,请稍后重试");
return data as T;
}
export const api = {
login: (username: string, password: string) => request<{ token: string; username: string }>("/api/login", { method: "POST", body: JSON.stringify({ username, password }) }),
login: (username: string, password: string) => request<{ token: string } & SessionInfo>("/api/login", { method: "POST", body: JSON.stringify({ username, password }) }),
logout: () => request<{ success: true }>("/api/logout", { method: "POST" }),
session: () => request<{ username: string }>("/api/session"),
session: () => request<SessionInfo>("/api/session"),
health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"),
sites: () => request<SiteInfo[]>("/api/sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
......@@ -34,4 +38,11 @@ export const api = {
verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }),
setPrimaryDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId, { method: "PATCH", body: JSON.stringify({ isPrimary: true }) }),
removeDomain: (siteId: string, domainId: string) => request<void>("/api/sites/" + siteId + "/domains/" + domainId, { method: "DELETE" }),
tenants: () => request<TenantAdminInfo[]>("/api/admin/tenants"),
tenant: (tenantId: string) => request<TenantAdminInfo>("/api/admin/tenants/" + tenantId),
tenantSites: (tenantId: string) => request<TenantAdminSiteInfo[]>(`/api/admin/tenants/${tenantId}/sites`),
createTenant: (input: CreateTenantInput) => request<TenantAdminInfo>("/api/admin/tenants", { method: "POST", body: JSON.stringify(input) }),
setTenantEnabled: (tenantId: string, enabled: boolean) => request<TenantAdminInfo>("/api/admin/tenants/" + tenantId, { method: "PATCH", body: JSON.stringify({ enabled }) }),
resetTenantPassword: (tenantId: string, userId: string, password: string) => request<{ success: true }>(`/api/admin/tenants/${tenantId}/accounts/${userId}/reset-password`, { method: "POST", body: JSON.stringify({ password }) }),
revokeTenantSessions: (tenantId: string, userId: string) => request<{ success: true }>(`/api/admin/tenants/${tenantId}/accounts/${userId}/revoke-sessions`, { method: "POST" }),
};
......@@ -60,3 +60,7 @@
@media(max-width:1100px){.prerequisite-grid{grid-template-columns:1fr 1fr}.instruction-list{grid-template-columns:1fr}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto auto}.domain-overview-main>.domain-state{display:none}}
@media(max-width:800px){.prerequisite-grid{grid-template-columns:1fr}.wizard-heading{align-items:flex-start}.wizard-steps{padding:0 8px}.wizard-steps>div{flex-direction:column;gap:3px;text-align:center}.wizard-steps>div>i{top:25px}.wizard-domain-entry form{grid-template-columns:1fr}.wizard-domain-entry form>button{width:100%}.instruction-list,.system-readiness{grid-template-columns:1fr}.wizard-action{align-items:stretch;flex-direction:column}.wizard-action>button{width:100%}.wizard-success>div{flex-direction:column}.wizard-success a,.wizard-success button{width:100%;justify-content:center}.domain-overview-row{grid-template-columns:1fr}.domain-overview-actions{padding:0 10px 9px;justify-content:flex-end}.domain-overview-main{grid-template-columns:auto minmax(0,1fr) auto}.domain-overview-main>em,.domain-overview-main>.domain-state{display:none}}
.wizard-domain-entry form>button:disabled{color:#9a9ca5;background:#e9e9ef;opacity:1;box-shadow:none}
/* Multi-tenant administrator workspace */
.admin-page{min-height:100vh;background:#f6f6fa;color:#252333}.admin-topbar{height:72px;padding:0 34px;display:flex;align-items:center;justify-content:space-between;background:#fff;border-bottom:1px solid #ebe9f1}.admin-topbar>div{display:flex;align-items:center;gap:10px}.admin-topbar>div>span:nth-child(2){display:flex;flex-direction:column}.admin-topbar small{color:#8a8795;font-size:11px}.admin-topbar button{margin-left:14px;padding:8px 12px;display:flex;align-items:center;gap:6px;border:1px solid #e3e0ea;border-radius:9px;background:#fff;color:#666273}.admin-layout{min-height:calc(100vh - 73px);display:grid;grid-template-columns:286px 1fr}.admin-sidebar{padding:28px 20px;background:#fff;border-right:1px solid #ebe9f1}.admin-sidebar>div{padding:0 8px 18px;display:flex;justify-content:space-between;color:#706c7b;font-size:13px}.admin-sidebar>div strong{padding:2px 7px;border-radius:20px;background:#f0edf7;color:#6d43cf}.admin-create-button{width:100%;padding:11px 14px;display:flex;align-items:center;justify-content:center;gap:7px;border:0;border-radius:10px;background:#6d3fe0;color:#fff;font-weight:700}.admin-sidebar nav{margin-top:18px;display:grid;gap:5px}.admin-sidebar nav button{width:100%;padding:10px;display:grid;grid-template-columns:36px 1fr 8px;align-items:center;gap:9px;text-align:left;border:1px solid transparent;border-radius:11px;background:transparent}.admin-sidebar nav button.active{background:#f5f1ff;border-color:#e7dcff}.admin-sidebar nav button>span:first-child{width:34px;height:34px;display:grid;place-items:center;border-radius:9px;background:#ece8f6;color:#6337c8;font-weight:800}.admin-sidebar nav button>span:nth-child(2){min-width:0;display:flex;flex-direction:column}.admin-sidebar nav strong,.admin-sidebar nav small{overflow:hidden;text-overflow:ellipsis}.admin-sidebar nav small{margin-top:3px;color:#9894a1}.admin-sidebar nav i{width:7px;height:7px;border-radius:50%}.admin-sidebar nav i.enabled{background:#35ae75}.admin-sidebar nav i.disabled{background:#bbb7c1}.admin-content{max-width:1040px;width:100%;padding:48px 56px;margin:0 auto}.admin-heading{display:flex;align-items:flex-start;justify-content:space-between}.admin-heading>div>span{color:#7548dd;font-size:11px;font-weight:800;letter-spacing:.14em}.admin-heading h1{margin:6px 0 8px;font-size:30px}.admin-heading p{margin:0;color:#85818f}.admin-heading code{font-size:12px}.admin-heading>button{border:0;background:transparent}.admin-tenant-heading h1{display:flex;align-items:center;gap:10px}.admin-tenant-heading h1 em{padding:4px 8px;border-radius:6px;background:#eee7ff;color:#6940c5;font-size:11px;font-style:normal}.admin-tenant-heading>button{padding:9px 14px;border-radius:9px;font-weight:700}.admin-tenant-heading>button.disable{background:#fff0f0;color:#bf4545}.admin-tenant-heading>button.enable{background:#e9f8f0;color:#27835a}.admin-stats{margin:28px 0 22px;display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.admin-stats article{padding:22px;border:1px solid #e8e5ed;border-radius:14px;background:#fff;display:flex;flex-direction:column;gap:5px}.admin-stats strong{font-size:24px}.admin-stats strong.ok{font-size:17px;color:#27835a}.admin-stats strong.off{font-size:17px;color:#b84747}.admin-stats span{color:#8c8896;font-size:12px}.admin-card{padding:26px;border:1px solid #e8e5ed;border-radius:16px;background:#fff;box-shadow:0 12px 40px rgba(47,36,76,.04)}.admin-card+.admin-card{margin-top:18px}.admin-section-title h2{margin:0 0 5px;font-size:17px}.admin-section-title p{margin:0;color:#8c8896;font-size:12px}.admin-account-list{margin-top:18px;display:grid;gap:10px}.admin-account-list article{padding:13px 0;display:grid;grid-template-columns:38px 1fr auto auto;align-items:center;gap:10px;border-top:1px solid #f0edf3}.admin-account-list article>div{display:flex;flex-direction:column;gap:3px}.admin-account-list small{color:#9995a2;font-size:10px}.admin-account-list button{padding:7px 10px;border:1px solid #dfdbe7;border-radius:8px;background:#fff;color:#615d6c}.admin-info-card dl{margin:18px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.admin-info-card dl div{display:flex;flex-direction:column;gap:5px}.admin-info-card dt{color:#9894a1;font-size:11px}.admin-info-card dd{margin:0;font-size:13px}.admin-create-form{max-width:700px;margin:20px auto}.admin-form-grid{margin:28px 0;display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-empty{min-height:400px;display:grid;place-content:center;justify-items:center;gap:10px;color:#85818f}.admin-empty h2{margin:5px}.admin-empty button{padding:10px 15px;border:0;border-radius:9px;background:#6d3fe0;color:#fff}.settings-avatar{width:34px;height:34px;display:grid;place-items:center;border-radius:9px;background:#ebe5fa;color:#6138c1;font-weight:800}
.admin-sites-card>.admin-section-title{display:flex;align-items:center;justify-content:space-between}.admin-sites-card>.admin-section-title>span{padding:3px 8px;border-radius:20px;background:#f0edf7;color:#6d43cf;font-size:12px;font-weight:700}.admin-site-list{margin-top:18px;display:grid;gap:9px}.admin-site-list article{padding:14px;display:grid;grid-template-columns:40px minmax(0,1fr) auto auto;align-items:center;gap:12px;border:1px solid #ece9f1;border-radius:12px;background:#fcfbfd}.admin-site-avatar{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;background:#eee8fc;color:#6740c4;font-weight:800}.admin-site-copy{min-width:0;display:flex;flex-direction:column;gap:4px}.admin-site-copy strong,.admin-site-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.admin-site-copy small{color:#8f8b98;font-size:10px}.admin-site-copy code{font-size:9px}.admin-site-state{display:flex;align-items:center;gap:5px}.admin-site-state span{padding:4px 7px;border-radius:6px;background:#f0eff3;color:#777380;font-size:9px;white-space:nowrap}.admin-site-state span.ready,.admin-site-state span.published{color:#187558;background:#e7f8f1}.admin-site-state span.building,.admin-site-state span.creating,.admin-site-state span.publishing{color:#946300;background:#fff6df}.admin-site-state span.failed{color:#b74343;background:#fff0f0}.admin-site-links{display:flex;align-items:center;gap:6px}.admin-site-links a,.admin-site-links>span{height:31px;padding:0 9px;display:flex;align-items:center;gap:5px;border:1px solid #ded9e7;border-radius:8px;color:#6540bc;background:#fff;font-size:9px;font-weight:700;text-decoration:none;white-space:nowrap}.admin-site-links a.production{color:#187558}.admin-site-links>span{color:#aaa6b1;background:#f5f4f6}.admin-sites-loading,.admin-sites-empty{min-height:90px;display:flex;align-items:center;justify-content:center;gap:8px;color:#8c8896;font-size:12px}
@media(max-width:800px){.admin-layout{grid-template-columns:1fr}.admin-sidebar{border-right:0;border-bottom:1px solid #ebe9f1}.admin-sidebar nav{grid-template-columns:repeat(2,minmax(0,1fr))}.admin-content{padding:28px 20px}.admin-stats{grid-template-columns:1fr}.admin-account-list article{grid-template-columns:38px 1fr}.admin-account-list button{grid-column:auto}.admin-info-card dl,.admin-form-grid{grid-template-columns:1fr}.admin-site-list article{grid-template-columns:40px minmax(0,1fr)}.admin-site-state,.admin-site-links{grid-column:2;justify-content:flex-start}.admin-topbar{padding:0 18px}}
......@@ -12,6 +12,7 @@ export interface CreateSiteInput {
}
export interface SiteInfo {
tenantId: string;
siteId: string;
name: string;
industry: string;
......@@ -36,6 +37,55 @@ export interface SiteInfo {
lastPublishError?: string;
}
export type AccountType = "system_admin" | "tenant_user";
export interface SessionInfo {
userId: string;
username: string;
accountType: AccountType;
tenantId: string | null;
}
export interface TenantAccountInfo {
userId: string;
username: string;
enabled: boolean;
createdAt: string;
}
export interface TenantAdminInfo {
tenantId: string;
name: string;
enabled: boolean;
isTest: boolean;
createdAt: string;
updatedAt: string;
siteCount: number;
accounts: TenantAccountInfo[];
}
export interface TenantAdminSiteInfo {
tenantId: string;
siteId: string;
name: string;
industry: string;
status: SiteStatus;
publishStatus: PublishStatus;
previewUrl: string;
productionUrl?: string;
publishedAt?: string;
createdAt: string;
updatedAt: string;
lastError?: string;
lastPublishError?: string;
}
export interface CreateTenantInput {
name: string;
username: string;
password: string;
}
export interface SitePatch {
summary: string;
operations: Array<
......@@ -87,6 +137,7 @@ export interface DomainDnsRecord {
}
export interface DomainBinding {
tenantId: string;
domainId: string;
siteId: string;
hostname: string;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment