Skip to content

A bought console, given a permission model it could be trusted with

A commercial admin template carried the screens. We built what it was missing: a real client-side ability model and a typed data layer, so what a user can see and what the server will allow finally agree.

Dashboard

Quay Street · Thu 17 Sep 2026 · updated 14:26

Quay StreetNew saleSearch receipts, products

Takings today

1 entry · 3 screens

£4,862.40

Card, cash and contactless

Transactions

187

Across tills 1 to 3

Average basket

£26.00

Takings over transactions

Refunds today

3

£61.85 returned

Takings by hour

Quay Street · 08:00 to 14:26
£214.60
£486.35
£742.10
£903.45
£1,021.80
£958.30
£535.80
08:0009:0010:0011:0012:0013:0014:00

Refunds today

£61.85
Exterior wood stain, oak 2.5LQS-260917-0112 · Owen Pryce · 11:48−£24.99
Brass cabinet hinge 75mm ×4QS-260916-0247 · Owen Pryce · 12:30−£13.96
Extension lead 4-way 2mQS-260915-0098 · Margaret Hale · 13:15−£22.90

Refund is asked of the session's ability, on this card and at every till.

Recent sales

Newest first
ReceiptTimeTillItemsPaymentTotal
QS-260917-019114:24Till 23Card£18.47
QS-260917-019014:21Till 31Card£42.99
QS-260917-018914:17Till 16Cash£31.20
QS-260917-018814:12Till 32Card£9.58
QS-260917-018714:09Till 24Contactless£57.35

Low stock

Quay Street
Masking tape 50mDC-103346 / 24
Wall plugs, brown, 100FX-304209 / 30
Sanding sheets P120, 10DC-132084 / 15
LED bulb GU10 5WEL-4011511 / 40
Draught excluder 5mHW-218762 / 10

The shape of the work

Industry
E-commerce & Retail
Duration
16 weeks
Cooperation model
Fixed price, phased
Services
Access controlData layerConsole integration
Integrations
Existing REST APISSO providerSentryFeature flags
Technologies
Next.jsTypeScriptCASLTanStack QueryRedux ToolkitAnt Design
Team
1 Project lead2 Frontend engineers1 QA engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

Before and after

What was there, and what replaced it

What was there

A template ships every screen and no opinions. Permissions were per-component guesses, so a cashier saw buttons that failed on the server, and each screen fetched in its own way, so the same figure disagreed with itself between two tabs.

Permissions were per-component guesses derived from a role string, so different components read the same role differently. A cashier saw buttons that failed on the server, which looks like a broken product more than a denied action. And each screen fetched independently, so the same figure could appear twice on one dashboard with two different values.

What replaced it

One ability object resolved from the session, asked by every control through a single `can()`; and one typed query layer, so a screen declares what it needs and shares the cache with every other screen that needs it.

Introduction

What we inherited

The team had bought a console template that looked finished and behaved like a demo. We kept the screens and built the two layers underneath that decide what a real user is allowed to do and where their data comes from.

A team that had bought a console template which looked finished and behaved like a demo. Every screen existed and none of them had opinions about who could do what. The engagement was scoped deliberately narrow: keep the screens, build the two layers underneath that decide what a user may do and where a figure comes from.

Frontend Architecture & Access Control

What we kept, and what we cut

  • Resolved a single ability from the session, replacing role checks scattered through components

    Resolving one ability meant first writing down what each role was actually permitted to do, which had never existed as a document anywhere.

  • Made every control ask the same `can()`, so the UI can't disagree with the server

    Every control, route and menu item asks the same `can()`, and the API enforces the same rule shape, so the two cannot disagree.

  • Replaced per-screen fetching with a typed query layer sharing one cache

    Typed query hooks share one cache, so two screens showing the same figure read one entry, with invalidation declared alongside the mutation that causes it.

  • Kept the bought screens, because rewriting working UI wasn't where the value was

    Not redesigning was agreed explicitly at the start, and it's why the rollout needed no retraining: staff saw the screens they already knew.

  • Made the ability the thing tests assert against, so a permission change is checkable

    The ability is a plain object tests assert against, so a permission change is a readable diff, caught in review before it ever reaches production.

