Feat: Add Change Password

增加修改密码功能
This commit is contained in:
Paul 2026-03-28 12:31:02 +08:00
parent 19d14bd6a3
commit c1eb759436
6 changed files with 214 additions and 11 deletions

View File

@ -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>

View File

@ -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 />

View File

@ -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}`;

View File

@ -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>
);
}

View File

@ -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"}

View File

@ -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 };
});
}