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.
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.
What was there, and what replaced it
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.
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.
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.
- 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
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.
Resolved from the session
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.
- 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.
- 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
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.
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.
- 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.
- 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
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.
Phase by phase
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
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
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
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
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
- 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.
- 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.
- 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.
- 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
- Session readKiran Dhillon
- Role rules6 from the role
- Branch assignment2 scoped to quay-st
- Till session3 need an open till
- 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
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.
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.
- 01 · SourceThe signed-in sessionThe only input to a permission question. No component reads a role string and interprets it for itself.
- 02 · ResolutionOne CASL ability, built at loadResolved once from the session and held as a plain object, which is also what the tests assert against.
- 03 · DecisionOne 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.
- 04 · StateTyped 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.
- 05 · DeliveryThe 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 buildNearby engagements
Data & AnalyticsA checkout that stopped losing sales to a 6-second load
Profiling found the real bottleneck behind a slow checkout (an N+1 query and an oversized bundle) and tuned both, with before-and-after metrics locked in as a baseline against future regressions.
E-commerce & Retail · 5 weeks
Web PlatformsA reader people finish, and a library that remembers where they stopped
A reading platform for a comics catalog: a browse surface people can actually navigate, a reader that gets out of the way, and a history that puts everyone back on the page they left.
E-commerce & Retail · 18 weeks
Web PlatformsOne number, a whole handset, and a database that keeps up with 125 brands
A metered IMEI lookup service that turns fifteen digits into a device, its specifications and its status. It's sold three ways to three audiences and backed by a device database that maintains itself.
Consumer Electronics · 22 weeks
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.














