diff --git a/.gitea/workflows/build-desktop.yml b/.gitea/workflows/build-desktop.yml new file mode 100644 index 0000000..3146d7e --- /dev/null +++ b/.gitea/workflows/build-desktop.yml @@ -0,0 +1,91 @@ +name: Build desktop + +# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and +# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the +# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea +# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage + +# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md. + +on: + push: + branches: [dev, main] + paths: + - 'apps/desktop/**' + - 'apps/web/**' + - 'packages/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.gitea/workflows/build-desktop.yml' + workflow_dispatch: + +jobs: + desktop: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@10.24.0 --activate + + - name: Install Tauri system deps + # Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/ + # appindicator/rsvg stack + AppImage tooling (patchelf, file). + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + file \ + build-essential \ + curl \ + wget + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + apps/desktop/src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build desktop bundle (.deb + .AppImage) + # Unsigned — no TAURI_SIGNING_* needed here (this is a test artifact, not an + # updater release). --bundles restricts to the two installers we ship; tauri + # builds the web SPA first (beforeBuildCommand), so the desktop UI matches. + run: pnpm --filter @parking/desktop bundle --bundles deb,appimage + + - name: Collect installers + id: collect + run: | + set -e + BUNDLE=apps/desktop/src-tauri/target/release/bundle + mkdir -p dist + find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' \) -exec cp {} dist/ \; + echo "Artifacts:"; ls -la dist/ + + - name: Upload installers + uses: actions/upload-artifact@v4 + with: + name: desktop-${{ github.ref_name }}-${{ github.sha }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index b4c50b3..33349d6 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -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 { 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 = {}; + 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 }; + }, + ); } diff --git a/apps/server/src/routes/profile.test.ts b/apps/server/src/routes/profile.test.ts new file mode 100644 index 0000000..0fdadc3 --- /dev/null +++ b/apps/server/src/routes/profile.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/Profile.tsx b/apps/web/src/Profile.tsx new file mode 100644 index 0000000..a9b2982 --- /dev/null +++ b/apps/web/src/Profile.tsx @@ -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(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(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 ( +
+

{t("profile.title")}

+ + {/* Account: display name + email (username + role are read-only — admin-managed). */} +
+

+ {t("profile.accountSection")} +

+
+
+ {t("profile.username")} + {user.username} +
+
+ {t("profile.role")} + {user.roleName} +
+
+ + +
+ + {accountMsg && {accountMsg}} +
+
+ + {/* Password: requires the current one (server enforces). */} +
+

+ {t("profile.passwordSection")} +

+ + + +
+ + {pwMsg && {pwMsg}} +
+
+
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 7558393..2ff35c9 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { + return apiFetch("/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 { try { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 6884338..dbd5f82 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 0d71e99..d77e000 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index bb25d4d..5eb57f0 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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 && } {user && } - - {user?.username} · {user?.roleName} - + {user && ( + + {user.username} · {user.roleName} + + )}