跪拜 Guibai
← Back to the summary

The Two Permission Bugs That Screenshots Caught: Button-Level Trimming and a 403 Dressed as 404

This is the second post in the "ruanno-ui in Practice" series. The first covered how I had AI maintain a 97-component Vue 3 component library; this one shifts perspective, actually assembling that library into a deliverable admin backend: dynamic permission routes + button-level permission trimming + multiple tabs + theme system, and also lays out two real defects that screenshots exposed in the process.

All screenshots in this post come from a running real application (1440 × 900, logged-in state using the actual store and route guards), not design mockups.


0. Bottom line first: 5 things done, 2 pitfalls hit

An admin backend's skeleton is really only five things: login state → role → menu tree → dynamic routes → button-level permissions. Sounds simple, but once it actually runs, the details decide the user's feel:

Step Key design One wrong thing that breaks it
Login state token + user info + roles + permissions all stored in localStorage Guard immediately re-resolves the same path after login, otherwise the catch-all always matches
Permission model Role codes (admin/ops/dev/pm/guest) → set of permission codes Menus filtered but buttons not filtered, equivalent to doing nothing
Menu + route Same menuTree produces both sidebar menu and router.addRoute Two separate definitions, eventually inconsistent
Multiple tabs Tab equals cache list (keep-alive include) Wrong page jump when closing current tab
Theme Only change data-rn-theme Hard-coded colors in components, theme changes turn into a mess

Below, each item is broken down with corresponding screenshots.


1. Login page: 7 design templates, one shared logic

The login page is the most underestimated part of an admin backend. It does two things: block people who shouldn't get in, and give users an immediate judgment of "does this system look reliable."

I made 7 visual schemes for the login page (Aurora / Neon / Floating / Split / Blueprint / Brutalist / Ink), with a switcher at the bottom right, and the choice saved to localStorage. The key point: all 7 templates share the same useLogin(), none of them implements its own submit logic.

Aurora-style login: left brand area, right form area, bottom right is the 7-design switcher (currently highlighting Aurora)

Blueprint-style login: grid blueprint background, same form logic, entirely different layout

Ink-style login: dark paper-ink texture, still the same useLogin()

useLogin() only does four things:

export function useLogin() {
  const username = ref("admin");          // prefilled for demo convenience
  const password = ref("123456");
  const captchaCode = ref("");            // graphic captcha: component emits the correct answer itself
  const captchaInput = ref("");
  const captchaPassed = ref(false);       // whether slider/click captcha has passed
  const loading = ref(false);
  const error = ref("");

  function captchaValid(): boolean {
    if (captchaPassed.value) return true;                        // interactive captcha passed
    return !!captchaCode.value &&
      captchaInput.value.toLowerCase() === captchaCode.value.toLowerCase();
  }

  async function submit() {
    if (!username.value || !password.value) return (error.value = t("login.empty"));
    if (!captchaValid()) {                                        // note: if captcha fails, return immediately
      error.value = t("login.captchaFail");
      captchaInput.value = "";
      return;
    }
    loading.value = true;
    const res = await login({ username: username.value, password: password.value });
    userStore.setToken(res.token);
    userStore.setUserInfo(res.userInfo);
    userStore.setRoles(res.roles);              // ← role decides menu
    userStore.setPermissions(res.permissions);  // ← permission codes decide buttons
    router.push((route.query.redirect as string) || "/");
  }
  // ...
}

There's an easily ignored semantic here: roles and permission codes are two different things. Roles decide "which menus you can see," permission codes decide "which buttons you can click." Many people merge these, and the result is either too coarse menus or too fine buttons—neither works.

The redirect in router.push(route.query.redirect || "/") is the target address before login—when a user is blocked while directly accessing an inner page, after login they should go back to where they were, not to the homepage.


2. Menu and routing: one tree, two products

This is the thing I insisted on most in this admin: menu, routes, and breadcrumbs must come from the same data.

