feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission- gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing the top nav to Booth·Shift·Setup; old top-level paths redirect. Users: add optional profile metadata (full name, phone, email, address) on create/edit. Theme: a light palette saved to the user's profile (users.theme), toggled in the header beside the language switch and applied on load like the language preference. Both ride on a single additive migration (0008). Shift history: a new GET /api/shifts folds the signed shift_z_report chain into completed shifts, SCOPED server-side — operators see only their own; holders of shift:cash see all with an operator + date-range filter. Surfaced as the Shifts tab; an operator cannot read another operator's takings (param spoofing is ignored). These three features share the router, api client and i18n catalogs, so they land together. Verified live: theme persists across reload, metadata round- trips to the DB, and shift scoping holds (operator self-only, admin all+filter). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -23,10 +23,26 @@ interface LanguageBody {
|
||||
language: Lang;
|
||||
}
|
||||
|
||||
const THEMES = ["dark", "light"] as const;
|
||||
type Theme = (typeof THEMES)[number];
|
||||
interface ThemeBody {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function sessionView(db: Db, user: { id: string; username: string; roleId: string; language: string }) {
|
||||
function sessionView(
|
||||
db: Db,
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
language: string;
|
||||
theme: string;
|
||||
fullName?: string | null;
|
||||
},
|
||||
) {
|
||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||
const permissions = [...permissionsFor(user.roleId)];
|
||||
return {
|
||||
@@ -36,6 +52,8 @@ function sessionView(db: Db, user: { id: string; username: string; roleId: strin
|
||||
roleName: role?.name ?? user.roleId,
|
||||
permissions,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
fullName: user.fullName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,4 +124,19 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return { language };
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own UI theme preference (any signed-in user). Persisted to the users
|
||||
// row like `language`, so it's restored on the next login from any booth.
|
||||
app.put<{ Body: ThemeBody }>(
|
||||
"/api/auth/theme",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const theme = req.body?.theme;
|
||||
if (!theme || !THEMES.includes(theme)) {
|
||||
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
|
||||
}
|
||||
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
|
||||
return { theme };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import {
|
||||
InvalidCashMovementError,
|
||||
NoOpenShiftError,
|
||||
@@ -14,6 +14,14 @@ interface CashMovementBody {
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
interface ShiftsQuery {
|
||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||
operator?: string;
|
||||
/** ISO window over shift START time. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||
@@ -43,6 +51,23 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
||||
};
|
||||
});
|
||||
|
||||
// Completed shift history. SCOPED by permission:
|
||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||
// `operator` and a `from`/`to` time window over each shift's START.
|
||||
// This keeps one operator from reading another's takings while letting admins
|
||||
// reconcile across the site. The data is the signed shift_z_report chain.
|
||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
|
||||
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||
const q = req.query ?? {};
|
||||
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||
const shifts = shift.listShifts({ operator, from, to });
|
||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||
});
|
||||
|
||||
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
|
||||
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
||||
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
||||
|
||||
@@ -21,12 +21,20 @@ import { permissionsFor, requirePermission } from "../auth.js";
|
||||
// account takeover; deleting an admin is sabotage). Both are blocked below by
|
||||
// comparing permission SETS. An admin holds the full set, so it is unrestricted.
|
||||
|
||||
interface CreateBody {
|
||||
// Optional profile metadata accepted on create/update. All nullable; "" is treated
|
||||
// as "clear" (→ null). Trimmed before persisting.
|
||||
interface ProfileBody {
|
||||
fullName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
address?: string | null;
|
||||
}
|
||||
interface CreateBody extends ProfileBody {
|
||||
username: string;
|
||||
password: string;
|
||||
roleId: string;
|
||||
}
|
||||
interface UpdateBody {
|
||||
interface UpdateBody extends ProfileBody {
|
||||
username?: string;
|
||||
roleId?: string;
|
||||
}
|
||||
@@ -35,6 +43,20 @@ interface PasswordBody {
|
||||
}
|
||||
|
||||
const MIN_PASSWORD = 8;
|
||||
const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const;
|
||||
|
||||
/** Pull the optional profile fields out of a body → a patch of trimmed values
|
||||
* ("" → null). Absent keys are omitted (so an update only touches what's sent). */
|
||||
function profilePatch(body: ProfileBody): Record<string, string | null> {
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const k of PROFILE_FIELDS) {
|
||||
const v = body[k];
|
||||
if (v === undefined) continue;
|
||||
const trimmed = typeof v === "string" ? v.trim() : "";
|
||||
out[k] = trimmed === "" ? null : trimmed;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const readGuard = requirePermission("user:read");
|
||||
@@ -54,8 +76,28 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
|
||||
/** A user row safe to return — never the password hash. */
|
||||
function publicUser(u: { id: string; username: string; roleId: string; language: string; createdAt: string }) {
|
||||
return { id: u.id, username: u.username, roleId: u.roleId, language: u.language, createdAt: u.createdAt };
|
||||
function publicUser(u: {
|
||||
id: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
language: string;
|
||||
createdAt: string;
|
||||
fullName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
address?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
roleId: u.roleId,
|
||||
language: u.language,
|
||||
createdAt: u.createdAt,
|
||||
fullName: u.fullName ?? null,
|
||||
phone: u.phone ?? null,
|
||||
email: u.email ?? null,
|
||||
address: u.address ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True if `targetRoleId` grants any permission the caller's role does NOT hold,
|
||||
@@ -103,7 +145,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
const id = randomUUID();
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
db.insert(users).values({ id, username, passwordHash, roleId }).run();
|
||||
db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run();
|
||||
const created = db.select().from(users).where(eq(users.id, id)).get()!;
|
||||
return reply.code(201).send(publicUser(created));
|
||||
});
|
||||
@@ -121,7 +163,9 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
|
||||
}
|
||||
|
||||
const next: { username?: string; roleId?: string } = {};
|
||||
const next: { username?: string; roleId?: string } & Record<string, string | null> = {
|
||||
...profilePatch(req.body ?? {}),
|
||||
};
|
||||
if (req.body?.username != null) {
|
||||
const username = req.body.username.trim();
|
||||
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
|
||||
|
||||
Reference in New Issue
Block a user