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:
@@ -29,6 +29,33 @@ interface ThemeBody {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
||||
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
||||
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
|
||||
interface ProfileBody {
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
// Self-service password change: the user proves they hold the CURRENT password before
|
||||
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
|
||||
// why it lives here and not behind a permission: it's account-self-care, not admin power.
|
||||
interface PasswordBody {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
|
||||
* Returns undefined for an absent key so an update only touches what was sent. */
|
||||
function cleanProfileField(v: string | null | undefined): string | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
const trimmed = typeof v === "string" ? v.trim() : "";
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||
* permissions are the source of truth. */
|
||||
@@ -41,6 +68,7 @@ function sessionView(
|
||||
language: string;
|
||||
theme: string;
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
},
|
||||
) {
|
||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||
@@ -54,6 +82,7 @@ function sessionView(
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
fullName: user.fullName ?? null,
|
||||
email: user.email ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return { theme };
|
||||
},
|
||||
);
|
||||
|
||||
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
||||
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||
app.put<{ Body: ProfileBody }>(
|
||||
"/api/auth/profile",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const fullName = cleanProfileField(req.body?.fullName);
|
||||
const email = cleanProfileField(req.body?.email);
|
||||
const patch: Record<string, string | null> = {};
|
||||
if (fullName !== undefined) patch.fullName = fullName;
|
||||
if (email !== undefined) patch.email = email;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return reply.code(400).send({ error: "nothing to update" });
|
||||
}
|
||||
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
|
||||
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||
if (!row) return reply.code(401).send({ error: "session no longer valid" });
|
||||
return sessionView(db, row);
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
|
||||
// already-logged-in booth: a passerby can't silently re-key the account). New password
|
||||
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
|
||||
app.put<{ Body: PasswordBody }>(
|
||||
"/api/auth/password",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const currentPassword = req.body?.currentPassword ?? "";
|
||||
const newPassword = req.body?.newPassword ?? "";
|
||||
if (newPassword.length < MIN_PASSWORD) {
|
||||
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||
}
|
||||
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||
if (!row) {
|
||||
clearAuthCookies(reply);
|
||||
return reply.code(401).send({ error: "session no longer valid" });
|
||||
}
|
||||
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
|
||||
if (!ok) {
|
||||
return reply.code(403).send({ error: "current password is incorrect" });
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act
|
||||
// ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change
|
||||
// must prove the current password. Distinct from admin user-management (routes/users.ts).
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/profile (self-service)", () => {
|
||||
it("a permission-less user can edit their OWN name + email", async () => {
|
||||
// 'viewer' role with NO user:* permission — profile is not gated on it.
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "cashier", roleId: "viewer", permissions: [],
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.fullName).toBe("Mon Kukaleshi");
|
||||
expect(body.email).toBe("mon@example.com");
|
||||
// Persisted to the caller's own row.
|
||||
const row = db.select().from(users).where(eq(users.username, "cashier")).get();
|
||||
expect(row?.fullName).toBe("Mon Kukaleshi");
|
||||
expect(row?.email).toBe("mon@example.com");
|
||||
});
|
||||
|
||||
it('clears a field when sent ""', async () => {
|
||||
const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
// First set a name…
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fullName: "Old Name" },
|
||||
});
|
||||
// …then clear it with whitespace (→ null).
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fullName: " " },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().fullName).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an empty patch (nothing to update)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/profile",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("requires a session (401 without a token)", async () => {
|
||||
const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/password (self-service)", () => {
|
||||
it("changes the password when the current one is correct, and the new one then logs in", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/password",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { currentPassword: password, newPassword: "brand-new-pw-123" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Old password no longer works; new one does.
|
||||
const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||
expect(oldTry.statusCode).toBe(401);
|
||||
const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } });
|
||||
expect(newTry.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/password",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
// Original password still works.
|
||||
const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||
expect(still.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a too-short new password (400)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/password",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { currentPassword: password, newPassword: "short" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user