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:
@@ -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