One ability, asked everywhere

Every control calls the same `can()`, so the interface and the API can't disagree about what's allowed.

Every control in the console (buttons, menu items, whole routes) asks the same CASL ability through one `can()` call, so there's exactly one definition of what's allowed. The same rule shape is enforced on the API, which keeps the interface and the server in agreement. Before, a hidden button and an open endpoint were two independent decisions maintained by two people.

What shipped
  • One `can()` asked by every control, route and menu item
  • The same rule shape enforced on the API
  • Interface and server can't disagree about what's allowed
Sale QS-260917-0183

Completed 13:52 on Till 3 · Quay Street · signed in as Kiran Dhillon, Till operator

PaidSearch receipts, products

Receipt

QS-260917-0183

Rung up

17 Sep 2026 · 13:52

Till

Till 3 · Kiran Dhillon

Payment

Card · Visa ending 2208

Customer

Walk-in

Items

5 lines · VAT included
ItemQtyPriceLine
Brass cabinet hinge 75mmHW-204174£3.49£13.96
Exterior wood stain, oak 2.5LDC-118601£24.99£24.99
Stainless screws 4×40mm, box of 200FX-301122£6.75£13.50
Masking tape 50mDC-103343£2.20£6.60
Paint roller set 9inDC-120711£11.49£11.49

Subtotal£70.54

VAT at 20%, included£11.76

Total paid£70.54

Customer returning line 2, unopened, with the receipt.

Actions on this sale

Each asks can()
Reprint receipt
Refund line 2
Refund without receipt
Void sale
Open drawer, no sale
Payout £80.00
Issue gift card

Refund line 2 · refused

Till operator · 11 abilities
can('create', 'Refund')false

Shown to you, and refused

refund:create is not granted to Till operator. A manager or the owner can refund it from their own session.

The same rule, on the API

POST /api/refundscan('create', 'Refund') → 403 for this role
Requests sent0 · refused before the call
Rule shapeone definition, read by both

Nothing on this screen was redrawn. The template's buttons now ask the session's ability instead of a role string.

On screen

The sale screen at till-operator level, where refund stays on screen and is refused: every action on a recalled sale asks the same can(), refund and void stay visible, locked with the reason, and the API holds the same rule, so no request is sent only to fail.

This session's abilities

Resolved once from the session at sign-in and held · Owen Pryce, Manager, Quay Street

Re-resolve on role changeSearch receipts, products
Session readOwen Pryce · signed in 08:02
Role rulesManager · 15 rules
Branch assignmentbranchId = quay-st
Till sessionTill 2 open since 08:05
Ability held26 of 35 · asked everywhere

Session

held in memory

User

Owen Pryce

Role

Manager

Branch

Quay Street

Till session

Till 2 · open

Resolved

08:02:14 · once

Where each came from

26of 35 catalogued
Role rules15
Branch assignment8
Open till session2
Owner grant1

26 abilities, and the source of each

action:subject · condition · source

Ability

Source

sale:createRing up a sale · own till session opentill
sale:readView and reprint sales · branchId = quay-stbranch
sale:discount.lineDiscount a line · percent ≤ 20role
sale:discount.basketDiscount a basketrole
sale:void.lineVoid a line before paymentrole
sale:voidVoid a completed salerole
refund:createRefund a salerole
refund:create.noReceiptRefund without a receipt · granted by Margaret Halegrant
till:openOpen a till session · branchId = quay-stbranch
till:closeClose and count a till · own till session opentill
till:noSaleOpen the drawer, no salerole
cash:payoutPetty cash payout · amount ≤ £50role
product:readView productsrole

Ability

Source

product:createAdd a productrole
product:update.priceChange a pricerole
stock:adjustAdjust stock · branchId = quay-stbranch
stock:transferTransfer between branches · from branchId = quay-stbranch
stock:countRecord a stock countrole
purchaseOrder:createRaise a purchase order · branchId = quay-stbranch
customer:readView customersrole
customer:updateEdit a customerrole
giftCard:issueIssue a gift cardrole
report:sales.branchBranch sales report · branchId = quay-stbranch
staff:readView staff · branchId = quay-stbranch
shift:editEdit the rota · branchId = quay-stbranch
audit:readRead the audit logrole

