feat(profile): self-service name/email/password + desktop installers in CI
Self-service profile: any signed-in user edits their OWN fullName/email and changes their OWN password (proving the current one), without any user:* permission. New routes PUT /api/auth/profile + /api/auth/password act only on req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile reachable from the header username chip. email added to the session view + SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green. Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage on every push to dev/main and uploads them as unsigned workflow artifacts (per-commit test build). Signed/versioned release stays on release.yml (tag v*). Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI split), log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { changeMyPassword, updateMyProfile, type SessionUser } from "./api.js";
|
||||
|
||||
// Self-service profile: the signed-in user edits their OWN display name + email and
|
||||
// changes their OWN password (proving the current one). This is NOT the admin
|
||||
// user-manager (UsersManager.tsx) — it never touches another account, username, or
|
||||
// role, and needs no `user:*` permission. See routes/auth.ts (/api/auth/profile,
|
||||
// /api/auth/password) and wiki/entities/local-jwt-auth.md.
|
||||
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
export function Profile({
|
||||
user,
|
||||
setUser,
|
||||
}: {
|
||||
user: SessionUser;
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// --- Account (name / email) ---
|
||||
const [fullName, setFullName] = useState(user.fullName ?? "");
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [accountMsg, setAccountMsg] = useState<string | null>(null);
|
||||
const [savingAccount, setSavingAccount] = useState(false);
|
||||
|
||||
async function saveAccount() {
|
||||
setAccountMsg(null);
|
||||
setSavingAccount(true);
|
||||
try {
|
||||
const next = await updateMyProfile({ fullName, email });
|
||||
// Keep the router-context user in sync so the header reflects the change.
|
||||
setUser(next);
|
||||
setFullName(next.fullName ?? "");
|
||||
setEmail(next.email ?? "");
|
||||
setAccountMsg(t("profile.profileSaved"));
|
||||
} catch (e) {
|
||||
setAccountMsg((e as Error).message);
|
||||
} finally {
|
||||
setSavingAccount(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Password ---
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
|
||||
async function changePassword() {
|
||||
setPwMsg(null);
|
||||
if (next.length < MIN_PASSWORD) {
|
||||
setPwMsg(t("profile.passwordTooShort", { min: MIN_PASSWORD }));
|
||||
return;
|
||||
}
|
||||
if (next !== confirm) {
|
||||
setPwMsg(t("profile.passwordsDontMatch"));
|
||||
return;
|
||||
}
|
||||
setSavingPw(true);
|
||||
try {
|
||||
await changeMyPassword(current, next);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setConfirm("");
|
||||
setPwMsg(t("profile.passwordChanged"));
|
||||
} catch (e) {
|
||||
setPwMsg((e as Error).message);
|
||||
} finally {
|
||||
setSavingPw(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-xl flex-col gap-6">
|
||||
<h1 className="text-lg text-term-text">{t("profile.title")}</h1>
|
||||
|
||||
{/* Account: display name + email (username + role are read-only — admin-managed). */}
|
||||
<section className="card flex flex-col gap-3 p-4">
|
||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||
{t("profile.accountSection")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
|
||||
<div>
|
||||
<span className="block">{t("profile.username")}</span>
|
||||
<span className="text-sm text-term-text">{user.username}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block">{t("profile.role")}</span>
|
||||
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
{t("profile.fullName")}
|
||||
<input
|
||||
className="input"
|
||||
value={fullName}
|
||||
placeholder={t("profile.fullNamePh")}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
{t("profile.email")}
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={email}
|
||||
placeholder={t("profile.emailPh")}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||
{t("profile.saveProfile")}
|
||||
</button>
|
||||
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Password: requires the current one (server enforces). */}
|
||||
<section className="card flex flex-col gap-3 p-4">
|
||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||
{t("profile.passwordSection")}
|
||||
</h2>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
{t("profile.currentPassword")}
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
{t("profile.newPassword")}
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||
{t("profile.confirmPassword")}
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={changePassword}
|
||||
disabled={savingPw || !current || !next || !confirm}
|
||||
>
|
||||
{t("profile.changePassword")}
|
||||
</button>
|
||||
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user