8129b63a8c
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
172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|