feat(profile): self-service name/email/password + desktop installers in CI
Build desktop / desktop (push) Failing after 5m2s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s

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:
2026-06-24 10:15:34 +02:00
parent f9bd586265
commit 8129b63a8c
11 changed files with 602 additions and 3 deletions
+171
View File
@@ -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>
);
}
+25
View File
@@ -75,6 +75,8 @@ export interface SessionUser {
theme: Theme;
/** Optional display name (profile metadata); null if unset. */
fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */
email: string | null;
}
/** Does this session grant the permission? Central authz check for the SPA. */
@@ -103,6 +105,29 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
}
/** Edit MY own profile (display name / email). Returns the refreshed session.
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
export function updateMyProfile(patch: {
fullName?: string | null;
email?: string | null;
}): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/profile", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** Change MY own password — proves the current one first (server enforces). */
export function changeMyPassword(
currentPassword: string,
newPassword: string,
): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/password", {
method: "PUT",
body: JSON.stringify({ currentPassword, newPassword }),
});
}
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
+21
View File
@@ -58,6 +58,27 @@ export const en: Catalog = {
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
profile: "Profile",
},
profile: {
title: "My profile",
accountSection: "Account",
fullName: "Full name",
fullNamePh: "First and last name",
email: "Email",
emailPh: "you@example.com",
username: "Username",
role: "Role",
saveProfile: "Save profile",
profileSaved: "Profile saved.",
passwordSection: "Change password",
currentPassword: "Current password",
newPassword: "New password",
confirmPassword: "Confirm password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordsDontMatch: "Passwords don't match.",
passwordTooShort: "Password must be at least {{min}} characters.",
},
status: {
live: "LIVE",
+21
View File
@@ -60,6 +60,27 @@ export const sq = {
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
profile: "Profili",
},
profile: {
title: "Profili im",
accountSection: "Llogaria",
fullName: "Emri i plotë",
fullNamePh: "Emri dhe mbiemri",
email: "Email",
emailPh: "ti@shembull.com",
username: "Përdoruesi",
role: "Roli",
saveProfile: "Ruaj profilin",
profileSaved: "Profili u ruajt.",
passwordSection: "Ndrysho fjalëkalimin",
currentPassword: "Fjalëkalimi aktual",
newPassword: "Fjalëkalimi i ri",
confirmPassword: "Konfirmo fjalëkalimin",
changePassword: "Ndrysho fjalëkalimin",
passwordChanged: "Fjalëkalimi u ndryshua.",
passwordsDontMatch: "Fjalëkalimet nuk përputhen.",
passwordTooShort: "Fjalëkalimi duhet të jetë të paktën {{min}} karaktere.",
},
status: {
live: "LIVE",
+23 -3
View File
@@ -31,6 +31,7 @@ import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js";
import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
@@ -399,9 +400,15 @@ function RootLayout() {
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.roleName}
</span>
{user && (
<Link
to="/profile"
title={t("nav.profile")}
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
>
{user.username} · {user.roleName}
</Link>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
@@ -635,10 +642,23 @@ const logsRoute = createRoute({
component: LogsViewer,
});
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({
getParentRoute: () => rootRoute,
path: "profile",
component: function ProfileRoute() {
const { user, setUser } = rootRoute.useRouteContext();
if (!user) return null;
return <Profile user={user} setUser={setUser} />;
},
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
...legacyRedirects,
profileRoute,
shiftRoute,
reportsRoute,
subscriptionsRoute.addChildren([