Skip to content
~sami-ullah

Concept project

Vue 3 Admin Panel with Role-based Access and Pinia

A concept project with a working demo: a Vue 3 and Pinia admin panel where routes, menus and buttons follow fine-grained permissions, and a mock API enforces the same rules on its own. Switch between Admin, Manager and Viewer, edit the role matrix, expire the token and read the audit trail. It shows how I structure access control in a Vue front end.

Screenshot of the Vue 3 Admin Panel with Role-based Access and Pinia demo

Overview#

Most admin panels start with if (user.isAdmin) sprinkled through the templates. It works for a month. Then a "Manager" role arrives that can refund orders but not delete users, and every one of those checks has to be found and rewritten.

This concept shows the structure I use instead: permissions are defined once as typed strings, every route, menu item and button asks for a permission (never a role), and the API checks the same permissions again on its own. The live demo is real Vue 3 and Pinia code mounted inside this Next.js site, with a mock API that behaves like a NestJS service.

My role#

Everything: the permission model, the guard, the Pinia stores, the v-can directive, the mock API pipeline, the interface and this write-up. It is a concept project, not client work. The users and orders are fictional seed data.

The problem#

An admin panel has to answer one question in many places: "may this person do this?"

  • Routes. A Viewer who types /users/new into the address bar must not see the create form.
  • Navigation. Menu items for pages you cannot open are noise, and they leak what the system can do.
  • Actions. Inside a page you can read, some buttons are still off-limits: a Manager can edit users but not deactivate them.
  • The server. Hiding a button is a UX feature, not security. Anyone can open dev tools and send the request. The API must refuse by itself, and someone should be able to see later that it did.

When those four checks are written separately, they drift. The goal is one source of truth that all four read.

Approach & architecture#

◆ Navigation
  1. Address bar or linkthen
  2. router storethen
  3. guardNavigation()then
  4. page | /login (remember path) | 403 page
  1. Role switch or matrix editthen
  2. guard result recomputedthen
  3. menu and current page update
◆ Every API request
  1. Pinia store actionthen
  2. api()then
  3. AuthGuardthen
  4. PermissionsGuardthen
  5. ValidationPipe (zod)then
  6. handlerthen
  7. audit log
  1. api()then
  2. 401then
  3. sign out, keep return paththen
  4. loginthen
  5. back to the same page

Permissions as typed strings. The list is a const tuple, so a typo like user.write fails to compile.

ts
export const PERMISSIONS = [
  "users.read", "users.write", "users.deactivate",
  "orders.read", "orders.refund",
  "roles.read", "roles.manage", "audit.read",
] as const;
export type Permission = (typeof PERMISSIONS)[number];
export type RoleMatrix = Record<RoleId, Permission[]>;

One guard function. Routes declare permission in their definition, like meta.permission in Vue Router. The guard is a pure function, so it is easy to test and the same function decides both navigation and the "route access" list on the dashboard.

ts
export function guardNavigation(path: string, ctx: { authenticated: boolean; has: (p: Permission) => boolean }): GuardResult {
  const match = matchRoute(path);
  if (!match) return { kind: "notfound", path };
  if (match.route.public) return { kind: "ok", match };
  if (!ctx.authenticated) return { kind: "login", redirect: match.path };
  const needed = match.route.permission;
  if (needed && !ctx.has(needed)) return { kind: "forbidden", from: match.path, needed };
  return { kind: "ok", match };
}

In the router store the guard result is a computed. That detail matters: when you switch role or remove a permission from your own role, the current page is re-checked straight away, with no reload and no navigation event.

v-can and useCan(). Buttons use a directive; script logic uses the composable. The directive runs a watchEffect, so it reacts to role changes even when the component itself does not re-render.

ts
// <button v-can="'users.deactivate'">  hides
// <button v-can:disable="'orders.refund'">  disables with a reason
export const vCan: Directive<CanEl, Permission> = {
  mounted(el, binding) {
    el.__canStop = watchEffect(() => apply(el, binding));
  },
  updated(el, binding) {
    apply(el, binding, true);
  },
  unmounted(el) {
    el.__canStop?.();
  },
};

The API does not trust the UI. The mock API runs the same order a NestJS controller would: AuthGuard, then a PermissionsGuard reading @RequirePermission() metadata, then a zod validation pipe, then the handler. It checks the role matrix stored on the "server", not the client's copy. Denied writes are written to the audit log. In the production design the guard looks like this:

