parent
19d14bd6a3
commit
c1eb759436
|
|
@ -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() {
|
|||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="/builds/:id" element={<BuildDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -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,6 +18,16 @@ export default function Layout() {
|
|||
Paul CI/CD
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-1.5 text-xs transition-colors ${isActive ? "text-gray-200" : "text-gray-400 hover:text-gray-200"}`
|
||||
}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
Settings
|
||||
</NavLink>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-200 transition-colors"
|
||||
|
|
@ -25,6 +35,7 @@ export default function Layout() {
|
|||
<LogOut className="w-3.5 h-3.5" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
|
||||
{/* Username */}
|
||||
<section className="bg-gray-900 border border-gray-800 rounded-lg p-5 space-y-4 max-w-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPen className="w-4 h-4 text-gray-400" />
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
|
||||
Change Username
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={saveUsername} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400">Username</label>
|
||||
<input
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(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"
|
||||
/>
|
||||
</div>
|
||||
{usernameError && <p className="text-xs text-red-400">{usernameError}</p>}
|
||||
{usernameSuccess && <p className="text-xs text-green-400">Username updated successfully.</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={usernameLoading || newUsername.trim() === username}
|
||||
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
{usernameLoading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Password */}
|
||||
<section className="bg-gray-900 border border-gray-800 rounded-lg p-5 space-y-4 max-w-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-gray-400" />
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
|
||||
Change Password
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={savePassword} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pwForm.current_password}
|
||||
onChange={(e) => setPwForm((f) => ({ ...f, current_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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pwForm.new_password}
|
||||
onChange={(e) => setPwForm((f) => ({ ...f, new_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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-gray-400">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pwForm.confirm_password}
|
||||
onChange={(e) => setPwForm((f) => ({ ...f, confirm_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"
|
||||
/>
|
||||
</div>
|
||||
{pwError && <p className="text-xs text-red-400">{pwError}</p>}
|
||||
{pwSuccess && <p className="text-xs text-green-400">Password changed successfully.</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pwLoading}
|
||||
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
{pwLoading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"}
|
||||
{"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"}
|
||||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue