Files
parking_solution/apps/web/src/lib/i18n/en.ts
T
julian d0841c8601 feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.

@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).

DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).

auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.

Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).

Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).

Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 01:19:28 +02:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
import type { Catalog } from "./sq.js";
export const en: Catalog = {
common: {
loading: "Loading…",
logout: "Log out",
cancel: "Cancel",
close: "Close",
save: "Save",
none: "—",
},
auth: {
title: "Parking System",
username: "Username",
password: "Password",
signIn: "Sign in",
signingIn: "Signing in…",
},
nav: {
booth: "Booth",
shift: "Shift",
setup: "Setup",
tariff: "Tariff",
subscriptions: "Subscriptions",
site: "Site",
users: "Users",
roles: "Roles",
},
status: {
live: "LIVE",
connecting: "CONNECTING",
offline: "OFFLINE",
},
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "entry/exit",
lane: "at lane",
booth: "at booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
open: "Open",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
inside: "inside",
of: "of",
uncapped: "uncapped",
free: "free",
lotFull: "● lot full",
liveFeed: "Live feed",
events: "events",
noEventsYet: "No events yet — entries and exits will stream here.",
activeSessions: "Active sessions",
insideCount: "inside",
noActiveSessions: "No active sessions.",
inAt: "in",
openPayExit: "Open pay / exit",
openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)",
barrierOpened: "barrier opened",
openManually: "open manually",
badgeExiting: "exiting",
badgePaid: "paid",
badgeUnpaid: "unpaid",
badgeSubscription: "subscription",
evtEntry: "ENTRY",
evtExit: "EXIT",
evtPay: "PAY",
evtVoid: "VOID",
evtOpenCmd: "OPEN→",
evtOpenObserved: "OPEN✓",
evtShiftOpen: "SHIFT+",
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtAnomaly: "ANOMALY",
},
tariff: {
title: "Tariff",
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
currency: "Currency",
freeEntryGrace: "Free entry grace (min)",
billingIncrement: "Billing increment (min)",
dailyCap: "Daily cap (blank = none)",
dailyCapPh: "e.g. 12.00",
lostTicketFee: "Lost-ticket fee",
exitGrace: "Exit walk-back grace (min)",
rateBlocks: "Rate blocks",
rateBlocksHint: "Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.",
bandDuration: "Band duration",
hoursUnit: "hours",
egHours: "e.g. 2",
pricePerIncrement: "Price / increment",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.",
defaultCard: "Default card (always active)",
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder",
modeFlat: "Flat price",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add cards that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, the simple card is published.",
tierName: "Name",
tierPriority: "Priority",
tierCategory: "Category",
tierCategoryPh: "e.g. bus",
tierDays: "Days",
tierHours: "Hours",
tierDates: "Dates",
tierOvernight: "(crosses midnight)",
addTier: "+ Add tier",
dow1: "Mon",
dow2: "Tue",
dow3: "Wed",
dow4: "Thu",
dow5: "Fri",
dow6: "Sat",
dow0: "Sun",
},
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
unbound: "unbound",
car_one: "{{count}} car",
car_other: "{{count}} cars",
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
noneYet: "No subscriptions yet.",
add: "+ Add subscription",
new: "New subscription",
editTitle: "Edit subscription",
holderName: "Holder name",
contact: "Contact",
carLimit: "Car limit",
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
credentials: "Credentials",
credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag",
rfCardTagSoon: "RF card/tag (soon)",
rfPlaceholder: "card number (or read the card)",
readCard: "Read card",
captureChooseReader: "Choose a reader, then present the card:",
captureNoReaders: "No readers configured.",
captureWaiting: "Present the card to the reader…",
captureTimeout: "Timed out with no card read. Try again.",
captured: "Card read: {{value}}",
qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value",
addCredential: "+ credential",
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save",
cancel: "Cancel",
saved: "Subscription saved.",
savedPrinted: "Subscription saved — QR code printed.",
savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
printCode: "Print code",
printedOn: "Code printed on {{printer}}.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active",
statusSuspended: "suspended",
statusRevoked: "revoked",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",
free: "free",
full: "FULL",
capacityLabel: "Capacity (blank = no limit):",
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
fieldParkName: "Park name",
fieldParkNamePh: "e.g. Acme Parking",
fieldOperator: "Operator (legal name)",
fieldOperatorPh: "operating company",
fieldNius: "NIUS",
fieldNiusPh: "e.g. L01234567A",
fieldAddress: "Address",
fieldPhone: "Phone",
fieldEmail: "Email",
},
users: {
title: "Users",
add: "+ Add user",
new: "New user",
none: "No users.",
username: "Username",
password: "Password",
passwordHint: "At least 8 characters.",
newPassword: "new password",
role: "Role",
resetPassword: "Reset password",
delete: "Delete",
confirmDelete: "Delete user \"{{name}}\"?",
},
roles: {
title: "Roles",
add: "+ Add role",
new: "New role",
editTitle: "Edit role",
name: "Name",
permissions: "Permissions",
builtin: "built-in",
edit: "Edit",
delete: "Delete",
confirmDelete: "Delete role \"{{name}}\"?",
permCount_one: "{{count}} permission",
permCount_other: "{{count}} permissions",
userCount_one: "{{count}} user",
userCount_other: "{{count}} users",
},
shift: {
label: "Shift:",
open: "open",
notStarted: "not started",
since: "since",
startShift: "Start shift",
starting: "Starting…",
endShift: "End shift",
ending: "Ending…",
drawer: "Drawer:",
openingFloatInherited: "(opening float inherited from the prior shift)",
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
amount: "amount",
reasonPlaceholder: "reason (e.g. opening float)",
load: "Load +",
remove: "Remove −",
enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.",
zReport: "Z-REPORT",
payments: "Payments:",
cash: "Cash:",
card: "Card:",
drawerSection: "— Drawer —",
openingFloat: "Opening float:",
cashTaken: "Cash taken:",
cashAdded: "Cash added:",
cashRemoved: "Cash removed:",
expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).",
// Header shift control + the booth shift gate.
headerNoShift: "No shift",
headerOpen: "Open shift",
headerClose: "Close shift",
headerHeldBy: "Shift open — {{operator}}",
headerHeldByShort: "Shift: {{operator}}",
gateTitle: "Open a shift to process tickets",
gateBody:
"No shift is open. Open your shift so payments and exits are recorded against it.",
gateOtherTitle: "The open shift belongs to another operator",
gateOtherBody:
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
openNow: "Open shift now",
opening: "Opening…",
},
pay: {
ticket: "Ticket",
entry: "Entry",
now: "Now",
duration: "Duration",
statusLabel: "Status",
paid: "PAID",
unpaid: "UNPAID",
total: "Total",
noTariff: "no tariff",
tender: "Tender",
cash: "Cash",
card: "Card",
printExitVoucher: "Print exit ticket",
selfExitHint: "(customer self-exits at the exit)",
payAndOpen: "Pay + open barrier",
payAndVoucher: "Pay + print voucher",
openBarrier: "Open barrier",
printVoucher: "Print voucher",
takingPayment: "taking payment…",
printingVoucher: "printing voucher…",
opening: "opening…",
noSessionFound: "No session found for this ticket.",
alreadyClosed: "This session is already closed (exited {{time}}).",
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
subscription: "SUBSCRIPTION",
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
receiptPrintFailed: "(receipt didn't print — use \"Reprint receipt\".)",
receiptReprinted: "Receipt reprinted on {{printer}}.",
reprintReceipt: "Reprint receipt",
reprinting: "printing…",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
},
};