ts
@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(private reflector: Reflector, private roles: RolesService) {}
  async canActivate(ctx: ExecutionContext) {
    const needed = this.reflector.get<Permission>(PERMISSION_KEY, ctx.getHandler());
    if (!needed) return true;
    const { user } = ctx.switchToHttp().getRequest();
    return this.roles.has(user.role, needed); // cached per role, invalidated on PUT /roles/:role
  }
}
sql
CREATE TABLE role_permissions (
  role        text NOT NULL,
  permission  text NOT NULL,
  PRIMARY KEY (role, permission)
);
CREATE TABLE audit_log (
  id       bigserial PRIMARY KEY,
  at       timestamptz NOT NULL DEFAULT now(),
  actor_id bigint NOT NULL REFERENCES users(id),
  action   text NOT NULL,
  target   text NOT NULL,
  outcome  text NOT NULL CHECK (outcome IN ('ok', 'denied', 'conflict'))
);

Key challenges#

  • Proving the server check works. A demo that only hides buttons proves nothing. The "bypass UI checks" switch renders the forbidden buttons with a dashed outline, and the API console sends any request as the current user. Both get a real 403, an optimistic update that rolls back, and an audit entry.
  • Lock-out protection. If an admin can edit the role matrix, they can remove their own roles.manage. The API refuses that change with a 422, and the matrix shows those boxes as locked.
  • Token expiry without losing work. A 401 from any call signs the user out and stores the page they were on. After sign-in they land back on it, the same flow an Axios interceptor handles in a production app.
  • Double submits on money. Refunds send an Idempotency-Key. Tick "simulate a double click" and the second request is answered from the stored response, so the order is refunded once.
  • Running Vue inside Next.js. The demo mounts a Vue app from a small React component. Components use template strings compiled in the browser, which needs Vue's full build. I import that build once, and every component imports plain vue.

Tech stack#

  • Demo: Vue 3 (Composition API, defineComponent with templates), Pinia setup stores, TypeScript, zod for request validation, Tailwind CSS. A small router store stands in for Vue Router. It is mounted in this Next.js site through a React island, and state is kept in localStorage.
  • Production design: Vue 3 single-file components built with Vite, Vue Router with the same guard in router.beforeEach, Pinia, and a NestJS API with guards, a validation pipe and an audit interceptor. Data in PostgreSQL, idempotency keys and the role cache in Redis.

Results#

  • A working admin panel with users (list, search, create, edit, deactivate), orders (list, detail, partial and full refunds), a permission matrix, an audit log with CSV export, and an API console.
  • Three demo roles with clearly different access. Typing a guarded URL as a Viewer opens the 403 page, which names the missing permission.
  • Menus, buttons and the current page update the moment the role or the matrix changes.
  • Every request is shown in a request log with the guard steps it passed or failed.
  • All the code under "View source" on the demo page is the code that runs.

This is a concept build without real users, so there are no usage or performance figures.

Try the demo#

Open the Vue admin panel demo. Things to try:

  1. Switch to Lena Park (viewer), then type /users/new in the address bar and press go.
  2. As Omar Siddiqui (manager), turn on "Bypass UI checks" and press deactivate on a user. Watch the optimistic change roll back and the 403 in the request log.
  3. As Dana Reyes (admin), untick orders.read for admin in Roles. The Orders menu item disappears straight away.
  4. Press "Expire token", then click any page. Sign in again and you return to where you were.

What I would do next#

  • Rebuild it as a Vite project with .vue files and Vue Router, and add Vitest tests for the guard, useCan() and every API status code.
  • Add record-level rules, for example "managers can refund only orders from their region", which need a policy function per resource, not just a permission string.
  • Stream audit events to a separate append-only store so an admin cannot edit them.
Full screenshot of the Vue 3 Admin Panel with Role-based Access and Pinia demo running in the browser
Screenshot of the live demo with its sample data. Click to open it.
▶ live demoSwitch roles, open a guarded URL, edit the permission matrix and force a forbidden API call to see the 403 land in the audit log.

Skills used

  • Screenshot of Voy — Social Commerce Web App

    Client project

    Voy — Social Commerce Web App

    Voy is a mobile-first social commerce app where creators share shoppable videos, stories and product picks, and shoppers follow, chat and buy without leaving the feed. As a front-end developer in a small team, I built the shop, profile and product pages in Vue 3 and TypeScript and connected them to the REST API.

    Vue 3TypeScriptViteTailwind CSS

#nextjs6 min

JSON-LD Structured Data in Next.js 15

How to add JSON-LD structured data in Next.js 15: a safe script component, typed schema-dts graphs with stable @id values, FAQ rules and how to test it.

read →