diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx
index b75af04..f16143a 100644
--- a/packages/client/src/App.tsx
+++ b/packages/client/src/App.tsx
@@ -4,6 +4,7 @@ import Projects from "./pages/Projects";
import ProjectDetail from "./pages/ProjectDetail";
import BuildDetail from "./pages/BuildDetail";
import Login from "./pages/Login";
+import Settings from "./pages/Settings";
import Layout from "./components/Layout";
function AuthGuard() {
@@ -33,6 +34,7 @@ export default function App() {
} />
} />
} />
+ } />
diff --git a/packages/client/src/components/Layout.tsx b/packages/client/src/components/Layout.tsx
index 5e007a2..722fa30 100644
--- a/packages/client/src/components/Layout.tsx
+++ b/packages/client/src/components/Layout.tsx
@@ -1,5 +1,5 @@
import { Outlet, NavLink, useNavigate } from "react-router-dom";
-import { GitBranch, LogOut } from "lucide-react";
+import { GitBranch, LogOut, Settings } from "lucide-react";
export default function Layout() {
const navigate = useNavigate();
@@ -18,13 +18,24 @@ export default function Layout() {
Paul CI/CD
-
+
+
+ `flex items-center gap-1.5 text-xs transition-colors ${isActive ? "text-gray-200" : "text-gray-400 hover:text-gray-200"}`
+ }
+ >
+
+ Settings
+
+
+
diff --git a/packages/client/src/pages/ProjectDetail.tsx b/packages/client/src/pages/ProjectDetail.tsx
index 19e3a79..f608dbb 100644
--- a/packages/client/src/pages/ProjectDetail.tsx
+++ b/packages/client/src/pages/ProjectDetail.tsx
@@ -97,8 +97,9 @@ export default function ProjectDetail() {
const res = await fetch(`/api/projects/${id}/trigger`, { method: "POST" });
const data = await res.json();
setTriggering(false);
- if (data.buildId) navigate(`/builds/${data.buildId}`);
- load();
+ if (data.buildId) {
+ navigate(`/builds/${data.buildId}`);
+ }
};
const webhookUrl = `${window.location.origin}/api/webhook/${id}`;
diff --git a/packages/client/src/pages/Settings.tsx b/packages/client/src/pages/Settings.tsx
new file mode 100644
index 0000000..dd0f3cc
--- /dev/null
+++ b/packages/client/src/pages/Settings.tsx
@@ -0,0 +1,159 @@
+import { useState, useEffect } from "react";
+import { KeyRound, UserPen } from "lucide-react";
+
+export default function Settings() {
+ const [username, setUsername] = useState("");
+ const [newUsername, setNewUsername] = useState("");
+ const [usernameError, setUsernameError] = useState("");
+ const [usernameSuccess, setUsernameSuccess] = useState(false);
+ const [usernameLoading, setUsernameLoading] = useState(false);
+
+ const [pwForm, setPwForm] = useState({
+ current_password: "",
+ new_password: "",
+ confirm_password: "",
+ });
+ const [pwError, setPwError] = useState("");
+ const [pwSuccess, setPwSuccess] = useState(false);
+ const [pwLoading, setPwLoading] = useState(false);
+
+ useEffect(() => {
+ fetch("/api/auth/me")
+ .then((r) => r.json())
+ .then((d) => {
+ setUsername(d.username);
+ setNewUsername(d.username);
+ });
+ }, []);
+
+ const saveUsername = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setUsernameError("");
+ setUsernameSuccess(false);
+ if (newUsername.trim() === username) return;
+ setUsernameLoading(true);
+ const res = await fetch("/api/auth/change-username", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ new_username: newUsername.trim() }),
+ });
+ setUsernameLoading(false);
+ if (res.ok) {
+ setUsername(newUsername.trim());
+ setUsernameSuccess(true);
+ } else {
+ const data = await res.json();
+ setUsernameError(data.error || "Failed to update username");
+ }
+ };
+
+ const savePassword = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setPwError("");
+ setPwSuccess(false);
+ if (pwForm.new_password !== pwForm.confirm_password) {
+ setPwError("New passwords do not match");
+ return;
+ }
+ setPwLoading(true);
+ const res = await fetch("/api/auth/change-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ current_password: pwForm.current_password,
+ new_password: pwForm.new_password,
+ }),
+ });
+ setPwLoading(false);
+ if (res.ok) {
+ setPwSuccess(true);
+ setPwForm({ current_password: "", new_password: "", confirm_password: "" });
+ } else {
+ const data = await res.json();
+ setPwError(data.error || "Failed to change password");
+ }
+ };
+
+ return (
+
+
Settings
+
+ {/* Username */}
+
+
+
+
+ Change Username
+
+
+
+
+
+ {/* Password */}
+
+
+
+
+ Change Password
+
+
+
+
+
+ );
+}
diff --git a/packages/client/tsconfig.tsbuildinfo b/packages/client/tsconfig.tsbuildinfo
index b95fa06..2fef7ef 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/login.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","./src/pages/settings.tsx"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/packages/server/src/routes/auth.js b/packages/server/src/routes/auth.js
index 53be1e7..ca8e6c1 100644
--- a/packages/server/src/routes/auth.js
+++ b/packages/server/src/routes/auth.js
@@ -28,4 +28,34 @@ export default async function authRoutes(fastify) {
return reply.code(401).send({ error: "Not authenticated" });
return { id: request.session.userId, username: request.session.username };
});
+
+ fastify.post("/api/auth/change-username", async (request, reply) => {
+ const { new_username } = request.body ?? {};
+ if (!new_username?.trim())
+ return reply.code(400).send({ error: "new_username required" });
+
+ const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(new_username.trim());
+ if (existing)
+ return reply.code(409).send({ error: "Username already taken" });
+
+ db.prepare("UPDATE users SET username = ? WHERE id = ?").run(new_username.trim(), request.session.userId);
+ request.session.username = new_username.trim();
+ return { ok: true };
+ });
+
+ fastify.post("/api/auth/change-password", async (request, reply) => {
+ const { current_password, new_password } = request.body ?? {};
+ if (!current_password || !new_password)
+ return reply.code(400).send({ error: "current_password and new_password required" });
+ if (new_password.length < 6)
+ return reply.code(400).send({ error: "new_password must be at least 6 characters" });
+
+ const user = db.prepare("SELECT * FROM users WHERE id = ?").get(request.session.userId);
+ if (!user || !(await bcrypt.compare(current_password, user.password_hash)))
+ return reply.code(401).send({ error: "Current password is incorrect" });
+
+ const hash = await bcrypt.hash(new_password, 10);
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, user.id);
+ return { ok: true };
+ });
}