From 19d14bd6a3b79451784a0844f76afb6005545957 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 28 Mar 2026 12:29:50 +0800 Subject: [PATCH] Feat: Add Login Page & Auth Method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加登录界面和授权验证逻辑 --- packages/client/src/App.tsx | 33 ++++++++-- packages/client/src/components/Layout.tsx | 31 +++++++--- packages/client/src/pages/Login.tsx | 74 +++++++++++++++++++++++ packages/client/tsconfig.tsbuildinfo | 2 +- packages/server/package.json | 3 + packages/server/src/db/index.js | 27 ++++++--- packages/server/src/db/schema.sql | 7 +++ packages/server/src/index.js | 50 ++++++++++++--- packages/server/src/routes/auth.js | 31 ++++++++++ pnpm-lock.yaml | 31 ++++++++++ 10 files changed, 257 insertions(+), 32 deletions(-) create mode 100644 packages/client/src/pages/Login.tsx create mode 100644 packages/server/src/routes/auth.js diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index d03fd1e..b75af04 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,18 +1,39 @@ -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; import Projects from "./pages/Projects"; import ProjectDetail from "./pages/ProjectDetail"; import BuildDetail from "./pages/BuildDetail"; +import Login from "./pages/Login"; import Layout from "./components/Layout"; +function AuthGuard() { + const navigate = useNavigate(); + const [state, setState] = useState<"loading" | "ok" | "unauth">("loading"); + + useEffect(() => { + fetch("/api/auth/me").then((r) => { + if (r.ok) setState("ok"); + else setState("unauth"); + }); + }, []); + + if (state === "loading") return null; + if (state === "unauth") return ; + return ; +} + export default function App() { return ( - }> - } /> - } /> - } /> - } /> + } /> + }> + }> + } /> + } /> + } /> + } /> + diff --git a/packages/client/src/components/Layout.tsx b/packages/client/src/components/Layout.tsx index ad6a13b..5e007a2 100644 --- a/packages/client/src/components/Layout.tsx +++ b/packages/client/src/components/Layout.tsx @@ -1,17 +1,30 @@ -import { Outlet, NavLink } from "react-router-dom"; -import { GitBranch } from "lucide-react"; +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { GitBranch, LogOut } from "lucide-react"; export default function Layout() { + const navigate = useNavigate(); + + const logout = async () => { + await fetch("/api/auth/logout", { method: "POST" }); + navigate("/login", { replace: true }); + }; + return (
-
- - +
+ + + Paul CI/CD + +
+
diff --git a/packages/client/src/pages/Login.tsx b/packages/client/src/pages/Login.tsx new file mode 100644 index 0000000..cee1937 --- /dev/null +++ b/packages/client/src/pages/Login.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { GitBranch } from "lucide-react"; + +export default function Login() { + const navigate = useNavigate(); + const [form, setForm] = useState({ username: "", password: "" }); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + setLoading(false); + if (res.ok) { + navigate("/projects", { replace: true }); + } else { + const data = await res.json(); + setError(data.error || "Login failed"); + } + }; + + return ( +
+
+
+ + Paul CI/CD +
+ +
+
+ + setForm((f) => ({ ...f, username: e.target.value }))} + className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500" + /> +
+ +
+ + setForm((f) => ({ ...f, password: e.target.value }))} + className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500" + /> +
+ + {error &&

{error}

} + + +
+
+
+ ); +} diff --git a/packages/client/tsconfig.tsbuildinfo b/packages/client/tsconfig.tsbuildinfo index 7007508..b95fa06 100644 --- a/packages/client/tsconfig.tsbuildinfo +++ b/packages/client/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/pages/builddetail.tsx","./src/pages/projectdetail.tsx","./src/pages/projects.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/pages/builddetail.tsx","./src/pages/login.tsx","./src/pages/projectdetail.tsx","./src/pages/projects.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 4774f75..c2f6f05 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -8,7 +8,10 @@ "start": "node src/index.js" }, "dependencies": { + "@fastify/cookie": "^11.0.0", + "@fastify/session": "^11.0.0", "@fastify/static": "^8.0.0", + "bcryptjs": "^3.0.2", "better-sqlite3": "^11.0.0", "fastify": "^5.0.0" } diff --git a/packages/server/src/db/index.js b/packages/server/src/db/index.js index ee377c9..97bce17 100644 --- a/packages/server/src/db/index.js +++ b/packages/server/src/db/index.js @@ -1,21 +1,32 @@ import Database from "better-sqlite3"; -import { readFileSync } from "fs"; +import { readFileSync, mkdirSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; +import bcrypt from "bcryptjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const DB_PATH = join(__dirname, "../../data/cicd.db"); - -// ensure data dir exists -import { mkdirSync } from "fs"; mkdirSync(join(__dirname, "../../data"), { recursive: true }); -const db = new Database(DB_PATH); +const db = new Database(join(__dirname, "../../data/cicd.db")); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); -const schema = readFileSync(join(__dirname, "schema.sql"), "utf8"); -db.exec(schema); +db.exec(readFileSync(join(__dirname, "schema.sql"), "utf8")); + +// Seed default admin if no users exist +const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get().c; +if (userCount === 0) { + const username = process.env.ADMIN_USER || "admin"; + const password = process.env.ADMIN_PASS || "admin123"; + const hash = bcrypt.hashSync(password, 10); + db.prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)").run( + username, + hash + ); + console.log( + `[cicd] Default admin created: ${username} / ${password} ← change this!` + ); +} export default db; diff --git a/packages/server/src/db/schema.sql b/packages/server/src/db/schema.sql index abf98fc..7392a6b 100644 --- a/packages/server/src/db/schema.sql +++ b/packages/server/src/db/schema.sql @@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS projects ( created_at INTEGER DEFAULT (unixepoch()) ); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) +); + CREATE TABLE IF NOT EXISTS builds ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, diff --git a/packages/server/src/index.js b/packages/server/src/index.js index 7bef083..0db150e 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -1,5 +1,8 @@ +import { randomBytes } from "crypto"; import Fastify from "fastify"; import staticPlugin from "@fastify/static"; +import cookie from "@fastify/cookie"; +import session from "@fastify/session"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { existsSync } from "fs"; @@ -7,22 +10,53 @@ import { existsSync } from "fs"; import projectRoutes from "./routes/projects.js"; import buildRoutes from "./routes/builds.js"; import webhookRoutes from "./routes/webhook.js"; +import authRoutes from "./routes/auth.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const fastify = Fastify({ logger: true }); -// Store raw body for webhook signature verification -fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (req, body, done) => { - req.rawBody = body; - try { - done(null, JSON.parse(body.toString())); - } catch (err) { - done(err); +// Raw body for webhook signature verification +fastify.addContentTypeParser( + "application/json", + { parseAs: "buffer" }, + (req, body, done) => { + req.rawBody = body; + try { + done(null, JSON.parse(body.toString())); + } catch (err) { + done(err); + } + } +); + +// Auth plugins +await fastify.register(cookie); +await fastify.register(session, { + secret: process.env.SESSION_SECRET || randomBytes(32).toString("hex"), + saveUninitialized: false, + rolling: false, + cookie: { + secure: false, + httpOnly: true, + maxAge: 7 * 24 * 3600 * 1000, + }, +}); + +// Auth guard: protect all /api/* except login, me, and webhooks +const AUTH_WHITELIST = ["/api/auth/login", "/api/auth/me"]; +fastify.addHook("onRequest", async (request, reply) => { + const { url, method } = request; + if (!url.startsWith("/api/")) return; + if (AUTH_WHITELIST.includes(url)) return; + if (url.startsWith("/api/webhook/")) return; + if (!request.session.userId) { + return reply.code(401).send({ error: "Not authenticated" }); } }); -// API routes +// Routes +fastify.register(authRoutes); fastify.register(projectRoutes); fastify.register(buildRoutes); fastify.register(webhookRoutes); diff --git a/packages/server/src/routes/auth.js b/packages/server/src/routes/auth.js new file mode 100644 index 0000000..53be1e7 --- /dev/null +++ b/packages/server/src/routes/auth.js @@ -0,0 +1,31 @@ +import bcrypt from "bcryptjs"; +import db from "../db/index.js"; + +export default async function authRoutes(fastify) { + fastify.post("/api/auth/login", async (request, reply) => { + const { username, password } = request.body ?? {}; + if (!username || !password) + return reply.code(400).send({ error: "username and password required" }); + + const user = db + .prepare("SELECT * FROM users WHERE username = ?") + .get(username); + if (!user || !(await bcrypt.compare(password, user.password_hash))) + return reply.code(401).send({ error: "Invalid credentials" }); + + request.session.userId = user.id; + request.session.username = user.username; + return { id: user.id, username: user.username }; + }); + + fastify.post("/api/auth/logout", async (request, reply) => { + await request.session.destroy(); + return reply.code(204).send(); + }); + + fastify.get("/api/auth/me", async (request, reply) => { + if (!request.session.userId) + return reply.code(401).send({ error: "Not authenticated" }); + return { id: request.session.userId, username: request.session.username }; + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43a4cbc..d834c67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,18 @@ importers: packages/server: dependencies: + '@fastify/cookie': + specifier: ^11.0.0 + version: 11.0.2 + '@fastify/session': + specifier: ^11.0.0 + version: 11.1.1 '@fastify/static': specifier: ^8.0.0 version: 8.3.0 + bcryptjs: + specifier: ^3.0.2 + version: 3.0.3 better-sqlite3: specifier: ^11.0.0 version: 11.10.0 @@ -321,6 +330,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -339,6 +351,9 @@ packages: '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + '@fastify/session@11.1.1': + resolution: {integrity: sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==} + '@fastify/static@8.3.0': resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} @@ -596,6 +611,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} @@ -1649,6 +1668,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.1.0 + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.0.3': @@ -1674,6 +1698,11 @@ snapshots: http-errors: 2.0.1 mime: 3.0.0 + '@fastify/session@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + safe-stable-stringify: 2.5.0 + '@fastify/static@8.3.0': dependencies: '@fastify/accept-negotiator': 2.0.1 @@ -1890,6 +1919,8 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bcryptjs@3.0.3: {} + better-sqlite3@11.10.0: dependencies: bindings: 1.5.0