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
+77
View File
@@ -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 };
},
);
}
+130
View File
@@ -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);
});
});