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
+
+
+
+
+ Logout
+
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 (
+
+ );
+}
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