forked from EduCraft/curriculum-project-hub
feat(hub): add /database admin surface with Feishu login
Adds a self-contained `/database/*` HTTP surface under hub/src/database: - /database/admin: Feishu-only login page (Tailwind, light theme) - /database/dashboard: session-gated sidebar + content shell - /database/dev-login: DEV ONLY session bypass, double-gated by NODE_ENV != production AND HUB_DEV_LOGIN_BYPASS; never active in prod hub.ts mounts the plugin after the admin plugin so the cookie parser and /auth/feishu/* routes are available. The dev bypass logic is fully contained in the database module; admin auth routes are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@ dist/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.secrets/
|
.secrets/
|
||||||
|
.dev-keyring.json
|
||||||
|
.dev-workspaces/
|
||||||
|
.dev-skills/
|
||||||
admin-web/node_modules/
|
admin-web/node_modules/
|
||||||
admin-web/build/
|
admin-web/build/
|
||||||
admin-web/.svelte-kit/
|
admin-web/.svelte-kit/
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# src/database/
|
||||||
|
|
||||||
|
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
|
||||||
|
所以服务器启动时能正确识别这些路由。
|
||||||
|
|
||||||
|
页面:
|
||||||
|
|
||||||
|
- `/database/admin` —— 飞书登录页(唯一登录方式)
|
||||||
|
- `/database/dashboard` —— 左菜单 + 右内容的后台,未登录会跳回 `/database/admin`
|
||||||
|
|
||||||
|
登录走平台既有的飞书 OAuth:登录页的按钮指向 `/auth/feishu/<orgSlug>`,
|
||||||
|
回调由 `src/admin/routes/authRoutes.ts` 处理并种下 session cookie。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` |
|
||||||
|
| `routes/databaseRoutes.ts` | 路由 + 页面渲染,**你主要在这里加内容** |
|
||||||
|
|
||||||
|
新增一类端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`,
|
||||||
|
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})`
|
||||||
|
注册一次。
|
||||||
|
|
||||||
|
## 约定(与 admin 面一致)
|
||||||
|
|
||||||
|
1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。
|
||||||
|
2. **guard 前置、fail closed**:凡碰数据的端点第一行先跑
|
||||||
|
`requireSession` / `requireOrgRole` / `requireProjectPermission`
|
||||||
|
(都在 `../admin/auth/guards.js`)。
|
||||||
|
3. **租户隔离**(ADR-0020):每个 Prisma 查询都 scope 到 `auth.organization.id`,
|
||||||
|
不得跨 org。禁止无鉴权的数据路由。
|
||||||
|
4. 数据库通过传入的 `config.prisma` 访问(全进程单例,见 `../db.ts`);
|
||||||
|
不要在这里 `new PrismaClient()`。
|
||||||
|
|
||||||
|
## 为什么代码在 `src/` 下
|
||||||
|
|
||||||
|
`tsconfig.json` 固定 `rootDir: "src"` 且 `include: ["src/**/*.ts"]`。只有
|
||||||
|
`src/` 下的 `.ts` 会被 `tsc` 编译、被 `tsx watch`(`npm run dev`)加载。放在
|
||||||
|
`src/` 之外的目录不会被构建,外部识别不到。
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Registers the `/database/*` HTTP surface onto the Fastify app.
|
||||||
|
*
|
||||||
|
* Single public entry point (mirrors src/admin/plugin.ts). hub.ts calls
|
||||||
|
* registerDatabasePlugin() so the routes are recognized at startup.
|
||||||
|
*
|
||||||
|
* Register AFTER registerAdminPlugin: it relies on the @fastify/cookie parser
|
||||||
|
* and the /auth/feishu/* login routes that the admin plugin adds.
|
||||||
|
*
|
||||||
|
* The dev login bypass is self-contained in THIS module: the flag is read here
|
||||||
|
* (not threaded from hub.ts) and only ever enables the `/database/dev-login`
|
||||||
|
* route below. Double-gated: NODE_ENV must not be production AND
|
||||||
|
* HUB_DEV_LOGIN_BYPASS must be truthy. Production always requires real Feishu
|
||||||
|
* OAuth.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
|
||||||
|
|
||||||
|
export interface DatabasePluginConfig {
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
readonly sessionSecret: string;
|
||||||
|
readonly siloOrganizationSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALSY = new Set(["", "0", "false", "no", "off"]);
|
||||||
|
|
||||||
|
export async function registerDatabasePlugin(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: DatabasePluginConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const allowDevLoginBypass =
|
||||||
|
process.env["NODE_ENV"] !== "production" &&
|
||||||
|
!FALSY.has((process.env["HUB_DEV_LOGIN_BYPASS"] ?? "").trim().toLowerCase());
|
||||||
|
if (allowDevLoginBypass) {
|
||||||
|
app.log.warn("DEV login bypass enabled: /database/dev-login mints a session without Feishu OAuth");
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerDatabaseRoutes(app, {
|
||||||
|
prisma: config.prisma,
|
||||||
|
sessionSecret: config.sessionSecret,
|
||||||
|
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||||
|
allowDevLoginBypass,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* `/database/*` route aggregator.
|
||||||
|
*
|
||||||
|
* Owns the `/database` HTTP surface (single place new sub-routes get wired in).
|
||||||
|
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
|
||||||
|
* literal string it serves.
|
||||||
|
*
|
||||||
|
* /database/admin — Feishu-only login page (+ dev one-click button)
|
||||||
|
* /database/dashboard — sidebar + content admin shell, session-gated
|
||||||
|
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
|
||||||
|
*
|
||||||
|
* The dev bypass (button + /database/dev-login) is self-contained here and
|
||||||
|
* gated by allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS
|
||||||
|
* + NODE_ENV). Production requires real Feishu OAuth.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
|
||||||
|
|
||||||
|
export interface DatabaseRouteConfig {
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
/** HMAC secret for the signed session cookie — reused from the admin plane. */
|
||||||
|
readonly sessionSecret: string;
|
||||||
|
/** Silo Organization slug — builds the org-scoped Feishu login link. */
|
||||||
|
readonly siloOrganizationSlug: string;
|
||||||
|
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
|
||||||
|
readonly allowDevLoginBypass: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerDatabaseRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: DatabaseRouteConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
app.get("/database/admin", async (request, reply) => {
|
||||||
|
// Already signed in → straight to the dashboard.
|
||||||
|
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) {
|
||||||
|
return reply.redirect("/database/dashboard");
|
||||||
|
}
|
||||||
|
return reply.type("text/html").send(renderLoginPage(config));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/database/dashboard", async (request, reply) => {
|
||||||
|
const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config);
|
||||||
|
if (user === null) return reply.redirect("/database/admin");
|
||||||
|
return reply.type("text/html").send(renderDashboard(user.displayName));
|
||||||
|
});
|
||||||
|
|
||||||
|
// DEV ONLY bypass — self-contained here, registered only when the flag is on
|
||||||
|
// (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the
|
||||||
|
// scoped SessionIdentity shape the Feishu OAuth callback produces so the
|
||||||
|
// session guard behaves identically. Never registered in production.
|
||||||
|
if (config.allowDevLoginBypass) {
|
||||||
|
app.get("/database/dev-login", async (_request, reply) => {
|
||||||
|
const membership = await config.prisma.organizationMembership.findFirst({
|
||||||
|
where: {
|
||||||
|
revokedAt: null,
|
||||||
|
role: { in: ["OWNER", "ADMIN"] },
|
||||||
|
organization: { status: "ACTIVE" },
|
||||||
|
},
|
||||||
|
select: { userId: true, organizationId: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
if (membership === null) {
|
||||||
|
return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } });
|
||||||
|
}
|
||||||
|
const identity = await config.prisma.feishuUserIdentity.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: membership.userId,
|
||||||
|
connection: { organizationId: membership.organizationId, status: "ACTIVE" },
|
||||||
|
},
|
||||||
|
select: { id: true, connectionId: true, connection: { select: { organizationId: true } } },
|
||||||
|
});
|
||||||
|
if (identity === null) {
|
||||||
|
return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } });
|
||||||
|
}
|
||||||
|
const token = signSession(
|
||||||
|
{
|
||||||
|
userId: membership.userId,
|
||||||
|
feishuIdentityId: identity.id,
|
||||||
|
feishuConnectionId: identity.connectionId,
|
||||||
|
feishuOrganizationId: identity.connection.organizationId,
|
||||||
|
},
|
||||||
|
config.sessionSecret,
|
||||||
|
);
|
||||||
|
// Local dev is http://127.0.0.1, so secure:false. This route only ever
|
||||||
|
// runs outside production (double-gated in ./plugin.ts).
|
||||||
|
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: false,
|
||||||
|
maxAge: 7 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used");
|
||||||
|
return reply.redirect("/database/dashboard");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add more /database/* routes here. Guard data routes with requireSession /
|
||||||
|
// requireOrgRole (../../admin/auth/guards.js) and scope every query to the
|
||||||
|
// caller's org (ADR-0020). Access the DB via config.prisma.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify the session cookie and load the user, or null if not signed in. */
|
||||||
|
async function resolveUser(
|
||||||
|
rawCookie: string | undefined,
|
||||||
|
config: DatabaseRouteConfig,
|
||||||
|
): Promise<{ displayName: string } | null> {
|
||||||
|
if (rawCookie === undefined || rawCookie === "") return null;
|
||||||
|
const session = verifySession(rawCookie, config.sessionSecret);
|
||||||
|
if (session === null) return null;
|
||||||
|
const user = await config.prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { displayName: true },
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared document head: Tailwind CDN + a small design system (fonts, keyframes
|
||||||
|
* for the aurora background, fade-in, shimmer). Both pages import it so the
|
||||||
|
* look stays consistent.
|
||||||
|
*/
|
||||||
|
function pageHead(title: string): string {
|
||||||
|
return `<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>${title}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root { font-family: 'Inter', system-ui, sans-serif; }
|
||||||
|
@keyframes aurora {
|
||||||
|
0% { transform: translate(0,0) scale(1); }
|
||||||
|
33% { transform: translate(6%, -8%) scale(1.15); }
|
||||||
|
66% { transform: translate(-8%, 6%) scale(0.9); }
|
||||||
|
100% { transform: translate(0,0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.aurora { filter: blur(80px); animation: aurora 18s ease-in-out infinite; }
|
||||||
|
.rise { animation: rise .7s cubic-bezier(.16,1,.3,1) both; }
|
||||||
|
.rise-2 { animation: rise .7s cubic-bezier(.16,1,.3,1) .1s both; }
|
||||||
|
.rise-3 { animation: rise .7s cubic-bezier(.16,1,.3,1) .2s both; }
|
||||||
|
.grad-text {
|
||||||
|
background: linear-gradient(120deg,#7c3aed,#0891b2,#4f46e5);
|
||||||
|
background-size: 200% auto;
|
||||||
|
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||||
|
animation: shimmer 6s linear infinite;
|
||||||
|
}
|
||||||
|
.glass { background: rgba(255,255,255,.7); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); }
|
||||||
|
.glow-btn { box-shadow: 0 12px 32px -10px rgba(124,58,237,.45); }
|
||||||
|
</style>
|
||||||
|
</head>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The animated aurora background blobs, shared by both pages (soft pastels on light). */
|
||||||
|
const AURORA = `
|
||||||
|
<div class="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
|
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
|
||||||
|
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
|
||||||
|
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function renderLoginPage(config: DatabaseRouteConfig): string {
|
||||||
|
const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`;
|
||||||
|
const devButton = config.allowDevLoginBypass
|
||||||
|
? `
|
||||||
|
<div class="relative my-6 rise-3">
|
||||||
|
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
|
||||||
|
<div class="relative flex justify-center"><span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span></div>
|
||||||
|
</div>
|
||||||
|
<a href="/database/dev-login"
|
||||||
|
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100">
|
||||||
|
<span>⚡</span> 一键登录管理员
|
||||||
|
</a>
|
||||||
|
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
${pageHead("Database Admin · 登录")}
|
||||||
|
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-800 flex items-center justify-center p-4">
|
||||||
|
${AURORA}
|
||||||
|
<main class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
|
||||||
|
<div class="mb-7 text-center">
|
||||||
|
<div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn">D</div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="${feishuHref}"
|
||||||
|
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z"/></svg>
|
||||||
|
使用飞书登录
|
||||||
|
</a>
|
||||||
|
${devButton}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */
|
||||||
|
const NAV_ITEMS: ReadonlyArray<{ label: string; icon: string; href: string; active: boolean }> = [
|
||||||
|
{ label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z", href: "/database/dashboard", active: true },
|
||||||
|
{ label: "数据表", icon: "M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z", href: "#", active: false },
|
||||||
|
{ label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", href: "#", active: false },
|
||||||
|
{ label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z", href: "#", active: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderDashboard(displayName: string): string {
|
||||||
|
const nav = NAV_ITEMS.map((item) => {
|
||||||
|
const cls = item.active
|
||||||
|
? "group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50"
|
||||||
|
: "group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900";
|
||||||
|
return `<a href="${item.href}" class="${cls}">
|
||||||
|
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${item.icon}"/></svg>
|
||||||
|
${item.label}
|
||||||
|
</a>`;
|
||||||
|
}).join("\n ");
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: "数据表", value: "—", accent: "from-violet-200/60 to-transparent" },
|
||||||
|
{ label: "记录数", value: "—", accent: "from-cyan-200/60 to-transparent" },
|
||||||
|
{ label: "最近查询", value: "—", accent: "from-indigo-200/60 to-transparent" },
|
||||||
|
].map((s, i) => `
|
||||||
|
<div class="rise-${i + 1} group relative overflow-hidden rounded-2xl border border-white/80 glass p-5 shadow-lg shadow-slate-200/50 transition hover:-translate-y-0.5 hover:shadow-xl hover:shadow-indigo-200/50">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br ${s.accent} opacity-0 transition group-hover:opacity-100"></div>
|
||||||
|
<p class="relative text-sm text-slate-500">${s.label}</p>
|
||||||
|
<p class="relative mt-2 text-3xl font-bold text-slate-900">${s.value}</p>
|
||||||
|
</div>`).join("");
|
||||||
|
|
||||||
|
const initial = escapeHtml(displayName.slice(0, 1) || "U");
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
${pageHead("Database Admin · 概览")}
|
||||||
|
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700">
|
||||||
|
${AURORA}
|
||||||
|
<div class="relative flex min-h-screen">
|
||||||
|
<!-- 左侧菜单栏 -->
|
||||||
|
<aside class="flex w-64 shrink-0 flex-col border-r border-slate-200/80 glass">
|
||||||
|
<div class="flex items-center gap-3 px-5 py-6">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 text-lg font-bold text-white glow-btn">D</div>
|
||||||
|
<span class="text-base font-bold grad-text">Database Admin</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
|
||||||
|
${nav}
|
||||||
|
</nav>
|
||||||
|
<div class="m-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/60 px-3 py-3">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-sm font-semibold text-white">${initial}</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-[11px] uppercase tracking-wider text-slate-400">已登录</p>
|
||||||
|
<p class="truncate text-sm font-medium text-slate-700">${escapeHtml(displayName)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧内容 -->
|
||||||
|
<div class="flex flex-1 flex-col">
|
||||||
|
<header class="flex items-center justify-between border-b border-slate-200/80 glass px-8 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold text-slate-900">概览</h1>
|
||||||
|
<p class="text-xs text-slate-400">欢迎回来,这里是数据库管理台</p>
|
||||||
|
</div>
|
||||||
|
<button id="logout"
|
||||||
|
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||||
|
${stats}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rise-3 mt-6 flex h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 glass text-sm text-slate-400">
|
||||||
|
内容区占位 · 在 src/database/routes/databaseRoutes.ts 里继续搭建
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById("logout").addEventListener("click", async () => {
|
||||||
|
await fetch("/auth/logout", { method: "POST" });
|
||||||
|
window.location.href = "/database/admin";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||||
|
import { registerDatabasePlugin } from "./database/plugin.js";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||||
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
||||||
@@ -143,6 +144,14 @@ export async function startHub(): Promise<void> {
|
|||||||
secretEnvelope,
|
secretEnvelope,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `/database/*` surface. Registered after the admin plugin so the
|
||||||
|
// @fastify/cookie parser and /auth/* login routes are already available.
|
||||||
|
await registerDatabasePlugin(app, {
|
||||||
|
prisma,
|
||||||
|
sessionSecret,
|
||||||
|
siloOrganizationSlug: siloOrganization.slug,
|
||||||
|
});
|
||||||
|
|
||||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||||
if (feishuListenerEnabled) {
|
if (feishuListenerEnabled) {
|
||||||
const feishuConfig = {
|
const feishuConfig = {
|
||||||
|
|||||||
Reference in New Issue
Block a user