Resolved from the session

On screen

One session, twenty-six abilities, and where each one came from: resolved once at sign-in from a manager's role rules, branch assignment, open till session and a single owner grant, with each ability's condition beside it.

The client holds one resolved ability, so no component re-derives permissions from a role string.

The ability is built once from the session at load and held, so no component re-derives permissions from a role string. That removed a whole class of inconsistency where two components read the same role slightly differently, and it made the permission question cheap enough to ask everywhere it should be asked, not just where someone remembered.

What shipped
  • Ability resolved once from the session, then held
  • No component re-deriving permissions from a role string
  • Cheap enough to ask everywhere it should be asked

A shared cache

Typed query hooks mean two screens showing one figure read one cache entry instead of racing.

TanStack Query hooks are typed per resource, so two screens showing the same figure read one cache entry instead of issuing two requests and racing to render different numbers. Invalidation is declared with the mutation that causes it, which keeps the stale-view bug from returning every time someone adds a new write path.

What shipped
  • Typed hooks per resource; one cache entry per figure
  • Two screens showing one number can't disagree
  • Invalidation declared alongside the mutation that causes it
Data & cache

Typed query hooks per resource · one cache shared by every screen

0 duplicate requestsSearch receipts, products
Typed per resourceA screen declares what it needs
One entry per figureKeyed by resource and scope
Invalidation declaredBeside the mutation that causes it
Shared by every screenObservers read, never refetch

Query cache

Quay Street · live
Key · hookScreensStateUpdated
['sales', 'summary', {…}]useSalesSummary3fresh14:25:48
['sales', 'list', {…}]useSales2fresh14:25:48
['sales', 'hourly', {…}]useSalesHourly1fresh14:25:48
['refunds', 'list', {…}]useRefunds2fresh14:25:31
['tills', 'session', 'till-2']useTillSession2fresh14:24:02
['reports', 'branch', {…}]useBranchReport1fresh14:22:10
['products', 'lowStock', {…}]useLowStock1fresh14:20:55
['products', 'list', { page: 1 }]useProducts1stale14:02:17
['stock', 'levels', {…}]useStockLevels1stale13:58:40
['purchaseOrders', 'open']usePurchaseOrders1stale13:41:06
['customers', 'list', {…}]useCustomers0inactive12:16:29
['staff', 'rota', { week: 38 }]useRota0inactive09:12:44

Branch takings today

one entry

Query key

['sales', 'summary', { branch: 'quay-st', day: '2026-09-17' }]

useSalesSummary({ branchId, day }): SalesSummary

Screens observing it

DashboardTakings today card£4,862.40same entry
Reports · Branch salesHeader total£4,862.40same entry
Till close · Till 2Branch takings so far£4,862.40same entry

Network for this figure

GET/api/sales/summary?branch=quay-st&day=2026-09-171 request14:25:48

Before · each screen fetched

3 requests for one figure

Dashboard £4,862.40

Reports £4,791.90

Now · one cache entry

1 request · 0 duplicates

Every screen £4,862.40

Read, never raced

Invalidated by, declared with each mutation

useCompleteSaleuseCreateRefunduseVoidSale
On screen

One cache behind several screens: the branch takings entry read by the dashboard, the branch sales report and the till close from one request, with its typed hook and the mutations that invalidate it declared beside them.

Preview as role

The template's Products screen, unchanged, rendered from two sessions side by side

