d0841c8601
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
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
// 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…",
|
||
},
|
||
};
|