router/menus.ts maintains only one menuTree, each node carries path, component (string key), meta.titleKey, meta.icon, meta.roles, meta.keepAlive, meta.affix:

export const menuTree: MenuNode[] = [
  { name: "dashboard", path: "/dashboard", component: "dashboard",
    meta: { titleKey: "menu.dashboard", icon: "home", keepAlive: true, affix: true } },
  {
    name: "system", path: "/system", meta: { titleKey: "menu.system", icon: "settings" },
    children: [
      { name: "system-users", path: "/system/users", component: "users",
        meta: { titleKey: "menu.users", icon: "user", roles: ["admin", "ops", "pm"], keepAlive: true } },
      { name: "system-roles", path: "/system/roles", component: "roles",
        meta: { titleKey: "menu.roles", icon: "lock", roles: ["admin"], keepAlive: true } },
      // ...
    ],
  },
  // ...
];

Then one function produces both products:

export function buildMenuRoutes(roles: string[]) {
  const menus = filterMenuTree(menuTree, roles);           // sidebar menu (already trimmed by role)
  return { routes: buildRouteRecords(menus), menus };      // dynamic child routes + menu
}

buildRouteRecords converts leaf nodes into RouteRecordRaw, and the component string is lazy-loaded via viewMap:

const viewMap: Record<string, () => Promise<Component>> = {
  dashboard: () => import("@/views/DashboardView.vue"),
  users: () => import("@/views/UsersView.vue"),
  // ...
};

The benefits are concrete:

  1. Adding a page means changing one place: add a node to menuTree + add one line to viewMap, and menu, routes, breadcrumbs stay in sync automatically;
  2. Routes are naturally generated per role: filterMenuTree removes nodes without permission, and buildRouteRecords only builds routes for the rest—pages without permission are never registered, no "page exists but is just hidden" self-deception;
  3. Groups are automatically preserved: when filterMenuTree recurses and all children are filtered out, the parent group also doesn't show, so there's no empty "System Management."

After admin login, the dashboard is what this routing produces—sidebar, breadcrumbs, tabs, and chart cards all come from the same menu data:

Admin dashboard: four stat cards (total users/today's orders/today's revenue/conversion rate), revenue trend line, project progress, order bar chart, channel donut chart, and recent activity

After admin login, the sidebar shows a full two-level structure:

Admin sidebar: System Management expands into User Management/Role Management/Menu Management/Department Management/Dictionary Management/Operation Log, with Component Examples also expanded


3. Route guard: the "inevitable 404 after login" pit

The easiest place to trip up dynamic routing is in the guard. After addRoute, the current navigation is still on the old resolved result, so you must re-enter once:

router.beforeEach(async (to) => {
  const userStore = useUserStore();
  const permissionStore = usePermissionStore();

  if (!userStore.isLoggedIn && to.name !== "login") {
    return { path: "/login", query: { redirect: to.fullPath } };
  }
  if (to.name === "login" && userStore.isLoggedIn) {
    return { path: "/" };
  }
  if (userStore.isLoggedIn && !permissionStore.routesGenerated) {
    const { routes, menus } = buildMenuRoutes(userStore.roles);
    routes.forEach((r) => router.addRoute("layout", r));
    permissionStore.setMenuRoutes(menus);
    permissionStore.setGenerated();
    // Key: don't spread `to`, otherwise `name` is still the catch-all's not-found and we get 404 again
    return { path: to.fullPath, replace: true };
  }
  // Logged in but route not matched → distinguish "wrong address" from "no permission"
  if (userStore.isLoggedIn && to.name === "not-found" && permissionStore.knowsPath(to.path)) {
    return { path: "/403", replace: true };
  }
  return true;
});

The first pit is painful if written wrong: return { ...to } looks more "conservative," but by then to.name has already resolved to the catch-all route's not-found, so re-entering still hits 404. You must explicitly construct { path: to.fullPath } so the router re-matches by path.

When refreshing the page, routesGenerated is an in-memory state that resets to false, and the guard rebuilds the dynamic routes—so after F5 the menu doesn't disappear.

