Concept project · runs in your browserVue 3 · Pinia · TypeScript · zod
Vue 3 Admin Panel with Role-based Access and Pinia Demo
A real Vue 3 and Pinia admin panel mounted inside this page. Switch between Admin, Manager and Viewer to watch the menu, buttons and current page change. Type a guarded URL into the address bar, edit the permission matrix, expire the token, or turn on “bypass UI checks” and see the mock API answer 403 and write the attempt to the audit log.
Interactive demo
$ mounting Vue app…
## How it works
Permissions are strings like users.write, and roles are just lists of them. Nothing in the UI asks “is this an admin?”. Routes declare permission in their meta, the sidebar is built from the routes the role can reach, and buttons use a v-can directive or the useCan() composable.
Navigation goes through one guard function: not signed in sends you to the login page and remembers where you were; a missing permission renders the 403 page. The guard result is a computed value, so switching roles or editing the matrix re-checks the current page without a reload.
The UI is never the only check. Every request runs through the same pipeline a NestJS controller would:AuthGuard (token valid?), PermissionsGuard (role has the permission?), a zod ValidationPipe, then the handler. Writes are audited, including denied ones, and refunds accept an Idempotency-Key so a double submit refunds once.
## View source
The code running in the window above, exactly as deployed.
app/lab/vue-admin-rbac/rbac.ts501 lines
/**
* The "server" of the admin panel demo: permission model, route table + guard, and a mock REST API
* that enforces permissions on its own (the UI is never the only check).
*
* No Vue in here. The request pipeline mirrors a NestJS controller:
* AuthGuard (session valid?) -> PermissionsGuard (@RequirePermission) -> ValidationPipe (zod) -> handler -> audit
*/
import { z } from "zod";
/* ------------------------------------------------------------------ */
/* Permission model */
/* ------------------------------------------------------------------ */
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 const ROLE_IDS = ["admin", "manager", "viewer"] as const;
export type RoleId = (typeof ROLE_IDS)[number];
export type RoleMatrix = Record<RoleId, Permission[]>;
export const DEFAULT_ROLES: RoleMatrix = {
admin: [...PERMISSIONS],
manager: ["users.read", "users.write", "orders.read", "orders.refund", "roles.read"],
viewer: ["users.read", "orders.read"],
};
/** Permissions the admin role can never lose, so nobody can lock themselves out of role management. */
export const LOCKED: Partial<Record<RoleId, Permission[]>> = { admin: ["roles.read", "roles.manage"] };
export const can = (roles: RoleMatrix, role: RoleId | undefined, p: Permission) =>
!!role && roles[role].includes(p);
/* ------------------------------------------------------------------ */
/* Data */
/* ------------------------------------------------------------------ */
export interface User {
id: number;
name: string;
email: string;
role: RoleId;
active: boolean;
createdAt: number;
}
export interface Order {
id: number;
customer: string;
total: number; // cents
refunded: number; // cents
status: "paid" | "partially_refunded" | "refunded";
createdAt: number;
}
export interface AuditEntry {
id: number;
at: number;
actor: string;
role: RoleId;
action: string;
target: string;
outcome: "ok" | "denied" | "invalid" | "conflict";
detail?: string;
}
export interface Db {
users: User[];
orders: Order[];
roles: RoleMatrix;
audit: AuditEntry[];
seq: number;
/** Idempotency-Key -> stored response, like a Redis key with a TTL in production. */
idempotency: Record<string, ApiResponse>;
}
export interface Session {
userId: number;
token: string;
expiresAt: number;
}
/** Demo login accounts (ids 1–3). */
export const DEMO_ACCOUNTS = [
{ id: 1, label: "Dana Reyes", role: "admin" as RoleId },
{ id: 2, label: "Omar Siddiqui", role: "manager" as RoleId },
{ id: 3, label: "Lena Park", role: "viewer" as RoleId },
];
const FIRST = ["Nadia", "Bilal", "Grace", "Tomás", "Priya", "Jonah", "Mei", "Farah", "Kofi", "Ines", "Hassan"];
const LAST = ["Qureshi", "Okafor", "Lindqvist", "Moreno", "Raman", "Becker", "Chen", "Haddad", "Mensah", "Duarte", "Ali"];
const CUSTOMERS = ["Acme Dental", "Northwind Cafe", "Bluebird Florists", "Summit Physio", "Harbor Books", "Pine & Co. Studio", "Greenleaf Grocers", "Atlas Fitness"];
export function seedDb(now: number): Db {
const day = 86_400_000;
const users: User[] = DEMO_ACCOUNTS.map((a, i) => ({
id: a.id,
name: a.label,
email: `${a.label.split(" ")[0]!.toLowerCase()}@example.com`,
role: a.role,
active: true,
createdAt: now - (90 - i) * day,
}));
FIRST.forEach((f, i) => {
const l = LAST[(i * 3) % LAST.length]!;
users.push({
id: 4 + i,
name: `${f} ${l}`,
email: `${f.toLowerCase().replace("á", "a")}.${l.toLowerCase()}@example.com`,
role: i % 4 === 0 ? "manager" : "viewer",
active: i % 5 !== 3,
createdAt: now - (60 - i * 4) * day,
});
});
const orders: Order[] = Array.from({ length: 18 }, (_, i) => {
const total = 2_500 + ((i * 7_919) % 48_000);
const refunded = i % 7 === 2 ? total : i % 6 === 4 ? Math.round(total / 4) : 0;
return {
id: 1042 + i,
customer: CUSTOMERS[i % CUSTOMERS.length]!,
total,
refunded,
status: refunded === 0 ? "paid" : refunded === total ? "refunded" : "partially_refunded",
createdAt: now - (i * 13 + 2) * 3_600_000,
};
});
return { users, orders, roles: structuredClone(DEFAULT_ROLES), audit: [], seq: 100, idempotency: {} };
}
/* ------------------------------------------------------------------ */
/* Front-end routes and the navigation guard */
/* ------------------------------------------------------------------ */
export interface RouteDef {
name: string;
path: string; // "/users/:id"
label: string;
permission?: Permission;
menu?: boolean;
public?: boolean;
}
export const ROUTES: RouteDef[] = [
{ name: "dashboard", path: "/", label: "Dashboard", menu: true },
{ name: "users", path: "/users", label: "Users", permission: "users.read", menu: true },
{ name: "user-new", path: "/users/new", label: "New user", permission: "users.write" },
{ name: "user-edit", path: "/users/:id", label: "User", permission: "users.read" },
{ name: "orders", path: "/orders", label: "Orders", permission: "orders.read", menu: true },
{ name: "order", path: "/orders/:id", label: "Order", permission: "orders.read" },
{ name: "roles", path: "/roles", label: "Roles", permission: "roles.read", menu: true },
{ name: "audit", path: "/audit", label: "Audit log", permission: "audit.read", menu: true },
{ name: "console", path: "/console", label: "API console", menu: true },
{ name: "login", path: "/login", label: "Sign in", public: true },
{ name: "forbidden", path: "/403", label: "Forbidden", public: true },
];
export interface RouteMatch {
route: RouteDef;
params: Record<string, string>;
path: string;
}
export function matchRoute(path: string): RouteMatch | null {
const clean = "/" + path.split("?")[0]!.split("/").filter(Boolean).join("/");
const parts = clean.split("/").filter(Boolean);
// Static segments win over params: /users/new before /users/:id (same order rule as Vue Router's ranking).
const ranked = [...ROUTES].sort((a, b) => a.path.split(":").length - b.path.split(":").length);
for (const route of ranked) {
const rp = route.path.split("/").filter(Boolean);
if (rp.length !== parts.length) continue;
const params: Record<string, string> = {};
const ok = rp.every((seg, i) => {
if (seg.startsWith(":")) {
params[seg.slice(1)] = decodeURIComponent(parts[i]!);
return true;
}
return seg === parts[i];
});
if (ok) return { route, params, path: clean };
}
return null;
}
export type GuardResult =
| { kind: "ok"; match: RouteMatch }
| { kind: "login"; redirect: string }
| { kind: "forbidden"; from: string; needed: Permission }
| { kind: "notfound"; path: string };
/** router.beforeEach in one pure function: decides where a navigation ends up. */
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 };
}
/* ------------------------------------------------------------------ */
/* Mock REST API */
/* ------------------------------------------------------------------ */
export type Method = "GET" | "POST" | "PATCH" | "PUT";
export interface ApiRequest {
method: Method;
path: string;
body?: unknown;
headers?: Record<string, string>;
}
export interface ApiResponse {
status: number;
body: unknown;
/** Which pipeline steps ran, for the request log. */
trace: string[];
}
const createUserDto = z.object({
name: z.string().trim().min(2, "Name must be at least 2 characters").max(60),
email: z.string().trim().toLowerCase().email("Enter a valid email address"),
role: z.enum(ROLE_IDS),
});
const updateUserDto = createUserDto.partial().strict();
const refundDto = z.object({ amount: z.number().int("Amount must be in whole cents").positive("Amount must be above zero") });
const rolePermsDto = z.object({ permissions: z.array(z.enum(PERMISSIONS)) });
export type CreateUserDto = z.infer<typeof createUserDto>;
interface Ctx {
db: Db;
actor: User;
params: Record<string, string>;
body: unknown;
now: number;
}
interface HandlerResult {
status: number;
body: unknown;
audit?: { action: string; target: string; outcome?: AuditEntry["outcome"]; detail?: string };
}
interface Endpoint {
method: Method;
pattern: string;
permission: Permission | null;
dto?: z.ZodTypeAny;
idempotent?: boolean;
run: (ctx: Ctx) => HandlerResult;
}
const notFound = (what: string): HandlerResult => ({ status: 404, body: { statusCode: 404, message: `${what} not found` } });
export const ENDPOINTS: Endpoint[] = [
{ method: "GET", pattern: "/api/me", permission: null, run: ({ db, actor }) => ({ status: 200, body: { user: actor, permissions: db.roles[actor.role] } }) },
{ method: "GET", pattern: "/api/users", permission: "users.read", run: ({ db }) => ({ status: 200, body: db.users }) },
{
method: "GET",
pattern: "/api/users/:id",
permission: "users.read",
run: ({ db, params }) => {
const u = db.users.find((x) => x.id === Number(params.id));
return u ? { status: 200, body: u } : notFound("User");
},
},
{
method: "POST",
pattern: "/api/users",
permission: "users.write",
dto: createUserDto,
run: ({ db, body, now }) => {
const dto = body as CreateUserDto;
if (db.users.some((u) => u.email === dto.email))
return {
status: 409,
body: { statusCode: 409, message: "A user with this email already exists", field: "email" },
audit: { action: "user.create", target: dto.email, outcome: "conflict" },
};
const user: User = { id: ++db.seq, ...dto, active: true, createdAt: now };
db.users.push(user);
return { status: 201, body: user, audit: { action: "user.create", target: `${user.name} (#${user.id})` } };
},
},
{
method: "PATCH",
pattern: "/api/users/:id",
permission: "users.write",
dto: updateUserDto,
run: ({ db, params, body, actor }) => {
const u = db.users.find((x) => x.id === Number(params.id));
if (!u) return notFound("User");
const dto = body as Partial<CreateUserDto>;
if (dto.email && db.users.some((x) => x.email === dto.email && x.id !== u.id))
return { status: 409, body: { statusCode: 409, message: "A user with this email already exists", field: "email" } };
if (dto.role && dto.role !== u.role && u.id === actor.id)
return { status: 422, body: { statusCode: 422, message: "You cannot change your own role", field: "role" } };
if (dto.role === "admin" && actor.role !== "admin")
return {
status: 403,
body: { statusCode: 403, message: "Only admins can grant the admin role" },
audit: { action: "user.update", target: `${u.name} (#${u.id})`, outcome: "denied", detail: "grant admin" },
};
const changed = Object.keys(dto).filter((k) => dto[k as keyof CreateUserDto] !== u[k as keyof User]);
Object.assign(u, dto);
return { status: 200, body: u, audit: { action: "user.update", target: `${u.name} (#${u.id})`, detail: changed.join(", ") || "no changes" } };
},
},
{
method: "POST",
pattern: "/api/users/:id/deactivate",
permission: "users.deactivate",
run: ({ db, params, actor }) => {
const u = db.users.find((x) => x.id === Number(params.id));
if (!u) return notFound("User");
if (u.id === actor.id) return { status: 422, body: { statusCode: 422, message: "You cannot deactivate your own account" } };
u.active = !u.active;
return {
status: 200,
body: u,
audit: { action: u.active ? "user.reactivate" : "user.deactivate", target: `${u.name} (#${u.id})` },
};
},
},
{ method: "GET", pattern: "/api/orders", permission: "orders.read", run: ({ db }) => ({ status: 200, body: db.orders }) },
{
method: "GET",
pattern: "/api/orders/:id",
permission: "orders.read",
run: ({ db, params }) => {
const o = db.orders.find((x) => x.id === Number(params.id));
return o ? { status: 200, body: o } : notFound("Order");
},
},
{
method: "POST",
pattern: "/api/orders/:id/refund",
permission: "orders.refund",
dto: refundDto,
idempotent: true,
run: ({ db, params, body }) => {
const o = db.orders.find((x) => x.id === Number(params.id));
if (!o) return notFound("Order");
const { amount } = body as { amount: number };
const left = o.total - o.refunded;
if (left === 0)
return { status: 409, body: { statusCode: 409, message: "Order is already fully refunded" }, audit: { action: "order.refund", target: `#${o.id}`, outcome: "conflict" } };
if (amount > left)
return { status: 422, body: { statusCode: 422, message: `Refund cannot exceed the remaining ${(left / 100).toFixed(2)}`, field: "amount" } };
o.refunded += amount;
o.status = o.refunded === o.total ? "refunded" : "partially_refunded";
return { status: 200, body: o, audit: { action: "order.refund", target: `#${o.id}`, detail: `$${(amount / 100).toFixed(2)}` } };
},
},
{ method: "GET", pattern: "/api/roles", permission: "roles.read", run: ({ db }) => ({ status: 200, body: db.roles }) },
{
method: "PUT",
pattern: "/api/roles/:role",
permission: "roles.manage",
dto: rolePermsDto,
run: ({ db, params, body }) => {
const role = params.role as RoleId;
if (!ROLE_IDS.includes(role)) return notFound("Role");
const next = new Set((body as { permissions: Permission[] }).permissions);
const missing = (LOCKED[role] ?? []).filter((p) => !next.has(p));
if (missing.length)
return { status: 422, body: { statusCode: 422, message: `${role} must keep ${missing.join(", ")} (lock-out protection)` } };
const before = db.roles[role];
db.roles[role] = PERMISSIONS.filter((p) => next.has(p));
const added = db.roles[role].filter((p) => !before.includes(p));
const removed = before.filter((p) => !next.has(p));
return {
status: 200,
body: db.roles,
audit: {
action: "role.update",
target: role,
detail: [added.length && `+${added.join(" +")}`, removed.length && `-${removed.join(" -")}`].filter(Boolean).join(" ") || "no changes",
},
};
},
},
{
method: "GET",
pattern: "/api/audit",
permission: "audit.read",
run: ({ db }) => ({ status: 200, body: [...db.audit].sort((a, b) => b.at - a.at) }),
},
];
function matchEndpoint(method: Method, path: string) {
const parts = path.split("?")[0]!.split("/").filter(Boolean);
let methodMismatch = false;
for (const ep of ENDPOINTS) {
const pp = ep.pattern.split("/").filter(Boolean);
if (pp.length !== parts.length) continue;
const params: Record<string, string> = {};
if (!pp.every((seg, i) => (seg.startsWith(":") ? ((params[seg.slice(1)] = parts[i]!), true) : seg === parts[i]))) continue;
if (ep.method !== method) {
methodMismatch = true;
continue;
}
return { ep, params };
}
return methodMismatch ? "405" : null;
}
function audit(db: Db, actor: User, now: number, a: NonNullable<HandlerResult["audit"]>) {
db.audit.push({ id: ++db.seq, at: now, actor: actor.name, role: actor.role, outcome: a.outcome ?? "ok", action: a.action, target: a.target, detail: a.detail });
if (db.audit.length > 200) db.audit.shift();
}
/**
* Handle one request. Works on a copy of the database and returns the new copy, the way a
* transaction either commits as a whole or not at all.
*/
export function handle(current: Db, session: Session | null, req: ApiRequest, now: number): { res: ApiResponse; db: Db } {
const trace: string[] = [];
const db = structuredClone(current);
const reply = (status: number, body: unknown) => ({ res: { status, body, trace }, db });
const found = matchEndpoint(req.method, req.path);
if (found === "405") return { res: { status: 405, body: { statusCode: 405, message: "Method Not Allowed" }, trace }, db: current };
if (!found) return { res: { status: 404, body: { statusCode: 404, message: `Cannot ${req.method} ${req.path}` }, trace }, db: current };
const { ep, params } = found;
// AuthGuard
const actor = session ? db.users.find((u) => u.id === session.userId) : undefined;
if (!session || !actor || session.expiresAt <= now || !actor.active) {
trace.push("AuthGuard ✗");
const message = !session ? "Missing bearer token" : !actor?.active ? "Account is deactivated" : "Token expired";
return { res: { status: 401, body: { statusCode: 401, message }, trace }, db: current };
}
trace.push("AuthGuard ✓");
// PermissionsGuard: reads @RequirePermission() metadata and checks the role matrix in the DB (not the client's copy).
if (ep.permission) {
if (!can(db.roles, actor.role, ep.permission)) {
trace.push(`PermissionsGuard ✗ ${ep.permission}`);
if (req.method !== "GET") audit(db, actor, now, { action: `${req.method} ${ep.pattern}`, target: req.path, outcome: "denied", detail: `missing ${ep.permission}` });
return reply(403, { statusCode: 403, message: `Forbidden: requires ${ep.permission}`, role: actor.role });
}
trace.push(`PermissionsGuard ✓ ${ep.permission}`);
}
// Idempotency-Key: replay the stored response instead of refunding twice.
const key = ep.idempotent ? req.headers?.["Idempotency-Key"] : undefined;
if (key && current.idempotency[key]) {
trace.push(`Idempotency ↺ ${key}`);
const stored = current.idempotency[key]!;
return { res: { ...stored, trace: [...trace, "replayed stored response"] }, db: current };
}
// ValidationPipe
let body = req.body;
if (ep.dto) {
const parsed = ep.dto.safeParse(req.body ?? {});
if (!parsed.success) {
trace.push("ValidationPipe ✗");
const issues = parsed.error.issues.map((i) => ({ field: i.path.join(".") || "(body)", message: i.message }));
return { res: { status: 400, body: { statusCode: 400, message: "Validation failed", errors: issues }, trace }, db: current };
}
trace.push("ValidationPipe ✓");
body = parsed.data;
}
const out = ep.run({ db, actor, params, body, now });
trace.push(`${ep.method} ${ep.pattern} → ${out.status}`);
if (out.audit) audit(db, actor, now, out.audit);
const res: ApiResponse = { status: out.status, body: out.body, trace };
if (key && out.status < 500) db.idempotency[key] = res;
// Failed writes roll back everything except the audit entry.
if (out.status >= 400) return { res, db: { ...current, audit: db.audit, seq: db.seq } };
return { res, db };
}
export const STATUS_TEXT: Record<number, string> = {
200: "OK",
201: "Created",
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
422: "Unprocessable Entity",
};
export const money = (cents: number) => `$${(cents / 100).toFixed(2)}`;app/lab/vue-admin-rbac/app/stores.ts220 lines
/* eslint-disable react-hooks/rules-of-hooks -- Pinia stores (useXStore) are not React hooks */
import { computed, ref, watch } from "vue";
import { defineStore } from "pinia";
import { clearState, loadState, saveState } from "@/components/lab/vue/persist";
import {
DEMO_ACCOUNTS,
STATUS_TEXT,
can as roleCan,
guardNavigation,
handle,
matchRoute,
seedDb,
type ApiRequest,
type ApiResponse,
type Db,
type Permission,
type RouteMatch,
type Session,
} from "../rbac";
const KEY = "lab:vue-admin-rbac:v1";
const SESSION_MS = 15 * 60_000;
interface Saved {
db: Db;
session: Session | null;
path: string;
}
const saved = loadState<Saved>(KEY);
export interface LogLine {
id: number;
at: number;
method: string;
path: string;
status: number;
ms: number;
trace: string[];
actor: string;
}
/**
* The fake network + database. In production this is the NestJS API and PostgreSQL;
* the client stores below only talk to it through `request()`.
*/
export const useBackendStore = defineStore("backend", () => {
const db = ref<Db>(saved?.db ?? seedDb(Date.now()));
const log = ref<LogLine[]>([]);
const latency = ref(300);
let seq = 0;
async function request(req: ApiRequest, session: Session | null, actor: string): Promise<ApiResponse> {
const started = performance.now();
await new Promise((r) => setTimeout(r, latency.value * (0.6 + Math.random() * 0.8)));
const { res, db: next } = handle(db.value, session, req, Date.now());
db.value = next;
log.value = [
{ id: ++seq, at: Date.now(), method: req.method, path: req.path, status: res.status, ms: Math.round(performance.now() - started), trace: res.trace, actor },
...log.value,
].slice(0, 60);
return res;
}
function reset() {
db.value = seedDb(Date.now());
log.value = [];
}
return { db, log, latency, request, reset };
});
/** Who is signed in and what they may do. */
export const useAuthStore = defineStore("auth", () => {
const backend = useBackendStore();
const session = ref<Session | null>(saved?.session ?? null);
/** Page to return to after signing in again (set when a token expires or a guarded URL is opened). */
const returnTo = ref<string | null>(null);
/** Demo switch: render the controls the role should not see, to prove the API still says no. */
const bypassUi = ref(false);
const now = ref(Date.now());
setInterval(() => (now.value = Date.now()), 1000);
const user = computed(() => (session.value ? backend.db.users.find((u) => u.id === session.value!.userId) ?? null : null));
const role = computed(() => user.value?.role);
const permissions = computed<Permission[]>(() => (role.value ? backend.db.roles[role.value] : []));
const isAuthenticated = computed(() => !!user.value);
const secondsLeft = computed(() => (session.value ? Math.max(0, Math.round((session.value.expiresAt - now.value) / 1000)) : 0));
function can(p: Permission) {
return roleCan(backend.db.roles, role.value, p);
}
function loginAs(userId: number) {
session.value = { userId, token: `demo.${userId}.${Date.now().toString(36)}`, expiresAt: Date.now() + SESSION_MS };
}
function logout(remember?: string) {
session.value = null;
returnTo.value = remember ?? null;
}
/** Simulate the access token running out; the next API call gets 401. */
function expireToken() {
if (session.value) session.value = { ...session.value, expiresAt: Date.now() - 1 };
}
return { session, returnTo, bypassUi, user, role, permissions, isAuthenticated, secondsLeft, can, loginAs, logout, expireToken };
});
export interface Toast {
id: number;
tone: "ok" | "err" | "info";
text: string;
}
/** A small vue-router stand-in: a reactive current route plus a beforeEach-style guard. */
export const useRouterStore = defineStore("router", () => {
const auth = useAuthStore();
const path = ref(saved?.path ?? "/");
const history = ref<string[]>([]);
const denied = ref<{ from: string; needed: Permission } | null>(null);
const toasts = ref<Toast[]>([]);
let toastSeq = 0;
const resolved = computed(() =>
guardNavigation(path.value, { authenticated: auth.isAuthenticated, has: (p) => auth.can(p) }),
);
/** The route that is actually rendered after the guard ran. */
const current = computed<RouteMatch>(() => {
const r = resolved.value;
if (r.kind === "ok") return r.match;
if (r.kind === "login") return matchRoute("/login")!;
if (r.kind === "forbidden") return { ...matchRoute("/403")!, params: { from: r.from, needed: r.needed } };
return { ...matchRoute("/403")!, params: { from: r.path, needed: "" } };
});
// The guard result can change without a navigation (role switch, role matrix edit, logout).
watch(
resolved,
(r) => {
if (r.kind === "login" && !auth.returnTo) auth.returnTo = r.redirect;
denied.value = r.kind === "forbidden" ? { from: r.from, needed: r.needed } : null;
},
{ immediate: true },
);
function push(to: string) {
if (to === path.value) return;
history.value = [...history.value, path.value].slice(-20);
path.value = to;
}
function back() {
const prev = history.value.at(-1);
if (prev === undefined) return;
history.value = history.value.slice(0, -1);
path.value = prev;
}
function toast(text: string, tone: Toast["tone"] = "info") {
const id = ++toastSeq;
toasts.value = [...toasts.value, { id, text, tone }].slice(-3);
setTimeout(() => (toasts.value = toasts.value.filter((t) => t.id !== id)), 4500);
}
return { path, history, current, denied, toasts, push, back, toast };
});
/** Persist the backend DB, the session and the current URL in one key. */
export function persistAll() {
const backend = useBackendStore();
const auth = useAuthStore();
const router = useRouterStore();
watch(
() => ({ db: backend.db, session: auth.session, path: router.path }),
(v) => saveState(KEY, v),
{ deep: true },
);
}
export function resetDemo() {
clearState(KEY);
useBackendStore().reset();
const auth = useAuthStore();
auth.loginAs(DEMO_ACCOUNTS[0]!.id);
auth.bypassUi = false;
const router = useRouterStore();
router.path = "/";
router.history = [];
router.toast("Demo data reset", "info");
}
/**
* Typed fetch wrapper used by every client store. A 401 signs the user out and remembers the page,
* like an Axios interceptor on a real token refresh failure.
*/
export async function api<T>(req: ApiRequest): Promise<{ ok: true; data: T; status: number } | { ok: false; status: number; error: ApiError }> {
const auth = useAuthStore();
const backend = useBackendStore();
const router = useRouterStore();
const res = await backend.request(req, auth.session, auth.user?.name ?? "anonymous");
if (res.status < 400) return { ok: true, data: res.body as T, status: res.status };
const error = res.body as ApiError;
if (res.status === 401) {
auth.logout(router.path);
router.toast(`401 ${error.message}. Sign in again to continue.`, "err");
} else if (res.status === 403) {
router.toast(`403 ${STATUS_TEXT[403]}: ${error.message.replace(/^Forbidden: /, "")}`, "err");
}
return { ok: false, status: res.status, error };
}
export interface ApiError {
statusCode: number;
message: string;
field?: string;
errors?: { field: string; message: string }[];
}app/lab/vue-admin-rbac/app/can.ts75 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, watchEffect, type Directive, type DirectiveBinding, type WatchStopHandle } from "vue";
import type { Permission } from "../rbac";
import { useAuthStore, useRouterStore } from "./stores";
/**
* useCan(): the composable form of the permission check, for script logic and computed props.
* const can = useCan(); can("users.write")
* With "bypass UI checks" on, it answers true so the forbidden controls render and you can watch the API refuse.
*/
export function useCan() {
const auth = useAuthStore();
return (p: Permission) => auth.bypassUi || auth.can(p);
}
/** Strict check that ignores the bypass switch (used for labels like "read only"). */
export function useHas() {
const auth = useAuthStore();
return (p: Permission) => auth.can(p);
}
type CanEl = HTMLElement & { __canStop?: WatchStopHandle };
/**
* v-can="'users.write'" -> element is removed from view (hidden) without the permission
* v-can:disable="'orders.refund'" -> element stays visible but disabled, with a reason in the title
*
* A watchEffect keeps it reactive, so switching roles updates buttons without re-rendering the page.
*/
function apply(el: CanEl, binding: DirectiveBinding<Permission>, onlyAdd = false) {
const auth = useAuthStore();
const allowed = auth.can(binding.value);
const shown = allowed || auth.bypassUi;
el.dataset.bypassed = String(!allowed && auth.bypassUi);
if (binding.arg === "disable") {
// After a component update only add `disabled`, so the component's own :disabled binding keeps working.
if (!shown || !onlyAdd) (el as HTMLButtonElement).disabled = !shown;
el.title = shown ? (allowed ? "" : `Bypassed: your role lacks ${binding.value}`) : `Requires ${binding.value}`;
} else {
el.hidden = !shown;
}
}
export const vCan: Directive<CanEl, Permission> = {
mounted(el, binding) {
el.__canStop = watchEffect(() => apply(el, binding));
},
// The component re-rendered (e.g. its own :disabled changed): re-assert the permission state.
updated(el, binding) {
apply(el, binding, true);
},
unmounted(el) {
el.__canStop?.();
},
};
/** <RouterLink to="/users">: an <a> that navigates through the router store and marks the active page. */
export const RouterLink = defineComponent({
name: "RouterLink",
props: { to: { type: String, required: true }, exact: Boolean },
setup(props) {
const router = useRouterStore();
const active = computed(() =>
props.exact ? router.path === props.to : router.path === props.to || router.path.startsWith(props.to + "/"),
);
const go = (e: MouseEvent) => {
if (e.metaKey || e.ctrlKey) return;
e.preventDefault();
router.push(props.to);
};
return { active, go };
},
template: `<a :href="'#' + to" @click="go" :aria-current="active ? 'page' : undefined"><slot :active="active" /></a>`,
});app/lab/vue-admin-rbac/app/resources.ts130 lines
/* eslint-disable react-hooks/rules-of-hooks -- Pinia stores (useXStore) are not React hooks */
import { ref } from "vue";
import { defineStore } from "pinia";
import type { AuditEntry, CreateUserDto, Order, Permission, RoleId, RoleMatrix, User } from "../rbac";
import { api, useRouterStore, type ApiError } from "./stores";
type Status = "idle" | "loading" | "ready" | "error";
/** Client-side cache of /api/users with optimistic writes and rollback. */
export const useUsersStore = defineStore("users", () => {
const items = ref<User[]>([]);
const status = ref<Status>("idle");
const error = ref<string | null>(null);
const pending = ref<Set<number>>(new Set());
async function load() {
status.value = items.value.length ? "ready" : "loading";
const res = await api<User[]>({ method: "GET", path: "/api/users" });
if (res.ok) {
items.value = res.data;
status.value = "ready";
error.value = null;
} else {
status.value = "error";
error.value = res.error.message;
}
}
async function create(dto: CreateUserDto): Promise<{ ok: true; user: User } | { ok: false; error: ApiError }> {
const res = await api<User>({ method: "POST", path: "/api/users", body: dto });
if (!res.ok) return { ok: false, error: res.error };
items.value = [...items.value, res.data];
return { ok: true, user: res.data };
}
async function update(id: number, dto: Partial<CreateUserDto>): Promise<{ ok: true } | { ok: false; error: ApiError }> {
const res = await api<User>({ method: "PATCH", path: `/api/users/${id}`, body: dto });
if (!res.ok) return { ok: false, error: res.error };
items.value = items.value.map((u) => (u.id === id ? res.data : u));
return { ok: true };
}
/** Optimistic: flip the flag now, roll back if the API says no. */
async function toggleActive(id: number) {
const router = useRouterStore();
const before = items.value;
items.value = items.value.map((u) => (u.id === id ? { ...u, active: !u.active } : u));
pending.value = new Set(pending.value).add(id);
const res = await api<User>({ method: "POST", path: `/api/users/${id}/deactivate` });
const next = new Set(pending.value);
next.delete(id);
pending.value = next;
if (!res.ok) {
items.value = before;
if (res.status !== 403 && res.status !== 401) router.toast(`${res.status} ${res.error.message}`, "err");
else router.toast("Change rolled back", "info");
return;
}
items.value = items.value.map((u) => (u.id === id ? res.data : u));
router.toast(`${res.data.name} ${res.data.active ? "reactivated" : "deactivated"}`, "ok");
}
return { items, status, error, pending, load, create, update, toggleActive };
});
export const useOrdersStore = defineStore("orders", () => {
const items = ref<Order[]>([]);
const status = ref<Status>("idle");
const error = ref<string | null>(null);
async function load() {
status.value = items.value.length ? "ready" : "loading";
const res = await api<Order[]>({ method: "GET", path: "/api/orders" });
if (res.ok) {
items.value = res.data;
status.value = "ready";
} else {
status.value = "error";
error.value = res.error.message;
}
}
/** Refunds send an Idempotency-Key so a double click or retry cannot refund twice. */
async function refund(id: number, amount: number, idempotencyKey: string) {
const res = await api<Order>({
method: "POST",
path: `/api/orders/${id}/refund`,
body: { amount },
headers: { "Idempotency-Key": idempotencyKey },
});
if (res.ok) items.value = items.value.map((o) => (o.id === id ? res.data : o));
return res;
}
return { items, status, error, load, refund };
});
export const useRolesStore = defineStore("roles", () => {
const matrix = ref<RoleMatrix | null>(null);
const error = ref<string | null>(null);
async function load() {
const res = await api<RoleMatrix>({ method: "GET", path: "/api/roles" });
if (res.ok) matrix.value = res.data;
else error.value = res.error.message;
}
async function save(role: RoleId, permissions: Permission[]) {
const res = await api<RoleMatrix>({ method: "PUT", path: `/api/roles/${role}`, body: { permissions } });
if (res.ok) matrix.value = res.data;
return res;
}
return { matrix, error, load, save };
});
export const useAuditStore = defineStore("audit", () => {
const items = ref<AuditEntry[]>([]);
const status = ref<Status>("idle");
async function load() {
status.value = "loading";
const res = await api<AuditEntry[]>({ method: "GET", path: "/api/audit" });
if (res.ok) items.value = res.data;
status.value = res.ok ? "ready" : "error";
}
return { items, status, load };
});app/lab/vue-admin-rbac/app/App.ts161 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, markRaw, ref, watch, type Component } from "vue";
import { DEMO_ACCOUNTS, ROUTES, STATUS_TEXT } from "../rbac";
import { RouterLink } from "./can";
import AuditPage from "./pages/Audit";
import ConsolePage from "./pages/Console";
import { OrderPage, OrdersPage } from "./pages/Orders";
import RolesPage from "./pages/Roles";
import { DashboardPage, ForbiddenPage, LoginPage } from "./pages/Shell";
import UserFormPage from "./pages/UserForm";
import UsersPage from "./pages/Users";
import { persistAll, resetDemo, useAuthStore, useBackendStore, useRouterStore } from "./stores";
/** Route name -> page component (what `component:` is in a Vue Router route record). */
const VIEWS: Record<string, Component> = {
dashboard: DashboardPage,
users: UsersPage,
"user-new": UserFormPage,
"user-edit": UserFormPage,
orders: OrdersPage,
order: OrderPage,
roles: RolesPage,
audit: AuditPage,
console: ConsolePage,
login: LoginPage,
forbidden: ForbiddenPage,
};
const WITH_PARAMS = new Set(["user-new", "user-edit", "order", "forbidden"]);
export default defineComponent({
name: "AdminApp",
components: { RouterLink },
setup() {
const auth = useAuthStore();
// First visit: sign in as the admin (before the router guard first runs) so the panel opens on something useful.
if (!auth.session) auth.loginAs(DEMO_ACCOUNTS[0]!.id);
const router = useRouterStore();
const backend = useBackendStore();
persistAll();
// Sidebar = routes marked for the menu that the current role can reach (strict check, ignores bypass).
const menu = computed(() => ROUTES.filter((r) => r.menu && (!r.permission || auth.can(r.permission))));
const hiddenCount = computed(() => ROUTES.filter((r) => r.menu).length - menu.value.length);
const view = computed(() => markRaw(VIEWS[router.current.route.name] ?? DashboardPage));
const viewProps = computed(() => (WITH_PARAMS.has(router.current.route.name) ? { params: router.current.params } : {}));
// Address bar: type a URL to test the guard directly.
const address = ref(router.path);
watch(() => router.path, (p) => (address.value = p));
const go = () => router.push(address.value.trim() || "/");
const accountId = computed({
get: () => auth.session?.userId ?? 0,
set: (id: number) => {
auth.loginAs(id);
const a = DEMO_ACCOUNTS.find((x) => x.id === id)!;
router.toast("Now " + a.label + " (" + a.role + "). Menu and page re-checked.", "info");
},
});
const mmss = computed(() => {
const s = auth.secondsLeft;
return String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0");
});
const time = (t: number) => new Date(t).toLocaleTimeString([], { hour12: false });
const tone = (s: number) => (s < 300 ? "text-[#4ade80]" : s === 401 || s === 403 ? "text-[#f87171]" : "text-[#fbbf24]");
function signOut() {
auth.logout();
router.path = "/login";
}
return {
auth, router, backend, menu, hiddenCount, view, viewProps, address, go, accountId, accounts: DEMO_ACCOUNTS,
mmss, time, tone, signOut, resetDemo, STATUS_TEXT,
};
},
template: `
<div class="flex min-h-[40rem] flex-col text-fg">
<!-- Top bar: address bar, role switcher, session -->
<header class="flex flex-wrap items-center gap-2 border-b border-border bg-bg-elev px-3 py-2 text-sm">
<span class="font-mono font-semibold"><span class="text-accent">▲</span> Acme Admin</span>
<form class="order-last flex min-w-0 basis-full items-center gap-1 sm:order-none sm:basis-auto sm:flex-1" @submit.prevent="go">
<label for="rbac-address" class="sr-only">Address</label>
<span class="font-mono text-xs text-fg-muted" aria-hidden="true">admin.acme.test</span>
<input id="rbac-address" v-model="address" spellcheck="false" class="min-w-0 flex-1 rounded border border-border bg-bg px-2 py-1 font-mono text-xs outline-none focus:border-accent" />
<button type="submit" class="rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent">go</button>
</form>
<div class="ml-auto flex flex-wrap items-center gap-2">
<label class="flex items-center gap-1 text-xs text-fg-muted">
<span>Signed in as</span>
<select v-model.number="accountId" class="rounded border border-border bg-bg px-1.5 py-1 text-xs text-fg" :disabled="!auth.session">
<option v-if="!auth.session" :value="0">nobody</option>
<option v-for="a in accounts" :key="a.id" :value="a.id">{{ a.label }} · {{ a.role }}</option>
</select>
</label>
<span v-if="auth.session" class="font-mono text-xs" :class="auth.secondsLeft ? 'text-fg-muted' : 'text-danger'" title="Access token lifetime">
token {{ auth.secondsLeft ? mmss : 'expired' }}
</span>
</div>
</header>
<!-- Demo controls -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-border bg-bg-muted px-3 py-1.5 text-xs text-fg-muted">
<label class="flex items-center gap-1.5">
<input v-model="auth.bypassUi" type="checkbox" class="accent-[var(--warn-fg)]" /> Bypass UI checks (show forbidden buttons)
</label>
<button type="button" class="hover:text-accent" :disabled="!auth.session" @click="auth.expireToken()">Expire token</button>
<button v-if="auth.session" type="button" class="hover:text-accent" @click="signOut">Sign out</button>
<button type="button" class="hover:text-accent" @click="resetDemo()">Reset demo</button>
<label class="ml-auto flex items-center gap-1.5">latency
<select v-model.number="backend.latency" class="rounded border border-border bg-bg px-1 py-0.5 text-fg">
<option :value="0">0 ms</option><option :value="300">300 ms</option><option :value="1200">1.2 s</option>
</select>
</label>
</div>
<div class="flex flex-1 flex-col md:flex-row">
<!-- Sidebar built from the routes the user can reach -->
<nav v-if="auth.isAuthenticated" aria-label="Admin" class="border-b border-border bg-bg-elev md:w-48 md:shrink-0 md:border-r md:border-b-0">
<ul class="flex gap-1 overflow-x-auto p-2 md:flex-col">
<li v-for="r in menu" :key="r.name">
<RouterLink :to="r.path" :exact="r.path === '/'" v-slot="{ active }"
class="block rounded-md whitespace-nowrap focus-visible:outline-2 focus-visible:outline-accent">
<span class="block rounded-md px-3 py-1.5 text-sm" :class="active ? 'bg-accent-soft text-accent' : 'hover:bg-bg-muted'">{{ r.label }}</span>
</RouterLink>
</li>
</ul>
<p v-if="hiddenCount" class="hidden px-4 pb-3 text-xs text-fg-muted md:block">{{ hiddenCount }} menu item{{ hiddenCount > 1 ? 's' : '' }} hidden for {{ auth.role }}</p>
</nav>
<main class="min-w-0 flex-1 p-4 sm:p-6">
<component :is="view" v-bind="viewProps" :key="router.current.path + ':' + router.current.route.name" />
</main>
</div>
<!-- Request log: always a dark terminal -->
<section aria-label="Request log" class="border-t border-border bg-[#0b0b0d] p-3">
<h3 class="mb-1 font-mono text-xs text-[#a1a1aa]">request log · every call goes through AuthGuard → PermissionsGuard → ValidationPipe</h3>
<ol role="log" aria-live="polite" class="h-40 overflow-y-auto font-mono text-[12px] leading-relaxed">
<li v-if="!backend.log.length" class="text-[#71717a]">$ waiting for requests…</li>
<li v-for="l in backend.log" :key="l.id">
<span class="text-[#71717a]">{{ time(l.at) }}</span>
<span class="text-[#a1a1aa]"> {{ l.actor }} </span>
<span class="text-[#e4e4e7]">{{ l.method }} {{ l.path }}</span>
<span :class="tone(l.status)"> {{ l.status }} {{ STATUS_TEXT[l.status] }}</span>
<span class="text-[#71717a]"> {{ l.ms }}ms · {{ l.trace.join(' → ') }}</span>
</li>
</ol>
</section>
<!-- Toasts -->
<div aria-live="assertive" class="pointer-events-none absolute right-3 bottom-48 z-[500] flex w-[min(22rem,calc(100%-1.5rem))] flex-col gap-2">
<p v-for="t in router.toasts" :key="t.id" class="rounded-md border px-3 py-2 text-sm shadow-lg"
:class="t.tone === 'ok' ? 'border-accent/40 bg-bg-elev text-fg' : t.tone === 'err' ? 'border-danger/50 bg-bg-elev text-danger' : 'border-border bg-bg-elev text-fg'">
{{ t.text }}
</p>
</div>
</div>`,
});app/lab/vue-admin-rbac/app/pages/Users.ts118 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
import { ROLE_IDS } from "../../rbac";
import { RouterLink, useCan, useHas, vCan } from "../can";
import { useUsersStore } from "../resources";
import { useAuthStore } from "../stores";
export default defineComponent({
name: "UsersPage",
components: { RouterLink },
directives: { can: vCan },
setup() {
const users = useUsersStore();
const auth = useAuthStore();
const can = useCan();
const has = useHas();
const q = ref("");
const role = ref<"" | (typeof ROLE_IDS)[number]>("");
const showInactive = ref(true);
const search = ref<HTMLInputElement | null>(null);
const rows = computed(() => {
const needle = q.value.trim().toLowerCase();
return users.items
.filter((u) => (!role.value || u.role === role.value) && (showInactive.value || u.active))
.filter((u) => !needle || u.name.toLowerCase().includes(needle) || u.email.includes(needle))
.sort((a, b) => a.name.localeCompare(b.name));
});
// "/" focuses the search box, like most admin tools.
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.key === "/" && !["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName)) {
e.preventDefault();
search.value?.focus();
}
};
onMounted(() => {
users.load();
window.addEventListener("keydown", onKey);
});
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
return { users, auth, can, has, q, role, showInactive, rows, search, roles: ROLE_IDS };
},
template: `
<section aria-labelledby="users-h">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 id="users-h" class="text-xl font-semibold">Users</h2>
<p class="text-sm text-fg-muted">{{ users.items.length }} accounts · <span v-if="!has('users.write')">read only for your role</span><span v-else>you can edit</span></p>
</div>
<RouterLink to="/users/new" v-can="'users.write'"
class="rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg hover:opacity-90 data-[bypassed=true]:outline-2 data-[bypassed=true]:outline-dashed data-[bypassed=true]:outline-warn-fg">
+ New user
</RouterLink>
</div>
<div class="mt-4 flex flex-wrap items-end gap-3">
<label class="min-w-0 flex-1 basis-48">
<span class="text-xs text-fg-muted">Search <kbd class="rounded border border-border px-1 font-mono">/</kbd></span>
<input ref="search" v-model="q" type="search" placeholder="Name or email"
class="mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-2 text-sm outline-none focus:border-accent" />
</label>
<label>
<span class="text-xs text-fg-muted">Role</span>
<select v-model="role" class="mt-1 block rounded-md border border-border bg-bg-elev px-3 py-2 text-sm">
<option value="">All roles</option>
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
</select>
</label>
<label class="flex items-center gap-2 pb-2 text-sm">
<input v-model="showInactive" type="checkbox" class="accent-[var(--accent)]" /> Show inactive
</label>
</div>
<p v-if="users.status === 'loading'" class="mt-6 font-mono text-sm text-fg-muted" aria-live="polite">GET /api/users …</p>
<p v-else-if="users.status === 'error'" role="alert" class="mt-6 text-sm text-danger">{{ users.error }}</p>
<div v-else class="mt-4 overflow-x-auto">
<table class="w-full min-w-[34rem] text-left text-sm">
<caption class="sr-only">Users, filtered by the controls above</caption>
<thead class="font-mono text-xs text-fg-muted">
<tr class="border-b border-border">
<th scope="col" class="py-2 pr-3 font-normal">name</th>
<th scope="col" class="py-2 pr-3 font-normal">role</th>
<th scope="col" class="py-2 pr-3 font-normal">status</th>
<th scope="col" class="py-2 text-right font-normal">actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in rows" :key="u.id" class="border-b border-border" :class="{ 'opacity-60': !u.active }">
<td class="py-2 pr-3">
<div class="font-medium">{{ u.name }} <span v-if="u.id === auth.user?.id" class="ml-1 rounded bg-accent-soft px-1.5 font-mono text-[10px] text-accent">you</span></div>
<div class="font-mono text-xs text-fg-muted">{{ u.email }}</div>
</td>
<td class="py-2 pr-3 font-mono text-xs">{{ u.role }}</td>
<td class="py-2 pr-3 text-xs">
<span :class="u.active ? 'text-accent' : 'text-fg-muted'">{{ u.active ? '● active' : '○ inactive' }}</span>
<span v-if="users.pending.has(u.id)" class="ml-1 font-mono text-fg-muted">saving…</span>
</td>
<td class="py-2 text-right whitespace-nowrap">
<RouterLink :to="'/users/' + u.id" class="rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent hover:text-accent">
{{ can('users.write') ? 'edit' : 'view' }}
</RouterLink>
<button type="button" v-can="'users.deactivate'" @click="users.toggleActive(u.id)" :disabled="users.pending.has(u.id)"
:aria-label="(u.active ? 'Deactivate ' : 'Reactivate ') + u.name"
class="ml-1 rounded border border-border px-2 py-1 font-mono text-xs hover:border-danger hover:text-danger disabled:opacity-50 data-[bypassed=true]:border-dashed data-[bypassed=true]:border-warn-fg">
{{ u.active ? 'deactivate' : 'reactivate' }}
</button>
</td>
</tr>
<tr v-if="!rows.length"><td colspan="4" class="py-8 text-center text-sm text-fg-muted">No users match these filters.</td></tr>
</tbody>
</table>
</div>
</section>`,
});app/lab/vue-admin-rbac/app/pages/UserForm.ts111 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, onMounted, reactive, ref, watch } from "vue";
import { ROLE_IDS, type CreateUserDto } from "../../rbac";
import { RouterLink, useCan } from "../can";
import { useUsersStore } from "../resources";
import { useAuthStore, useRouterStore } from "../stores";
type Field = keyof CreateUserDto;
/** Create (/users/new) and edit (/users/:id). Read-only without users.write. */
export default defineComponent({
name: "UserFormPage",
components: { RouterLink },
props: { params: { type: Object as () => Record<string, string>, required: true } },
setup(props) {
const users = useUsersStore();
const router = useRouterStore();
const auth = useAuthStore();
const can = useCan();
const id = computed(() => (props.params.id ? Number(props.params.id) : null));
const existing = computed(() => (id.value ? users.items.find((u) => u.id === id.value) ?? null : null));
const readOnly = computed(() => !can("users.write"));
const form = reactive<CreateUserDto>({ name: "", email: "", role: "viewer" });
const errors = reactive<Partial<Record<Field | "form", string>>>({});
const saving = ref(false);
const touched = ref(false);
const fill = () => {
if (existing.value) Object.assign(form, { name: existing.value.name, email: existing.value.email, role: existing.value.role });
};
onMounted(async () => {
if (!users.items.length) await users.load();
fill();
});
watch(existing, fill);
/** Same rules as the server's zod DTO, so most errors show before a request is made. */
function validate() {
for (const k of Object.keys(errors) as (Field | "form")[]) delete errors[k];
if (form.name.trim().length < 2) errors.name = "Name must be at least 2 characters";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) errors.email = "Enter a valid email address";
return !errors.name && !errors.email;
}
watch(form, () => touched.value && validate());
async function submit() {
touched.value = true;
if (!validate()) return;
saving.value = true;
const res = id.value ? await users.update(id.value, { ...form }) : await users.create({ ...form });
saving.value = false;
if (!res.ok) {
const e = res.error;
e.errors?.forEach((x) => (errors[x.field as Field] = x.message));
if (e.field) errors[e.field as Field] = e.message;
else if (!e.errors) errors.form = e.statusCode + " " + e.message;
return;
}
router.toast(id.value ? "User saved" : "User created", "ok");
router.push("/users");
}
const isSelf = computed(() => existing.value?.id === auth.user?.id);
return { id, existing, readOnly, form, errors, saving, submit, isSelf, roles: ROLE_IDS, users };
},
template: `
<section aria-labelledby="uf-h" class="max-w-lg">
<RouterLink to="/users" class="font-mono text-xs text-fg-muted hover:text-accent">← users</RouterLink>
<h2 id="uf-h" class="mt-2 text-xl font-semibold">{{ id ? (readOnly ? 'User' : 'Edit user') : 'New user' }}</h2>
<p v-if="id && !existing && users.status === 'ready'" role="alert" class="mt-4 text-sm text-danger">404 · No user with id {{ id }}.</p>
<p v-if="readOnly" class="mt-2 rounded-md bg-warn-bg px-3 py-2 text-sm text-warn-fg">Read only: your role does not have <code class="font-mono">users.write</code>.</p>
<form v-if="!id || existing" class="mt-4 space-y-4" novalidate @submit.prevent="submit">
<fieldset :disabled="readOnly || saving" class="space-y-4">
<label class="block">
<span class="text-sm font-medium">Full name</span>
<input v-model="form.name" autocomplete="off" :aria-invalid="!!errors.name" :aria-describedby="errors.name ? 'uf-name-err' : undefined"
class="mt-1 w-full rounded-md border bg-bg-elev px-3 py-2 text-sm outline-none focus:border-accent disabled:opacity-60"
:class="errors.name ? 'border-danger' : 'border-border'" />
<span v-if="errors.name" id="uf-name-err" class="mt-1 block text-xs text-danger">{{ errors.name }}</span>
</label>
<label class="block">
<span class="text-sm font-medium">Email</span>
<input v-model="form.email" type="email" autocomplete="off" :aria-invalid="!!errors.email" :aria-describedby="errors.email ? 'uf-email-err' : undefined"
class="mt-1 w-full rounded-md border bg-bg-elev px-3 py-2 font-mono text-sm outline-none focus:border-accent disabled:opacity-60"
:class="errors.email ? 'border-danger' : 'border-border'" />
<span v-if="errors.email" id="uf-email-err" class="mt-1 block text-xs text-danger">{{ errors.email }}</span>
</label>
<fieldset>
<legend class="text-sm font-medium">Role</legend>
<div class="mt-1 flex flex-wrap gap-2" :aria-describedby="errors.role ? 'uf-role-err' : undefined">
<label v-for="r in roles" :key="r" class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent-soft"
:class="isSelf ? 'cursor-not-allowed opacity-60' : 'border-border'">
<input v-model="form.role" type="radio" name="role" :value="r" :disabled="isSelf" class="accent-[var(--accent)]" /> {{ r }}
</label>
</div>
<span v-if="isSelf" class="mt-1 block text-xs text-fg-muted">You cannot change your own role.</span>
<span v-if="errors.role" id="uf-role-err" class="mt-1 block text-xs text-danger">{{ errors.role }}</span>
</fieldset>
</fieldset>
<p v-if="errors.form" role="alert" class="text-sm text-danger">{{ errors.form }}</p>
<div v-if="!readOnly" class="flex gap-2">
<button type="submit" :disabled="saving" class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90 disabled:opacity-60">
{{ saving ? 'Saving…' : (id ? 'Save changes' : 'Create user') }}
</button>
<RouterLink to="/users" class="rounded-md border border-border px-4 py-2 text-sm hover:border-accent">Cancel</RouterLink>
</div>
</form>
</section>`,
});app/lab/vue-admin-rbac/app/pages/Orders.ts150 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, onMounted, ref, watch } from "vue";
import { money } from "../../rbac";
import { RouterLink, vCan } from "../can";
import { useOrdersStore } from "../resources";
import { useRouterStore } from "../stores";
const STATUS_LABEL = { paid: "paid", partially_refunded: "part refunded", refunded: "refunded" } as const;
export const OrdersPage = defineComponent({
name: "OrdersPage",
components: { RouterLink },
setup() {
const orders = useOrdersStore();
const filter = ref<"all" | "paid" | "refunds">("all");
onMounted(orders.load);
const rows = computed(() =>
orders.items.filter((o) => filter.value === "all" || (filter.value === "paid" ? o.status === "paid" : o.refunded > 0)),
);
const total = computed(() => rows.value.reduce((s, o) => s + o.total - o.refunded, 0));
return { orders, filter, rows, total, money, label: STATUS_LABEL, filters: ["all", "paid", "refunds"] as const };
},
template: `
<section aria-labelledby="orders-h">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="orders-h" class="text-xl font-semibold">Orders</h2>
<p class="text-sm text-fg-muted">Net revenue in view: <span class="font-mono text-fg">{{ money(total) }}</span></p>
</div>
<div role="group" aria-label="Filter orders" class="flex rounded-md border border-border p-0.5 text-xs">
<button v-for="f in filters" :key="f" type="button" @click="filter = f" :aria-pressed="filter === f"
class="rounded px-3 py-1 font-mono" :class="filter === f ? 'bg-accent text-accent-fg' : 'hover:text-accent'">{{ f }}</button>
</div>
</div>
<p v-if="orders.status === 'loading'" class="mt-6 font-mono text-sm text-fg-muted">GET /api/orders …</p>
<p v-else-if="orders.status === 'error'" role="alert" class="mt-6 text-sm text-danger">{{ orders.error }}</p>
<div v-else class="mt-4 overflow-x-auto">
<table class="w-full min-w-[30rem] text-left text-sm">
<caption class="sr-only">Orders</caption>
<thead class="font-mono text-xs text-fg-muted">
<tr class="border-b border-border">
<th scope="col" class="py-2 pr-3 font-normal">order</th>
<th scope="col" class="py-2 pr-3 font-normal">customer</th>
<th scope="col" class="py-2 pr-3 text-right font-normal">total</th>
<th scope="col" class="py-2 font-normal">status</th>
</tr>
</thead>
<tbody>
<tr v-for="o in rows" :key="o.id" class="border-b border-border">
<td class="py-2 pr-3 font-mono"><RouterLink :to="'/orders/' + o.id" class="text-accent hover:underline">#{{ o.id }}</RouterLink></td>
<td class="py-2 pr-3">{{ o.customer }}</td>
<td class="py-2 pr-3 text-right font-mono">{{ money(o.total) }}</td>
<td class="py-2 text-xs">
<span :class="o.status === 'paid' ? 'text-accent' : 'text-warn-fg'">{{ label[o.status] }}</span>
<span v-if="o.refunded && o.status !== 'refunded'" class="ml-1 font-mono text-fg-muted">−{{ money(o.refunded) }}</span>
</td>
</tr>
<tr v-if="!rows.length"><td colspan="4" class="py-8 text-center text-sm text-fg-muted">No orders match this filter.</td></tr>
</tbody>
</table>
</div>
</section>`,
});
export const OrderPage = defineComponent({
name: "OrderPage",
components: { RouterLink },
directives: { can: vCan },
props: { params: { type: Object as () => Record<string, string>, required: true } },
setup(props) {
const orders = useOrdersStore();
const router = useRouterStore();
const order = computed(() => orders.items.find((o) => o.id === Number(props.params.id)) ?? null);
const amount = ref("");
const error = ref<string | null>(null);
const busy = ref(false);
const doubleSubmit = ref(false);
const result = ref<string | null>(null);
const newKey = () => "rf_" + crypto.randomUUID().slice(0, 8);
let key = newKey();
onMounted(() => {
if (!orders.items.length) orders.load();
});
const left = computed(() => (order.value ? order.value.total - order.value.refunded : 0));
watch(left, (l) => (amount.value = l ? (l / 100).toFixed(2) : ""), { immediate: true });
async function refund() {
if (!order.value) return;
const id = order.value.id;
const cents = Math.round(Number(amount.value) * 100);
error.value = null;
result.value = null;
if (!Number.isFinite(cents) || cents <= 0) {
error.value = "Enter an amount above zero";
return;
}
busy.value = true;
// With "double click" on, the same request goes out twice with one Idempotency-Key.
const calls = doubleSubmit.value ? [0, 1] : [0];
const all = await Promise.all(calls.map(() => orders.refund(id, cents, key)));
busy.value = false;
const first = all[0]!;
if (!first.ok) {
if (first.status !== 403 && first.status !== 401)
error.value = first.status + " " + (first.error.errors?.[0]?.message ?? first.error.message);
return;
}
result.value =
all.length > 1 ? "Refunded once. The second request was answered from the idempotency store." : "Refund issued.";
router.toast("Refunded " + money(cents) + " on #" + id, "ok");
key = newKey(); // the next refund is a new intent
}
return { order, amount, error, busy, refund, left, money, doubleSubmit, result, label: STATUS_LABEL, orders };
},
template: `
<section aria-labelledby="order-h" class="max-w-xl">
<RouterLink to="/orders" class="font-mono text-xs text-fg-muted hover:text-accent">← orders</RouterLink>
<p v-if="!order && orders.status === 'ready'" role="alert" class="mt-4 text-sm text-danger">404 · Order not found.</p>
<template v-if="order">
<h2 id="order-h" class="mt-2 text-xl font-semibold">Order #{{ order.id }}</h2>
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="rounded-md border border-border p-3"><dt class="text-xs text-fg-muted">Customer</dt><dd class="mt-1">{{ order.customer }}</dd></div>
<div class="rounded-md border border-border p-3"><dt class="text-xs text-fg-muted">Total</dt><dd class="mt-1 font-mono">{{ money(order.total) }}</dd></div>
<div class="rounded-md border border-border p-3"><dt class="text-xs text-fg-muted">Refunded</dt><dd class="mt-1 font-mono">{{ money(order.refunded) }}</dd></div>
<div class="rounded-md border border-border p-3"><dt class="text-xs text-fg-muted">Status</dt><dd class="mt-1">{{ label[order.status] }}</dd></div>
</dl>
<form class="mt-6 space-y-3 rounded-lg border border-border bg-bg-elev p-4" novalidate @submit.prevent="refund">
<h3 class="font-mono text-sm font-semibold">Issue refund</h3>
<p v-if="!left" class="text-sm text-fg-muted">This order is fully refunded. Submitting again shows the 409.</p>
<label class="block">
<span class="text-sm">Amount (USD), up to {{ money(left) }}</span>
<input v-model="amount" inputmode="decimal" :aria-invalid="!!error" :aria-describedby="error ? 'refund-err' : undefined"
class="mt-1 w-full rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm outline-none focus:border-accent" />
</label>
<label class="flex items-center gap-2 text-xs text-fg-muted">
<input v-model="doubleSubmit" type="checkbox" class="accent-[var(--accent)]" /> Simulate a double click (same Idempotency-Key sent twice)
</label>
<p v-if="error" id="refund-err" role="alert" class="text-sm text-danger">{{ error }}</p>
<p v-if="result" aria-live="polite" class="text-sm text-accent">{{ result }}</p>
<button type="submit" v-can:disable="'orders.refund'" :disabled="busy"
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40 data-[bypassed=true]:outline-2 data-[bypassed=true]:outline-dashed data-[bypassed=true]:outline-warn-fg">
{{ busy ? 'Refunding…' : 'Refund' }}
</button>
<p class="text-xs text-fg-muted">Needs <code class="font-mono">orders.refund</code>. Viewers see this button disabled.</p>
</form>
</template>
</section>`,
});app/lab/vue-admin-rbac/app/pages/Roles.ts80 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, onMounted, ref } from "vue";
import { LOCKED, PERMISSIONS, ROLE_IDS, type Permission, type RoleId } from "../../rbac";
import { useCan } from "../can";
import { useRolesStore } from "../resources";
import { useRouterStore } from "../stores";
/** Permission matrix. Each checkbox is a PUT /api/roles/:role; the menu and guards react immediately. */
export default defineComponent({
name: "RolesPage",
setup() {
const roles = useRolesStore();
const router = useRouterStore();
const can = useCan();
const saving = ref<string | null>(null);
const error = ref<string | null>(null);
onMounted(roles.load);
const groups = computed(() => {
const out: Record<string, Permission[]> = {};
for (const p of PERMISSIONS) (out[p.split(".")[0]!] ??= []).push(p);
return Object.entries(out);
});
const locked = (role: RoleId, p: Permission) => !!LOCKED[role]?.includes(p);
async function toggle(role: RoleId, p: Permission, e: Event) {
if (!roles.matrix) return;
const has = roles.matrix[role].includes(p);
const next = has ? roles.matrix[role].filter((x) => x !== p) : [...roles.matrix[role], p];
saving.value = role + ":" + p;
error.value = null;
const res = await roles.save(role, next);
saving.value = null;
if (!res.ok) {
(e.target as HTMLInputElement).checked = has; // the server said no: undo the checkbox
if (res.status !== 403 && res.status !== 401) error.value = res.status + " " + res.error.message;
return;
}
router.toast((has ? "Removed " : "Granted ") + p + (has ? " from " : " to ") + role, "ok");
}
return { roles, groups, ROLE_IDS, can, toggle, saving, error, locked };
},
template: `
<section aria-labelledby="roles-h">
<h2 id="roles-h" class="text-xl font-semibold">Roles & permissions</h2>
<p class="mt-1 max-w-2xl text-sm text-fg-muted">
Permissions are fine-grained strings. Routes, menu items and buttons ask for a permission, never for a role,
so changing this matrix changes the whole app. Try removing <code class="font-mono">orders.read</code> from your own role.
</p>
<p v-if="!can('roles.manage')" class="mt-3 rounded-md bg-warn-bg px-3 py-2 text-sm text-warn-fg">Read only: editing needs <code class="font-mono">roles.manage</code>.</p>
<p v-if="error" role="alert" class="mt-3 text-sm text-danger">{{ error }}</p>
<p v-if="!roles.matrix && !roles.error" class="mt-6 font-mono text-sm text-fg-muted">GET /api/roles …</p>
<div v-if="roles.matrix" class="mt-4 overflow-x-auto">
<table class="w-full min-w-[28rem] text-sm">
<caption class="sr-only">Permission matrix: rows are permissions, columns are roles</caption>
<thead class="font-mono text-xs text-fg-muted">
<tr class="border-b border-border">
<th scope="col" class="py-2 pr-3 text-left font-normal">permission</th>
<th v-for="r in ROLE_IDS" :key="r" scope="col" class="px-3 py-2 font-normal">{{ r }}</th>
</tr>
</thead>
<tbody v-for="[group, perms] in groups" :key="group">
<tr><th colspan="4" scope="colgroup" class="pt-4 pb-1 text-left font-mono text-xs uppercase tracking-wider text-accent">{{ group }}</th></tr>
<tr v-for="p in perms" :key="p" class="border-b border-border">
<th scope="row" class="py-2 pr-3 text-left font-mono text-xs font-normal">{{ p }}</th>
<td v-for="r in ROLE_IDS" :key="r" class="px-3 py-2 text-center">
<input type="checkbox" :checked="roles.matrix[r].includes(p)"
:disabled="!can('roles.manage') || locked(r, p) || saving === r + ':' + p"
:aria-label="r + ' has ' + p" :title="locked(r, p) ? 'Locked: prevents admins locking themselves out' : ''"
@change="toggle(r, p, $event)" class="size-4 accent-[var(--accent)] disabled:opacity-50" />
</td>
</tr>
</tbody>
</table>
</div>
</section>`,
});app/lab/vue-admin-rbac/app/pages/Audit.ts86 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, onMounted, ref } from "vue";
import { useAuditStore } from "../resources";
/** Admin-only audit trail. Denied writes are recorded too: that is what security reviews ask for first. */
export default defineComponent({
name: "AuditPage",
setup() {
const audit = useAuditStore();
const outcome = ref<"" | "ok" | "denied" | "conflict">("");
const actor = ref("");
onMounted(audit.load);
const actors = computed(() => [...new Set(audit.items.map((a) => a.actor))].sort());
const rows = computed(() =>
audit.items.filter((a) => (!outcome.value || a.outcome === outcome.value) && (!actor.value || a.actor === actor.value)),
);
const time = (t: number) => new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
function exportCsv() {
const head = "at,actor,role,action,target,outcome,detail";
const lines = rows.value.map((a) =>
[new Date(a.at).toISOString(), a.actor, a.role, a.action, a.target, a.outcome, a.detail ?? ""]
.map((v) => '"' + String(v).replace(/"/g, '""') + '"')
.join(","),
);
const url = URL.createObjectURL(new Blob([[head, ...lines].join("\n")], { type: "text/csv" }));
const a = Object.assign(document.createElement("a"), { href: url, download: "audit-log.csv" });
a.click();
URL.revokeObjectURL(url);
}
return { audit, rows, outcome, actor, actors, time, exportCsv };
},
template: `
<section aria-labelledby="audit-h">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="audit-h" class="text-xl font-semibold">Audit log</h2>
<p class="text-sm text-fg-muted">Every write and every denied write, newest first.</p>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="text-xs text-fg-muted">Actor
<select v-model="actor" class="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm text-fg">
<option value="">Everyone</option>
<option v-for="a in actors" :key="a" :value="a">{{ a }}</option>
</select>
</label>
<label class="text-xs text-fg-muted">Outcome
<select v-model="outcome" class="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm text-fg">
<option value="">All</option><option value="ok">ok</option><option value="denied">denied</option><option value="conflict">conflict</option>
</select>
</label>
<button type="button" @click="audit.load()" class="rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent">Refresh</button>
<button type="button" @click="exportCsv" :disabled="!rows.length" class="rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent disabled:opacity-50">Export CSV</button>
</div>
</div>
<p v-if="audit.status === 'loading' && !audit.items.length" class="mt-6 font-mono text-sm text-fg-muted">GET /api/audit …</p>
<div v-else class="mt-4 overflow-x-auto">
<table class="w-full min-w-[36rem] text-left text-sm">
<caption class="sr-only">Audit entries</caption>
<thead class="font-mono text-xs text-fg-muted">
<tr class="border-b border-border">
<th scope="col" class="py-2 pr-3 font-normal">time</th>
<th scope="col" class="py-2 pr-3 font-normal">actor</th>
<th scope="col" class="py-2 pr-3 font-normal">action</th>
<th scope="col" class="py-2 pr-3 font-normal">target</th>
<th scope="col" class="py-2 font-normal">outcome</th>
</tr>
</thead>
<tbody>
<tr v-for="a in rows" :key="a.id" class="border-b border-border align-top">
<td class="py-2 pr-3 font-mono text-xs text-fg-muted">{{ time(a.at) }}</td>
<td class="py-2 pr-3">{{ a.actor }} <span class="font-mono text-xs text-fg-muted">{{ a.role }}</span></td>
<td class="py-2 pr-3 font-mono text-xs">{{ a.action }}<div v-if="a.detail" class="text-fg-muted">{{ a.detail }}</div></td>
<td class="py-2 pr-3 text-xs">{{ a.target }}</td>
<td class="py-2 font-mono text-xs" :class="a.outcome === 'ok' ? 'text-accent' : a.outcome === 'denied' ? 'text-danger' : 'text-warn-fg'">
{{ a.outcome === 'ok' ? '✓ ok' : a.outcome === 'denied' ? '✗ denied' : '! ' + a.outcome }}
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="5" class="py-8 text-center text-sm text-fg-muted">Nothing yet. Edit a user, refund an order, or switch to Lena (viewer) and force a forbidden request.</td>
</tr>
</tbody>
</table>
</div>
</section>`,
});app/lab/vue-admin-rbac/app/pages/Console.ts124 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent, ref } from "vue";
import { ENDPOINTS, STATUS_TEXT, type ApiResponse, type Method } from "../../rbac";
import { useAuthStore, useBackendStore } from "../stores";
const SAMPLE_BODIES: Record<string, string> = {
"POST /api/users": '{\n "name": "Sara Iqbal",\n "email": "sara@example.com",\n "role": "viewer"\n}',
"PATCH /api/users/:id": '{\n "role": "admin"\n}',
"POST /api/orders/:id/refund": '{\n "amount": 500\n}',
"PUT /api/roles/:role": '{\n "permissions": ["users.read", "orders.read", "audit.read"]\n}',
};
const SAMPLE_PATH: Record<string, string> = { ":id": "5", ":role": "viewer" };
/**
* Talk to the mock API directly, bypassing every check in the UI.
* This is the "curl it from the console" test: the server must refuse on its own.
*/
export default defineComponent({
name: "ConsolePage",
setup() {
const backend = useBackendStore();
const auth = useAuthStore();
const presets = ENDPOINTS.map((e) => ({ id: e.method + " " + e.pattern, ...e }));
const preset = ref(presets.find((p) => p.id === "POST /api/users")!.id);
const method = ref<Method>("POST");
const path = ref("/api/users");
const body = ref(SAMPLE_BODIES["POST /api/users"]!);
const idemKey = ref("");
const sending = ref(false);
const response = ref<ApiResponse | null>(null);
const parseError = ref<string | null>(null);
function pick() {
const p = presets.find((x) => x.id === preset.value)!;
method.value = p.method;
path.value = p.pattern.replace(/:(\w+)/g, (m) => SAMPLE_PATH[m] ?? m);
body.value = SAMPLE_BODIES[p.id] ?? "";
idemKey.value = p.idempotent ? "demo-key-1" : "";
response.value = null;
}
const needed = computed(() => presets.find((x) => x.id === preset.value)?.permission ?? "none (any signed-in user)");
async function send() {
parseError.value = null;
let parsed: unknown;
if (method.value !== "GET" && body.value.trim()) {
try {
parsed = JSON.parse(body.value);
} catch (e) {
parseError.value = "Body is not valid JSON: " + (e as Error).message;
return;
}
}
sending.value = true;
// Straight to the backend: no client store, no 401 interceptor, no v-can.
response.value = await backend.request(
{ method: method.value, path: path.value, body: parsed, headers: idemKey.value ? { "Idempotency-Key": idemKey.value } : undefined },
auth.session,
auth.user?.name ?? "anonymous",
);
sending.value = false;
}
const pretty = computed(() => (response.value ? JSON.stringify(response.value.body, null, 2) : ""));
const statusText = (s: number) => STATUS_TEXT[s] ?? "";
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") send();
};
return { presets, preset, pick, method, path, body, idemKey, send, sending, response, pretty, statusText, parseError, needed, auth, onKey };
},
template: `
<section aria-labelledby="console-h" @keydown="onKey">
<h2 id="console-h" class="text-xl font-semibold">API console</h2>
<p class="mt-1 max-w-2xl text-sm text-fg-muted">
Requests go straight to the mock API as <strong class="text-fg">{{ auth.user ? auth.user.name + ' (' + auth.role + ')' : 'nobody' }}</strong>,
with none of the UI checks. Pick an endpoint your role lacks and send it: the guard answers 403 and the attempt lands in the audit log.
</p>
<form class="mt-4 space-y-3" @submit.prevent="send">
<label class="block">
<span class="text-xs text-fg-muted">Endpoint</span>
<select v-model="preset" @change="pick" class="mt-1 block w-full rounded-md border border-border bg-bg-elev px-3 py-2 font-mono text-sm">
<option v-for="p in presets" :key="p.id" :value="p.id">{{ p.id }}</option>
</select>
</label>
<p class="font-mono text-xs text-fg-muted">@RequirePermission: <span class="text-accent">{{ needed }}</span></p>
<div class="flex gap-2">
<label class="w-28 shrink-0"><span class="sr-only">Method</span>
<select v-model="method" class="w-full rounded-md border border-border bg-bg-elev px-2 py-2 font-mono text-sm">
<option>GET</option><option>POST</option><option>PATCH</option><option>PUT</option>
</select>
</label>
<label class="min-w-0 flex-1"><span class="sr-only">Path</span>
<input v-model="path" class="w-full rounded-md border border-border bg-bg-elev px-3 py-2 font-mono text-sm outline-none focus:border-accent" />
</label>
</div>
<label class="block">
<span class="text-xs text-fg-muted">Idempotency-Key header (optional)</span>
<input v-model="idemKey" placeholder="none" class="mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-2 font-mono text-sm outline-none focus:border-accent" />
</label>
<label v-if="method !== 'GET'" class="block">
<span class="text-xs text-fg-muted">JSON body</span>
<textarea v-model="body" rows="6" spellcheck="false" :aria-invalid="!!parseError"
class="mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-2 font-mono text-[13px] outline-none focus:border-accent"></textarea>
</label>
<p v-if="parseError" role="alert" class="text-sm text-danger">{{ parseError }}</p>
<button type="submit" :disabled="sending" class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90 disabled:opacity-60">
{{ sending ? 'Sending…' : 'Send' }} <kbd class="ml-1 font-mono text-xs opacity-70">Ctrl+Enter</kbd>
</button>
</form>
<div aria-live="polite" class="mt-4">
<div v-if="response" class="overflow-hidden rounded-md border border-border bg-[#0b0b0d] font-mono text-[12px]">
<div class="flex flex-wrap items-center gap-2 border-b border-[#27272a] px-3 py-2">
<span :class="response.status < 300 ? 'text-[#4ade80]' : response.status < 500 && response.status !== 403 && response.status !== 401 ? 'text-[#fbbf24]' : 'text-[#f87171]'">
{{ response.status }} {{ statusText(response.status) }}
</span>
<span class="text-[#71717a]">{{ response.trace.join(' → ') }}</span>
</div>
<pre class="max-h-72 overflow-auto p-3 text-[#d4d4d8]">{{ pretty }}</pre>
</div>
</div>
</section>`,
});app/lab/vue-admin-rbac/app/pages/Shell.ts110 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables and Pinia stores, not React hooks */
import { computed, defineComponent } from "vue";
import { DEMO_ACCOUNTS, PERMISSIONS, ROUTES, money } from "../../rbac";
import { RouterLink, useHas } from "../can";
import { useAuthStore, useBackendStore, useRouterStore } from "../stores";
export const DashboardPage = defineComponent({
name: "DashboardPage",
components: { RouterLink },
setup() {
const auth = useAuthStore();
const backend = useBackendStore();
const has = useHas();
// Dashboard tiles only show numbers the role may read.
const tiles = computed(() => {
const db = backend.db;
return [
{ label: "Active users", value: String(db.users.filter((u) => u.active).length), perm: "users.read" as const, to: "/users" },
{ label: "Orders", value: String(db.orders.length), perm: "orders.read" as const, to: "/orders" },
{ label: "Net revenue", value: money(db.orders.reduce((s, o) => s + o.total - o.refunded, 0)), perm: "orders.read" as const, to: "/orders" },
{ label: "Audit events", value: String(db.audit.length), perm: "audit.read" as const, to: "/audit" },
];
});
const guarded = ROUTES.filter((r) => r.permission);
return { auth, tiles, has, guarded, all: PERMISSIONS };
},
template: `
<section aria-labelledby="dash-h">
<h2 id="dash-h" class="text-xl font-semibold">Welcome, {{ auth.user?.name.split(' ')[0] }}</h2>
<p class="mt-1 text-sm text-fg-muted">You are signed in as <span class="font-mono text-accent">{{ auth.role }}</span> with {{ auth.permissions.length }} of {{ all.length }} permissions.</p>
<div class="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<template v-for="t in tiles" :key="t.label">
<RouterLink v-if="has(t.perm)" :to="t.to" class="rounded-lg border border-border bg-bg-elev p-4 hover:border-accent">
<div class="text-xs text-fg-muted">{{ t.label }}</div>
<div class="mt-1 font-mono text-2xl">{{ t.value }}</div>
</RouterLink>
<div v-else class="rounded-lg border border-dashed border-border p-4 text-fg-muted">
<div class="text-xs">{{ t.label }}</div>
<div class="mt-1 font-mono text-xs">needs {{ t.perm }}</div>
</div>
</template>
</div>
<h3 class="mt-8 font-mono text-sm font-semibold">Route access for your role</h3>
<p class="mt-1 text-xs text-fg-muted">Type any of these into the address bar above. Guarded routes you lack open the 403 page.</p>
<ul class="mt-3 grid gap-1 font-mono text-xs sm:grid-cols-2">
<li v-for="r in guarded" :key="r.name" class="flex items-center gap-2">
<span :class="has(r.permission) ? 'text-accent' : 'text-danger'">{{ has(r.permission) ? '✓' : '✗' }}</span>
<span>{{ r.path }}</span><span class="text-fg-muted">{{ r.permission }}</span>
</li>
</ul>
</section>`,
});
export const LoginPage = defineComponent({
name: "LoginPage",
setup() {
const auth = useAuthStore();
const router = useRouterStore();
function signIn(id: number) {
auth.loginAs(id);
const to = auth.returnTo && auth.returnTo !== "/login" ? auth.returnTo : "/";
auth.returnTo = null;
router.path = to; // replace, not push: the login page should not be in history
router.toast("Signed in. Back to " + to, "ok");
}
return { accounts: DEMO_ACCOUNTS, signIn, auth };
},
template: `
<section aria-labelledby="login-h" class="mx-auto max-w-sm py-6">
<h2 id="login-h" class="text-xl font-semibold">Sign in</h2>
<p class="mt-1 text-sm text-fg-muted">Pick a demo account. No passwords in this demo.</p>
<p v-if="auth.returnTo && auth.returnTo !== '/'" class="mt-3 rounded-md bg-warn-bg px-3 py-2 text-sm text-warn-fg">
After sign-in you will return to <code class="font-mono">{{ auth.returnTo }}</code>.
</p>
<ul class="mt-4 space-y-2">
<li v-for="a in accounts" :key="a.id">
<button type="button" @click="signIn(a.id)" class="flex w-full items-center justify-between rounded-md border border-border bg-bg-elev px-4 py-3 text-left hover:border-accent">
<span>{{ a.label }}</span><span class="font-mono text-xs text-accent">{{ a.role }}</span>
</button>
</li>
</ul>
</section>`,
});
export const ForbiddenPage = defineComponent({
name: "ForbiddenPage",
components: { RouterLink },
props: { params: { type: Object as () => Record<string, string>, required: true } },
setup(props) {
const auth = useAuthStore();
const router = useRouterStore();
const notFound = computed(() => !props.params.needed);
return { auth, router, notFound };
},
template: `
<section aria-labelledby="f-h" class="py-8 text-center" role="alert">
<p class="font-mono text-5xl font-bold" :class="notFound ? 'text-fg-muted' : 'text-danger'">{{ notFound ? 404 : 403 }}</p>
<h2 id="f-h" class="mt-2 text-xl font-semibold">{{ notFound ? 'Page not found' : 'You do not have access to this page' }}</h2>
<p v-if="!notFound" class="mx-auto mt-2 max-w-md text-sm text-fg-muted">
<code class="font-mono text-fg">{{ params.from }}</code> needs <code class="font-mono text-accent">{{ params.needed }}</code>.
Your role, <span class="font-mono">{{ auth.role }}</span>, does not have it. Switch role in the header to see this page open.
</p>
<p v-else class="mt-2 text-sm text-fg-muted">No route matches <code class="font-mono">{{ params.from }}</code>.</p>
<div class="mt-5 flex justify-center gap-2">
<button v-if="router.history.length" type="button" @click="router.back()" class="rounded-md border border-border px-4 py-2 text-sm hover:border-accent">Go back</button>
<RouterLink to="/" exact class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg">Dashboard</RouterLink>
</div>
</section>`,
});components/lab/vue/VueIsland.tsx82 lines
"use client";
import { useEffect, useRef, useState } from "react";
import type { App, Component } from "vue";
type Props = {
/** Stable loader for the Vue root component, e.g. `const load = () => import("./app/App")` at module scope. */
load: () => Promise<{ default: Component }>;
/** Accessible name for the region the Vue app renders into. */
label: string;
/** Reserve height while Vue loads so the page does not jump. */
minHeight?: string;
};
/**
* Mounts a Vue 3 + Pinia app inside a React (Next.js) page.
*
* React owns the outer <div>; Vue owns everything inside it. The full Vue build is imported
* (not the runtime-only one) because the demo components use `template` strings, which need
* the in-browser template compiler. Importing it once registers the compiler for every
* component, so the components themselves import from plain "vue".
*/
export function VueIsland({ load, label, minHeight = "32rem" }: Props) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let app: App | null = null;
let cancelled = false;
(async () => {
try {
// Vue's esm-bundler build expects these compile-time flags. next.config.ts does not define
// them (no DefinePlugin), so set them as globals before Vue loads to keep the console clean.
Object.assign(globalThis, {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
});
const [{ createApp }, { createPinia }, root] = await Promise.all([
import("vue/dist/vue.esm-bundler.js"),
import("pinia"),
load(),
]);
if (cancelled || !host.current) return;
app = createApp(root.default);
app.use(createPinia());
app.config.errorHandler = (err) => {
console.error("[vue]", err);
};
app.mount(host.current);
setState("ready");
} catch (err) {
console.error("[VueIsland]", err);
if (!cancelled) setState("error");
}
})();
return () => {
cancelled = true;
app?.unmount();
app = null;
};
}, [load]);
return (
<div className="relative" style={{ minHeight }} role="region" aria-label={label} aria-busy={state === "loading"}>
<div ref={host} />
{state === "loading" && (
<p className="absolute inset-0 grid place-items-center font-mono text-sm text-fg-muted">
<span>
<span className="text-accent">$</span> mounting Vue app…
</span>
</p>
)}
{state === "error" && (
<p role="alert" className="absolute inset-0 grid place-items-center p-6 text-center text-sm text-danger">
The Vue app failed to load. Reload the page to try again.
</p>
)}
</div>
);
}