diff --git a/apps/csms/src/index.ts b/apps/csms/src/index.ts index bb95f16..fcad9bc 100644 --- a/apps/csms/src/index.ts +++ b/apps/csms/src/index.ts @@ -13,6 +13,7 @@ import chargePointRoutes from './routes/charge-points.ts' import transactionRoutes from './routes/transactions.ts' import idTagRoutes from './routes/id-tags.ts' import userRoutes from './routes/users.ts' +import setupRoutes from './routes/setup.ts' import type { HonoEnv } from './types/hono.ts' @@ -54,6 +55,7 @@ app.route('/api/charge-points', chargePointRoutes) app.route('/api/transactions', transactionRoutes) app.route('/api/id-tags', idTagRoutes) app.route('/api/users', userRoutes) +app.route('/api/setup', setupRoutes) app.get('/api', (c) => { const user = c.get('user') diff --git a/apps/csms/src/routes/setup.ts b/apps/csms/src/routes/setup.ts new file mode 100644 index 0000000..40c89fd --- /dev/null +++ b/apps/csms/src/routes/setup.ts @@ -0,0 +1,56 @@ +import { Hono } from "hono"; +import { count, eq } from "drizzle-orm"; +import { useDrizzle } from "@/lib/db.js"; +import { user } from "@/db/schema.js"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { auth } from "@/lib/auth.js"; + +const app = new Hono(); + +/** GET /api/setup — 检查系统是否已初始化(存在用户) */ +app.get("/", async (c) => { + const db = useDrizzle(); + const [{ value }] = await db.select({ value: count() }).from(user); + return c.json({ initialized: value > 0 }); +}); + +const setupSchema = z.object({ + name: z.string().min(1).max(100), + email: z.string().email(), + username: z + .string() + .min(3) + .max(50) + .regex(/^[a-zA-Z0-9_]+$/, "用户名只能包含字母、数字和下划线"), + password: z.string().min(8), +}); + +/** POST /api/setup — 仅在无用户时创建根管理员账号 */ +app.post("/", zValidator("json", setupSchema), async (c) => { + const db = useDrizzle(); + const [{ value }] = await db.select({ value: count() }).from(user); + + if (value > 0) { + return c.json({ error: "系统已初始化,无法重复创建根管理员" }, 403); + } + + const { name, email, username, password } = c.req.valid("json"); + + // 通过 better-auth 内部 API 注册账号 + const signUpRes = await auth.api.signUpEmail({ + body: { name, email, password, username }, + asResponse: false, + }); + + if (!signUpRes?.user?.id) { + return c.json({ error: "账号创建失败" }, 500); + } + + // 将角色提升为 admin + await db.update(user).set({ role: "admin" }).where(eq(user.id, signUpRes.user.id)); + + return c.json({ success: true, userId: signUpRes.user.id }); +}); + +export default app; diff --git a/apps/web/app/dashboard/users/page.tsx b/apps/web/app/dashboard/users/page.tsx index 84e6996..99412ce 100644 --- a/apps/web/app/dashboard/users/page.tsx +++ b/apps/web/app/dashboard/users/page.tsx @@ -138,9 +138,9 @@ export default function UsersPage() { - + setCreateForm({ ...createForm, name: e.target.value })} /> @@ -155,10 +155,9 @@ export default function UsersPage() { /> - + setCreateForm({ ...createForm, username: e.target.value })} /> @@ -166,8 +165,9 @@ export default function UsersPage() { setCreateForm({ ...createForm, password: e.target.value })} /> @@ -223,7 +223,7 @@ export default function UsersPage() { 用户 邮箱 - 用户名 + 登录名 角色 状态 注册时间 @@ -245,7 +245,7 @@ export default function UsersPage() { {u.username ?? "—"} @@ -292,10 +292,9 @@ export default function UsersPage() { /> - + setEditForm({ ...editForm, username: e.target.value }) diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index c0f3f4f..ab463be 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -1,13 +1,15 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react"; import { Thunderbolt } from "@gravity-ui/icons"; import { authClient } from "@/lib/auth-client"; export default function LoginPage() { const router = useRouter(); + const searchParams = useSearchParams(); + const justSetup = searchParams.get("setup") === "1"; const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); @@ -52,6 +54,15 @@ export default function LoginPage() {
+ {justSetup && ( + + + + 初始化完成 + 根管理员账号已创建,请登录。 + + + )} (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [field]: e.target.value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (form.password !== form.confirmPassword) { + setError("两次输入的密码不一致"); + return; + } + if (form.password.length < 8) { + setError("密码长度至少 8 位"); + return; + } + + setLoading(true); + try { + const res = await fetch(`${CSMS_URL}/api/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + name: form.name, + email: form.email, + username: form.username, + password: form.password, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError((data as { error?: string }).error ?? "初始化失败,请重试"); + return; + } + + // 初始化成功,跳转登录页 + router.push("/login?setup=1"); + } catch { + setError("网络错误,请稍后重试"); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Brand */} +
+
+ +
+
+

Helios EVCS

+

首次启动 · 创建根管理员账号

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + {error && ( + + + + 初始化失败 + {error} + + setError("")} /> + + )} + + + + + +

此页面仅在首次启动时可访问

+
+ ); +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 2702c43..ba58c6b 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,27 +1,95 @@ import { NextRequest, NextResponse } from "next/server"; +const CSMS_INTERNAL_URL = + process.env.CSMS_INTERNAL_URL ?? + process.env.NEXT_PUBLIC_CSMS_URL ?? + "http://localhost:3001"; + +/** 检查 CSMS 是否已完成初始化(有用户存在)。使用 cookie 缓存结果,避免每次请求都查询。 */ +async function isInitialized(request: NextRequest): Promise<{ initialized: boolean; fromCache: boolean }> { + // 读缓存 cookie + const cached = request.cookies.get("helios_setup_done"); + if (cached?.value === "1") { + return { initialized: true, fromCache: true }; + } + + try { + const res = await fetch(`${CSMS_INTERNAL_URL}/api/setup`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + // 短超时,避免阻塞 + signal: AbortSignal.timeout(3000), + }); + if (!res.ok) return { initialized: true, fromCache: false }; // 出错时放行,不阻止访问 + const data = (await res.json()) as { initialized: boolean }; + return { initialized: data.initialized, fromCache: false }; + } catch { + // 无法连接 CSMS 时放行,不强制跳转 + return { initialized: true, fromCache: false }; + } +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - // 只保护 /dashboard 路由 - if (!pathname.startsWith("/dashboard")) { - return NextResponse.next(); + // /setup 页面:已初始化则跳转登录 + if (pathname === "/setup") { + const { initialized, fromCache } = await isInitialized(request); + if (initialized) { + return NextResponse.redirect(new URL("/login", request.url)); + } + const res = NextResponse.next(); + if (!fromCache) { + // 未初始化,确保缓存 cookie 不存在(若之前意外设置了) + res.cookies.delete("helios_setup_done"); + } + return res; } - // 检查 better-auth session cookie(cookie 前缀是 helios_auth) - const sessionCookie = - request.cookies.get("helios_auth.session_token") ?? - request.cookies.get("__Secure-helios_auth.session_token"); + // /dashboard 路由:检查 session,未登录跳转 /login + if (pathname.startsWith("/dashboard")) { + const { initialized, fromCache } = await isInitialized(request); - if (!sessionCookie) { - const loginUrl = new URL("/login", request.url); - loginUrl.searchParams.set("from", pathname); - return NextResponse.redirect(loginUrl); + // 未初始化,先去 setup + if (!initialized) { + const res = NextResponse.redirect(new URL("/setup", request.url)); + if (!fromCache) res.cookies.delete("helios_setup_done"); + return res; + } + + const sessionCookie = + request.cookies.get("helios_auth.session_token") ?? + request.cookies.get("__Secure-helios_auth.session_token"); + + if (!sessionCookie) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("from", pathname); + const res = NextResponse.redirect(loginUrl); + if (!fromCache) res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" }); + return res; + } + + const res = NextResponse.next(); + if (!fromCache) res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" }); + return res; + } + + // /login 路由:未初始化则跳转 /setup + if (pathname === "/login") { + const { initialized, fromCache } = await isInitialized(request); + if (!initialized) { + const res = NextResponse.redirect(new URL("/setup", request.url)); + if (!fromCache) res.cookies.delete("helios_setup_done"); + return res; + } + const res = NextResponse.next(); + if (!fromCache) res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" }); + return res; } return NextResponse.next(); } export const config = { - matcher: ["/dashboard/:path*"], + matcher: ["/setup", "/login", "/dashboard/:path*"], };