Further down are the component example pages' capability demos, picking two that people most often ask "did you import a third-party library" about: the advanced table and advanced form.

Advanced table: fixed columns, sorting, filter, expandable rows, tree data, 10,000-row virtual scroll—all handled by ruanno-ui's Table component alone

Advanced form: three-step wizard (basic info / member info / confirm submit), supports dynamic add/remove of form items

Charts follow the same idea—zero third-party dependencies, hand-written SVG, colors go through CSS variables, so charts follow theme changes:

Chart page in examples: bar, stacked bar, line, donut, scatter, heatmap, relation graph—all recolor with the theme


4. Menu management: making "menu is route" visible

Since the menu is the single source of truth for routes, it should be visible and understandable in the UI, not hidden in code.

Menu management page: each row shows menu name, type (directory/menu), route path (e.g., /system/users), sort order and status, with subtitle reading "menu is the data source for runtime routes"

This page's purpose isn't really to let people add or delete menus (that requires backend cooperation), but to make "menu = route" visible to the team. A newcomer seeing the chain /system/users → users → UsersView.vue is faster than reading ten pages of documentation.


5. Roles and permission codes: the first real bug screenshots caught

The role management page is the visualization of the permission model:

Role management: 5 roles, permission column directly shows permission code counts—super admin 20, ops 7, dev 5, product 5, guest 0