Search receipts, products
screens/products/ProductList.tsxOne component, one file, as the template shipped it. Each control now asks can() instead of reading a role string.Screen not rewritten
OPManagerOwen Pryce · 26 abilities from the sessionProducts
New productRaise POStock count
ProductStockPriceActions
Brass cabinet hinge 75mmHW-20417148£3.49
Exterior wood stain, oak 2.5LDC-1186022£24.99
Stainless screws 4×40mm, 200FX-3011264£6.75
Masking tape 50mDC-103346£2.20
Paint roller set 9inDC-1207131£11.49
Extension lead 4-way 2mEL-4030218£22.90
Wall plugs, brown, 100FX-304209£3.15
Claw hammer 16ozHT-5002127£14.50
LED bulb GU10 5WEL-4011511£4.25
Draught excluder 5mHW-218762£8.99

1 control refused · 0 hidden

product:delete

KDTill operatorKiran Dhillon · 11 abilities from the sessionProducts
New productRaise POStock count
ProductStockPriceActions
Brass cabinet hinge 75mmHW-20417148£3.49
Exterior wood stain, oak 2.5LDC-1186022£24.99
Stainless screws 4×40mm, 200FX-3011264£6.75
Masking tape 50mDC-103346£2.20
Paint roller set 9inDC-1207131£11.49
Extension lead 4-way 2mEL-4030218£22.90
Wall plugs, brown, 100FX-304209£3.15
Claw hammer 16ozHT-5002127£14.50
LED bulb GU10 5WEL-4011511£4.25
Draught excluder 5mHW-218762£8.99

5 controls refused · 0 hidden

product:create · purchaseOrder:create · product:update.price · stock:adjust · product:delete

On screen

The same screen at two roles: the template's Products screen, unchanged, rendered for a manager and a till operator, with every control a session lacks shown locked and named rather than removed.

The screens stayed

Rewriting working UI would have spent the budget on the least broken part of the system.

The screens were left alone. They worked, staff knew them, and rewriting them would have spent the budget on the least broken part of the system while the actual problem (permissions and data fetching) went untouched. The work went entirely into the two layers underneath, which is why the rollout needed no retraining.

What shipped
  • Working screens deliberately left in place
  • Budget spent on permissions and fetching, none on visuals
  • No staff retraining required at rollout

Permissions are testable

The ability is an object tests assert against, so a permission change is a checkable diff.

Because the ability is a plain object built from a session, tests assert against it directly: given this role, these actions are permitted and these aren't. A permission change becomes a diff someone can read in review before it ships, a property the old scattered checks could never have.

What shipped
  • The ability is a plain object tests assert against
  • Permission changes show up as a readable diff in review
  • Behavior verified before deployment, not discovered after
Roles & abilities

35 catalogued abilities · the same object the interface, the API and the tests read

1 change in reviewSearch receipts, products

Role matrix

Showing 3 of 6 groups
AbilityOwner35Manager26Till operator11
Sales · 8
sale:create
sale:read
sale:discount.line≤ 20%≤ 5%
sale:discount.basket
sale:void.line
sale:void
refund:create
refund:create.noReceiptby grant
Till & cash · 4
till:open
till:close
till:noSale
cash:payout≤ £50
Reports · 4
report:sales.branch
report:sales.all
report:margin
report:export

A cross is not a hidden control: it is a refusal with this row as its reason.

CR-0412 · awaiting review

Margaret Hale · 16 Sep

Managers approve their own branch's small purchase orders

A permission change is a diff to one rules file, and the assertions below run against the ability before it ships.

access/rules/manager.ts+2 −0
12 export const manager = defineRules((can, s) => {
13 can('create', 'Refund')
14 can('void', 'Sale')
15 can('create', 'Payout', { amount: { $lte: 50 } })
16 can('adjust', 'Stock', { branchId: s.branchId })
17+ can('approve', 'PurchaseOrder', {
18+ branchId: s.branchId, total: { $lt: 500 } })
19 })
Manager2627 abilitiesOwner and till operator unchanged

ability.spec.ts · run on this change

7 passed
manager can approve a branch PO under £500new
manager cannot approve a PO of £500 or morenew
till operator cannot create a refund
till operator cannot discount a line above 5%
manager refunds without receipt only by grant
owner resolves all 35 catalogued abilities
api and interface read the same rule shape
Approve changeRequest changes
On screen

