# 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.

- Author: Sami Ullah (https://samiullah.tech/about)
- Type: Concept project
- Role: Full build
- Stack: Vue 3, Pinia, TypeScript, zod, Next.js, Tailwind CSS
- Year: 2026
- Updated: 2026-11-13
- Canonical: https://samiullah.tech/work/vue-admin-rbac

## 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](/lab/vue-admin-rbac) is real [Vue 3](/skills/vue) 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

```flow
# Navigation
Address bar or link -> router store -> guardNavigation() -> page | /login (remember path) | 403 page
Role switch or matrix edit -> guard result recomputed -> menu and current page update
```

```flow
# Every API request
Pinia store action -> api() -> AuthGuard -> PermissionsGuard -> ValidationPipe (zod) -> handler -> audit log
api() -> 401 -> sign out, keep return path -> login -> 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](/lab/vue-admin-rbac). 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.