When editing a role, you can tick specific permission codes. Left side shows "unassigned permissions 0/13", right side "assigned permissions 0/7" (screenshot shows the ops role's edit state):

Editing ops role: permission codes grouped by business (add/edit/delete users, roles, menus, departments, dictionaries, articles...), clickable to assign

Permission codes are defined centrally in enums/permission.ts, and the role → permission code mapping lives in the same place:

export const PERMISSION = {
  DASHBOARD_EXPORT: "dashboard:export",
  USER_CREATE: "system:user:create",
  USER_UPDATE: "system:user:update",
  USER_DELETE: "system:user:delete",
  // ...
} as const;

export const ROLE_PERMISSIONS: Record<string, string[]> = {
  admin: ALL_PERMISSIONS,
  ops: [PERMISSION.DASHBOARD_EXPORT, PERMISSION.USER_CREATE, PERMISSION.USER_UPDATE, /* ... */],
  dev: [PERMISSION.USER_UPDATE, PERMISSION.DICT_CREATE, /* ... */],
  pm:  [PERMISSION.USER_CREATE, PERMISSION.USER_UPDATE, PERMISSION.ARTICLE_CREATE, /* ... */],
  guest: [],
};

5.1 Menu right, buttons wrong

Logging in as the product role (pm), the sidebar is correctly trimmed—under "System Management" only "User Management" remains; roles, menus, departments, dictionaries, logs are all gone:

Product role (Ruan PM) sidebar: only User Management remains under System Management, other admin items filtered out

But opening the User Management page, every row still has a "Delete" button—even though the product role has no system:user:delete permission. The screenshot below is from the already-published version 0.2.6: the row triad "View / Edit / Delete" is fully present.

Product role entering user management: table rows still render the Delete button (actual screenshot from published version 0.2.6)

The reason is straightforward: the action buttons in UsersView.vue never connected to the permission directive.

<!-- Before fix: everyone can see -->
<Button size="sm" variant="ghost" @click.stop="openEdit(asUser(row))">{{ t("common.edit") }}</Button>
<Button size="sm" variant="ghost" @click.stop="confirmDelete(asUser(row))">{{ t("common.delete") }}</Button>

Investigation showed only RoleView, MenuView, DeptView, DictView, LogView, ArticleView used v-permission—UsersView missed it. And this type of problem most typically looks like: menu trimmed finely, buttons not managed at all—because menus are "visible," while buttons are "no one specifically clicks them."

The fix is to add the permission codes (including create and batch delete):

<Button v-permission="PERMISSION.USER_CREATE" @click="openCreate">New User</Button>

<Button v-permission="PERMISSION.USER_DELETE" size="sm" variant="danger" @click="confirmBatchDelete">Batch Delete</Button>

<template #actions="{ row }">
  <Button size="sm" variant="ghost" @click.stop="viewUser(asUser(row))">View</Button>
  <Button v-permission="PERMISSION.USER_UPDATE" size="sm" variant="ghost" @click.stop="openEdit(asUser(row))">Edit</Button>
  <Button v-permission="PERMISSION.USER_DELETE" size="sm" variant="ghost" @click.stop="confirmDelete(asUser(row))">Delete</Button>
</template>

The directive itself is short; without permission it removes the element from the DOM (not display:none, to avoid it being clicked by scripts):

function apply(el: HTMLElement, binding: DirectiveBinding<PermissionValue>) {
  const codes = Array.isArray(binding.value) ? binding.value : [binding.value!];
  const allowed = codes.some((c) => usePermissionStore().hasPermission(c));
  if (!allowed && el.parentNode) el.parentNode.removeChild(el);
}

After the fix, on the same page with the same product role, rows only show "View / Edit" and "Delete" disappears:

Same product role, same page, after fix rows only have View/Edit, Delete removed from DOM

The admin view is unaffected and still has all three actions—this is also counter-evidence that the trimming wasn't overdone.

One-sentence principle: menu filtering solves "can't see," permission directive solves "can't click." Doing only the former is like putting a door lock on the wall but no door.


6. Unauthorized access should return 403, not 404

The second pit is sneakier. Because pages without permission aren't registered as routes, when a user types /system/roles manually or clicks from a bookmark, it hits the catch-all—and thus lands on 404.

The user sees "page doesn't exist," and the first thought is "is this feature deleted / link broken?" rather than "I don't have permission." In a real project, this becomes a pile of useless support tickets.

The fix: check whether this path exists in the full menu set—exists but current role lacks it → 403; doesn't exist → 404.

// stores/permission.ts
function collectPaths(nodes: MenuNode[], out = new Set<string>()) {
  for (const node of nodes) {
    if (node.children) collectPaths(node.children, out);
    else if (node.path) out.add(node.path);
  }
  return out;
}
const ALL_MENU_PATHS = collectPaths(menuTree);   // full path set without role filtering

function knowsPath(path: string): boolean {
  const normalized = path.replace(/\/+$/, "") || "/";
  return ALL_MENU_PATHS.has(normalized);
}

Together with the four lines in the guard from Section 3, the effect splits. Product role visiting /system/roles:

Product role accessing unauthorized /system/roles: falls to 403 "Sorry, you don't have permission to access this page"

While a truly nonexistent address still returns 404:

Visiting nonexistent /not-exist-page: still 404 "The page you visited does not exist"

The two pages look similar, but the semantics are completely different—permission problems shouldn't masquerade as "page gone."


7. Multi-tabs + keep-alive: the cache list is the tab itself

One of the two things admin users hate most is "switch away and come back, table filter conditions are all gone." Multi-tabs + keep-alive solves it, but there's a pit in implementation: the include list of keep-alive can't be hand-written; it must equal "currently open tabs that declared keepAlive."

// stores/tabs.ts
const cachedViews = computed(() =>
  tabs.value.filter((t) => t.keepAlive).map((t) => t.name),
);

When addTab runs, it brings in route.meta.keepAlive, and the layout binds directly:

<keep-alive :include="tabsStore.cachedViews">
  <RouterView />
</keep-alive>

The tab's affix comes from meta.affix—the dashboard is set as a fixed page, so closeAll / closeOthers both keep it:

function closeAll(): string | null {
  tabs.value = tabs.value.filter((t) => t.affix);   // keep fixed pages
  const first = tabs.value[0];
  activeName.value = first?.name ?? "";
  return first?.path ?? null;                        // return the path to jump to
}

closeTab returns "where to go next," and the layout does the router.push—keeping the jump decision outside the store, avoiding a router dependency in the store. Closing the current tab jumps to the next of the original position (Math.min(idx, length - 1)); closing the last one jumps to the fixed page, no white screen.


8. Theme: one attribute switch, the whole UI follows

Five themes (Classic Blue / Light Ink / Dark Ink / Light Glow / Dark Glow) switch via <html data-rn-theme="..."> with no if (dark) branches in components:

function setTheme(name: string) {
  setRnTheme(name);                          // internally only does documentElement.dataset.rnTheme = name
  localStorage.setItem("ruanno-admin-theme", name);
}

This works cleanly because all colors come from --rn-* tokens, and theme files only write the differences from the default. In dark mode, charts, tabs, skeletons, watermarks all auto-adapt:

Dark Glow theme dashboard: full dark page, charts, card borders, status colors switch together, no single white patch "forgot to recolor"

The system settings page puts theme, layout, and appearance together; changes take effect immediately and persist:

System settings: theme scheme, primary color, layout mode (sidebar nav/top nav), sidebar width, multi-tabs, breadcrumb, fixed header, UI radius, gray mode, compact mode, watermark, footer

A note on the watermark: it wraps the whole content area in the layout, the text uses the currently logged-in username, as an anti-screenshot measure for internal systems, cost almost zero.


9. Turning the permission model into a table

With all five items landed, summarizing into a table is clearer:

Level Data source Interception point Failure behavior
Not logged in userStore.isLoggedIn Global guard Redirect to login, record redirect
Menu visibility filterMenuTree(menuTree, roles) Render sidebar Menu item doesn't appear
Route reachability buildRouteRecords() only registers allowed router.addRoute 403 (path exists) / 404 (doesn't exist)
Button usability v-permission="PERMISSION.xxx" Directive mounted/updated Element removed from DOM
API authorization Backend Server Frontend doesn't do this layer, and shouldn't pretend it does

The last row must be emphasized: frontend permissions are UX, not security. v-permission hides the button, but a user can still manually send requests via devtools. Real authorization must be validated on the server by permission codes; the frontend layer just prevents users from discovering they can't do something after clicking.


10. Retrospective: what did this actually expose?

Both bugs share one shape: the "declaration layer" was right, the "execution layer" missed a link.

This kind of defect is hard to spot by reading code, because every part alone looks correct; documentation can't catch it either, because documentation describes "how it should be." It was forced out by real screenshots: log in as product role → click into user management → screenshot, and you see the "Delete" button that shouldn't exist; manually type /system/roles → screenshot, and you see 404 where 403 should be.

So I made "walk through every role in a real browser, screenshot every step, and assert on the DOM" a fixed part of this project's process. Screenshots aren't just illustrations for the article; they are acceptance evidence—a screenshot showing a "Delete" button is far more convincing than ten lines of "button-level permissions implemented."

If you're building an admin too, follow this acceptance sequence: switch roles to check menus → manually type unauthorized addresses to see 403 → check buttons row by row → close tabs to see cache → switch themes to look for white patches. After those five steps, the permission part is basically truly done.


Want to try this admin directly (switch roles, themes, tabs)?

Demo accounts: admin / 123456 (super admin), pm / 123456 (product, for verifying button trimming).

Comments

Top 3 of 4 from juejin.cn, machine-translated. The original thread is authoritative.

Aaron732

Can't AI do it?

ruanno_top

It can write it, but writing it correctly is another matter. These two bugs are exactly what AI wrote itself, and even with tests all green they weren't caught; only real-device role-switching screenshots exposed them. So now I treat 'role-switch login + DOM-assertion screenshots' as an acceptance step, not relying on its self-check.

ruanno_top

It can write it, but writing it correctly is another matter. These two bugs are exactly what AI wrote itself, and even with tests all green they weren't caught; only real-device role-switching screenshots exposed them. So now I treat 'role-switch login + DOM-assertion screenshots' as an acceptance step, not relying on its self-check.

前端小豆

It's 2026; posting this now is meaningless.