The role matrix as the tests read it, and a change letting managers approve their branch's small purchase orders shown as a two-line diff to one rules file, with the ability assertions run against it.

Process

Phase by phase

  1. Phase 1: The Audit

    What The Template Actually Decided

    Cataloged every place the console made an access decision. Most were component-local guesses, and several disagreed with what the API would allow.

    • Permission Audit
    • Divergence List
    • Ability Catalogue
  2. Phase 2: One Ability

    Resolved Once, Asked Everywhere

    Built ability resolution from the session so the client holds one answer, and every control asks it instead of re-deriving from a role string.

    • Ability Resolution
    • Session Binding
    • can() Contract
  3. Phase 3: The Data Layer

    One Cache, Many Screens

    Replaced ad-hoc fetching with a typed query layer, so two screens showing the same figure read the same cache entry instead of racing each other.

    • Query Layer
    • Typed Hooks
    • Cache Strategy
  4. Phase 4: Keeping The Screens

    Not Rewriting What Worked

    Left the template's screens in place deliberately. The value was in the layers underneath, and rebuilding working UI would have spent the budget on the least broken part.

    • Integration Plan
    • Component Boundaries
    • Upgrade Path
Decision audit

Phase 1 · every place the template made an access decision, against what the API would allow

Ability catalogueSearch receipts, products

The template

A decision in each component, from a role string

Now

1 place · access/ability.ts

UI actions failing server-side

0

Divergence list

Catalogued from the template's components
WhereCheck as writtenAPI allowedTemplate verdictNow asks
layouts/Sider.tsx · Reportsuser.role !== 'cashier'manager, ownerAgreedcan('read', 'BranchReport')
sales/SaleDetail.tsx · Refundrole.includes('staff')manager, ownerShown, server refusedcan('create', 'Refund')
sales/SaleDetail.tsx · VoidisAdmin || isManagermanager, ownerAgreedcan('void', 'Sale')
products/ProductList · Deleterole === 'admin'ownerHidden, server allowedcan('delete', 'Product')
products/PriceCell.tsx!user.isCashiermanager, ownerAgreedcan('update', 'Product')
stock/AdjustModal.tsx(no check)manager, ownerShown, server refusedcan('adjust', 'Stock')
till/NoSaleButton.tsxMANAGERS.includes(role)manager, ownerAgreedcan('open', 'CashDrawer')
cash/PayoutForm.tsxamount < 100manager ≤ £50, ownerShown, server refusedcan('create', 'Payout')
customers/ExportButton.tsxrole === 'owner'ownerAgreedcan('export', 'Customer')
reports/MarginTab.tsxrole === 'owner'ownerAgreedcan('read', 'MarginReport')
gift-cards/IssueButton.tsxrole != 'cashier'every roleHidden, server allowedcan('issue', 'GiftCard')
routes/staff/page.tsxroles: ['admin', 'owner']ownerAgreedcan('manage', 'Staff')
purchase-orders/Approve.tsxrole === 'owner'ownerAgreedcan('approve', 'PurchaseOrder')
On screen

What the template actually decided: the audit of access checks written into individual components against what the API allowed, the ones that disagreed marked, and the single can() each control asks now.

After the rebuild

1

Places an access decision is made

0

UI actions that fail server-side

None

Screens rewritten

−100%

Duplicate fetches for one figure

Places an access decision is made is a count: one. UI actions that fail server-side is zero, because the interface and the API resolve the same rule shape. Screens rewritten is none. Duplicate fetches for one figure is a structural claim: two screens showing the same value read one cache entry.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

What the rebuild taught us

  1. 01

    A template gives you every screen and no opinions. The opinions are the work.

    A template is every screen and no decisions, and the decisions are the entire product. That's why it looks finished and behaves like a demo.

  2. 02

    Permission checks scattered across components will eventually disagree with the server, and they'll do it quietly.

    Scattered checks drift because each is written from the same role string by a different person on a different day, and the drift is silent until an action fails.

  3. 03

    Keeping bought UI is often the right call; the value is usually in the layers under it.

    Keeping bought UI is usually right: it's the part that already works, and the interesting failures are always a layer or two beneath it.

  4. 04

    An ability you can assert against in a test is a permission model you can change safely.

    A permission model you can assert against in a test is one you can change. One that only exists as scattered conditionals is one nobody dares touch.

