OCB & BVBank Merchant Portal + PWA
A cross-platform transaction management system for banking partners, with a shared SDK keeping RBAC in sync between Portal and PWA.
- React
- TypeScript
- Zustand
- Keycloak
- RBAC
A transaction management system for banking partners, shipped in two forms: a desktop Merchant Portal and a PWA for staff at the point of sale.
The problem#
Portal and PWA started as two separate codebases. Every change to authorisation rules had to be made twice, and the two drifted apart. At one point a role was blocked in the Portal but could still perform the same action in the PWA.
The fix: a shared authorisation SDK#
I authored an internal SDK that acts as the single source of truth for permissions, imported by both applications.
Normalising Keycloak roles#
Keycloak returns roles in a nested shape that is not consistent across realms. Step one is flattening them into a single permission set:
/** Permissions are `<resource>:<action>`, e.g. 'transaction:refund'. */
export type Permission = `${string}:${string}`;
export function resolvePermissions(token: DecodedToken): ReadonlySet<Permission> {
const roles = [
...(token.realm_access?.roles ?? []),
...Object.values(token.resource_access ?? {}).flatMap((entry) => entry.roles),
];
/* A Set gives O(1) checks and de-duplicates overlap between realms. */
return new Set(roles.flatMap((role) => ROLE_TO_PERMISSIONS[role] ?? []));
}One place to check#
Every gated piece of UI in both apps goes through the same component:
export function Can({ perform, children, fallback = null }: CanProps) {
const permissions = usePermissions();
/* Deny by default: a missing declaration hides UI rather than exposing it. */
return permissions.has(perform) ? <>{children}</> : <>{fallback}</>;
}Offline-tolerant state with Zustand#
The PWA has to keep working when connectivity at the counter is unreliable, so transactions are held locally and replayed:
export const useTransactions = create<TransactionState>()(
persist(
(set, get) => ({
queue: [],
enqueue: (draft) => set({ queue: [...get().queue, draft] }),
flush: async () => {
/* Sequential on purpose: ordering is a business rule here. */
for (const item of get().queue) {
await submitTransaction(item);
set({ queue: get().queue.filter((entry) => entry.id !== item.id) });
}
},
}),
{ name: 'txn-outbox' },
),
);Outcome#
- Authorisation rules live in one place; Portal and PWA can no longer drift
- Adding a role means editing one mapping table, not auditing the UI
- The PWA keeps recording transactions offline and syncs when connectivity returns