Compare commits
2 Commits
aa546235fb
...
8129b63a8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 8129b63a8c | |||
| f9bd586265 |
@@ -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
|
||||||
@@ -29,6 +29,33 @@ interface ThemeBody {
|
|||||||
theme: Theme;
|
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
|
/** 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
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* permissions are the source of truth. */
|
||||||
@@ -41,6 +68,7 @@ function sessionView(
|
|||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
@@ -54,6 +82,7 @@ function sessionView(
|
|||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
|
email: user.email ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return { theme };
|
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;
|
theme: Theme;
|
||||||
/** Optional display name (profile metadata); null if unset. */
|
/** Optional display name (profile metadata); null if unset. */
|
||||||
fullName: string | null;
|
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. */
|
/** 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 }) });
|
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. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ export const en: Catalog = {
|
|||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
recycleBin: "Recycle bin",
|
recycleBin: "Recycle bin",
|
||||||
logs: "Logs",
|
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: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ export const sq = {
|
|||||||
reports: "Raportet",
|
reports: "Raportet",
|
||||||
recycleBin: "Koshi",
|
recycleBin: "Koshi",
|
||||||
logs: "Loget",
|
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: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
|
|||||||
+23
-3
@@ -31,6 +31,7 @@ import { RolesManager } from "./RolesManager.js";
|
|||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { RecycleBin } from "./RecycleBin.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
|
// 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.
|
// initial bundle and only downloads when an admin opens /setup/reports.
|
||||||
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.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 && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
<span className="text-[11px] text-term-muted">
|
{user && (
|
||||||
{user?.username} · {user?.roleName}
|
<Link
|
||||||
</span>
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
@@ -635,10 +642,23 @@ const logsRoute = createRoute({
|
|||||||
component: LogsViewer,
|
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([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
reportsRoute,
|
reportsRoute,
|
||||||
subscriptionsRoute.addChildren([
|
subscriptionsRoute.addChildren([
|
||||||
|
|||||||
@@ -148,20 +148,73 @@ pressing `e` at the menu prompts for `admin` + password. Store the GRUB password
|
|||||||
> OS hardening on the first unit is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7) + Secure Boot
|
> OS hardening on the first unit is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7) + Secure Boot
|
||||||
> (Deployed) + GRUB edit-lock.
|
> (Deployed) + GRUB edit-lock.
|
||||||
|
|
||||||
|
## 5c. OS user model — admin vs operator (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
|
The OS has TWO roles and they must be different identities ([[threat-model]]: the operator is the
|
||||||
|
adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) and keep the
|
||||||
|
**operator** as an auto-login, UNPRIVILEGED account.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser admin && sudo usermod -aG sudo admin
|
||||||
|
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
||||||
|
sudo deluser <operator> sudo # demote the auto-login operator
|
||||||
|
groups <operator> # confirm: no 'sudo'
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
||||||
|
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
||||||
|
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
||||||
|
|
||||||
|
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
|
||||||
|
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
||||||
|
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
|
||||||
|
|
||||||
## 5b. Further hardening (TODO — not yet done)
|
## 5b. Further hardening (TODO — not yet done)
|
||||||
|
|
||||||
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
|
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
|
||||||
- **No/locked-down desktop** — single-purpose; autostart the kiosk ([[desktop-shell-tauri]]).
|
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
|
||||||
|
([[desktop-shell-tauri]]).
|
||||||
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
||||||
- `sudo apt autoremove` the leftover old kernel (`linux-*-7.0.0-14`) once the new one is proven.
|
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
|
||||||
|
|
||||||
## 6. Runtime — Docker stack
|
## 6. Runtime — Docker stack (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
Per [[container-deployment]]: install Docker Engine + compose, then run the `parking-server` +
|
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
|
||||||
`parking-vision` images via `docker-compose.yml -f docker-compose.prod.yml`. Provide a real
|
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
|
||||||
`JWT_SECRET` (`openssl rand -hex 32`) and `COOKIE_SECURE=0` (plain-http booth LAN — see
|
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
|
||||||
[[disk-os-hardening]] deploy-time runbook). Images are published to the Gitea registry by
|
|
||||||
`build-images.yml` on push to dev/main.
|
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
|
||||||
|
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
|
||||||
|
(chmod 600). The `.env` (driven into the containers by the base compose):
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
|
||||||
|
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
|
||||||
|
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
||||||
|
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.infra.msai.al # a read-only package token, not the account password
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
|
||||||
|
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
|
||||||
|
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
||||||
|
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
|
||||||
|
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
|
||||||
|
|
||||||
|
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
||||||
|
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
||||||
|
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
||||||
|
`hosts`/DNS ON-SITE, never an image rebuild.
|
||||||
|
|
||||||
## Quick-reference: the gotchas, in order they bit us
|
## Quick-reference: the gotchas, in order they bit us
|
||||||
|
|
||||||
|
|||||||
@@ -167,3 +167,19 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
||||||
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
||||||
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
||||||
|
|
||||||
|
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
||||||
|
|
||||||
|
The desktop bundle now runs in CI under **two distinct workflows** — keep the split clear:
|
||||||
|
|
||||||
|
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
||||||
|
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
||||||
|
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
|
||||||
|
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
||||||
|
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
||||||
|
and uploads them as **workflow artifacts** (14-day retention). **Unsigned** — no `TAURI_SIGNING_*`,
|
||||||
|
no Release, no `latest.json` — so it must NEVER be wired to the updater (an unsigned artifact would
|
||||||
|
be rejected anyway). It exists so each branch push yields a downloadable installer for manual
|
||||||
|
testing of the native shell, and catches a broken Tauri/Rust build early. Same system-deps + cargo
|
||||||
|
cache as `release.yml`. The container images (`build-images.yml`) and the desktop installers are
|
||||||
|
deliberately separate pipelines — the desktop app is **not** containerized ([[container-deployment]]).
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and s
|
|||||||
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
|
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
|
||||||
|
|
||||||
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
|
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
|
||||||
|
|
||||||
|
**Self-service profile (added 2026-06-24).** Alongside the admin user-manager (`routes/users.ts`,
|
||||||
|
gated on `user:*`), any signed-in user has two **self-only** routes (no permission needed — they
|
||||||
|
act solely on `req.user.sub`):
|
||||||
|
- `PUT /api/auth/profile` — edit own `fullName` / `email` (`""` clears → null). Returns the
|
||||||
|
refreshed session (so the SPA header updates). **Cannot** touch `username` or `role` — those stay
|
||||||
|
admin-only, so this is not a privilege-escalation surface.
|
||||||
|
- `PUT /api/auth/password` — change own password, but **must prove the current one** first
|
||||||
|
(`bcrypt.compare`) → defends a walked-up, already-logged-in booth from a silent re-key. New
|
||||||
|
password ≥ 8 chars. Distinct from the admin reset (`PUT /api/users/:id/password`), which needs no
|
||||||
|
current password but DOES need `user:update` + the no-escalation guard.
|
||||||
|
Both are still CSRF-guarded (mutations). The SPA surfaces them at `/profile` (`apps/web/src/Profile.tsx`),
|
||||||
|
reachable from the header username chip. Covered by `apps/server/src/routes/profile.test.ts`.
|
||||||
|
|
||||||
|
The dev
|
||||||
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
|
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
|
||||||
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
|
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
|
||||||
`SETUP_AUTH_BYPASS` shim, now removed.)
|
`SETUP_AUTH_BYPASS` shim, now removed.)
|
||||||
|
|||||||
+36
@@ -1516,3 +1516,39 @@ prompts for admin+password. OS hardening on unit 1 is now COMPLETE: LUKS FDE + T
|
|||||||
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
|
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
|
||||||
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
|
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
|
||||||
the images pushed — dev push + registry secrets pending).
|
the images pushed — dev push + registry secrets pending).
|
||||||
|
|
||||||
|
## [2026-06-23] deploy | First booth GO-LIVE — Docker stack running + web-access fixes (CI uv, compose env, relative /api, Caddy)
|
||||||
|
Deployed the two images onto the hardened booth (Dell 7070, Ubuntu 26.04) and worked through the
|
||||||
|
real-world bring-up issues. (1) Operator/admin OS user split: created a dedicated sudo `admin` user,
|
||||||
|
removed the auto-login operator from `sudo` (and should drop `lxd`/`lpadmin` — lxd is a root-escape
|
||||||
|
path); admin is the only sudo, operator auto-logs in unprivileged. (2) Docker 29.6 installed; deploy
|
||||||
|
dir /opt/parking_solution with hand-copied compose + .env; registry login to git.infra.msai.al; the
|
||||||
|
stack came up clean — vision fast_alpr loaded from the BAKED cache (0 downloads → offline-first
|
||||||
|
confirmed on real hardware), server migrated /data, both healthy. (3) Seeded the first admin via
|
||||||
|
`docker compose exec server node scripts/seed-admin.mjs` (bcrypt, writes users table — NOT the signed
|
||||||
|
ledger). FIXES committed this session: CI `astral-sh/setup-uv` action failed on the Gitea runner →
|
||||||
|
install uv via its official curl script instead (both ci.yml + build-images.yml) [0a22eab]; the base
|
||||||
|
compose only forwarded JWT_SECRET/DATABASE_URL/VISION_URL → added COOKIE_SECURE (CRITICAL on plain-
|
||||||
|
http or login cookies never send), WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED [1092316];
|
||||||
|
the SPA had VITE_API_BASE=http://127.0.0.1:3000 baked in (leaked from apps/web/.env.production, which
|
||||||
|
is for the TAURI build but Vite auto-loads it for every build) → server Dockerfile now empties it via
|
||||||
|
.env.production.local so the SPA uses RELATIVE /api and works from ANY host [77b2acb]; added a CADDY
|
||||||
|
reverse proxy (prod override) so the booth is reached on a clean port-80 URL, server goes internal,
|
||||||
|
Caddyfile binds :80 to match any hostname incl. parksystems.msai.al [c637b27]. NET RESULT: no domain
|
||||||
|
baked into any image — naming controlled by hosts/DNS on-site; admin can reach it from another LAN PC.
|
||||||
|
Verified the relative-/api + Caddy fix end-to-end locally (Host: parksystems.msai.al through :80 →
|
||||||
|
SPA + /api/auth/login reach the server, no CORS). See [[container-deployment]] "Web access",
|
||||||
|
[[appliance-provisioning]]. REMAINING on the box: push dev so CI rebuilds parking-server:dev with the
|
||||||
|
relative-/api fix, then pull on the booth; kiosk autostart; operator user lxd/lpadmin cleanup.
|
||||||
|
|
||||||
|
## [2026-06-24] build | Self-service user profile + desktop installers in CI
|
||||||
|
Two app-side additions. (1) **Self-service profile** — any signed-in user can now edit their OWN
|
||||||
|
`fullName`/`email` and change their OWN password (proving the current one), without any `user:*`
|
||||||
|
permission. New routes `PUT /api/auth/profile` + `PUT /api/auth/password` (act only on `req.user.sub`;
|
||||||
|
cannot touch username/role; CSRF-guarded), SPA screen `apps/web/src/Profile.tsx` at `/profile` (header
|
||||||
|
username chip links to it), `email` added to the session view + `SessionUser`. 7 new tests
|
||||||
|
(`routes/profile.test.ts`); server 148/148 green. Distinct from the admin user-manager (`routes/users.ts`,
|
||||||
|
`user:*`-gated). See [[local-jwt-auth]]. (2) **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); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||||||
|
[[desktop-shell-tauri]] "Desktop in CI".
|
||||||
|
|||||||
Reference in New Issue
Block a user