How we worked alongside the team

The first two weeks were an audit, not a build: cataloging every place the console made an access decision and where those diverged from the API. Keeping the template's screens was a deliberate call, because rewriting working UI would have spent the budget on the least broken part of the system.

Sixteen weeks, phased, with an explicit agreement not to redesign anything. That restraint is why it worked: the budget went entirely into permissions and data fetching, where the failures were, and the rollout needed no retraining because staff saw the screens they already knew.

One sale, three sessions

The screen stays the same. The ability behind it decides.

A recalled sale on the console, opened by the owner, a manager and a till operator. Each session resolves its ability once, then every action on the sale asks it. Pick an action to see the answer, where it came from, and what the API would do with the same rule. Switch tabs, or use the arrow keys once one is focused.

Kiran Dhillon, Till operator. This session resolves 11 of the 35 catalogued abilities. The sale screen below is the one the template shipped; only the answers it gets back differ.

Sale QS-260917-0183

Paid 13:52 · card

  • 4 × Brass cabinet hinge 75mm£13.96
  • 1 × Exterior wood stain, oak 2.5L£24.99
  • 2 × Stainless screws 4×40mm, box of 200£13.50
  • 3 × Masking tape 50m£6.60
  • 1 × Paint roller set 9in£11.49
  • Total, VAT included£70.54

Actions on this sale · select one

2 allowed, 5 refused, 0 hidden. Same screen, same buttons.

Resolved once, from the session

  1. Session readKiran Dhillon
  2. Role rules6 from the role
  3. Branch assignment2 scoped to quay-st
  4. Till session3 need an open till
  5. Ability held11 of 35

What this screen asks, and where each answer came from

  • sale:readOpen till session
  • refund:create not granted
  • refund:create.noReceipt not granted
  • sale:void not granted
  • till:noSale not granted
  • cash:payout not granted
  • giftCard:issueRole rules
can('create', 'Refund')false

Refund line 2: refused, not hidden. refund:create is not granted to Till operator.

POST /api/refunds · never sent. The API holds the same rule and would refuse it, so no button fails server-side.

Why the screen and the server can't disagree: every button asks one can() against the ability held for this session, and the API enforces the same rule shape. Resolution step timing here is illustrative.

Architecture

Two layers under screens that didn't change

The bought console carried every screen and no decisions. The work went into what sits beneath it: one place that decides what a session may do, and one place a figure comes from.

  1. 01 · Source
    The signed-in sessionThe only input to a permission question. No component reads a role string and interprets it for itself.
  2. 02 · Resolution
    One CASL ability, built at loadResolved once from the session and held as a plain object, which is also what the tests assert against.
  3. 03 · Decision
    One can(), in the UI and on the APIEvery control, route and menu item asks the same can(), and the API enforces the same rule shape, so the two can't disagree.
  4. 04 · State
    Typed query layer, one cacheA typed TanStack Query hook per resource: two screens showing one figure read one entry, and invalidation is declared with the mutation.
  5. 05 · Delivery
    The template's screens, retainedNo screen rewritten. The Ant Design screens staff already knew ask the ability and the hooks, so the rollout needed no retraining.

What a member of staff can reach

Refunds, voids & the promise a button makes

Refused, never a button that fails

A till operator used to see buttons that failed on the server, which reads as a broken product. Every control now asks the session's ability first, so an action the role lacks is refused on screen, with its reason.

The server holds the same rule

Hiding a button was never the protection. The API enforces the same rule shape the interface asks, so what staff can see and what the server will allow are one decision, where before there were two, maintained by different people.

Who can refund is a reviewable diff

The ability is a plain object the tests assert against: given this role, these actions are permitted and these aren't. Changing who may refund or void shows up as a readable diff in review, never as a surprise in production.

Staff seeing buttons your server then refuses? Scope your build in three minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.