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.
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:
- Adding a page means changing one place: add a node to
menuTree+ add one line toviewMap, and menu, routes, breadcrumbs stay in sync automatically; - Routes are naturally generated per role:
filterMenuTreeremoves nodes without permission, andbuildRouteRecordsonly builds routes for the rest—pages without permission are never registered, no "page exists but is just hidden" self-deception; - Groups are automatically preserved: when
filterMenuTreerecurses 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:
After admin login, the sidebar shows a full two-level structure:
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.
Charts follow the same idea—zero third-party dependencies, hand-written SVG, colors go through CSS variables, so charts follow theme changes:
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.
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:
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):
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:
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.
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:
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:
While a truly nonexistent address still returns 404:
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:
The system settings page puts theme, layout, and appearance together; changes take effect immediately and persist:
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.
- Permission codes were fully defined (20 codes, 5 roles) → but
UsersViewdidn't consume them; - The 403 page existed → but no path led users to it.
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)?
- Online demo: https://ui.ruanno.top/apps/admin/
- Docs site: https://ui.ruanno.top
- Component library source: https://gitee.com/ruanno-top/ruanno-ui
Demo accounts: admin / 123456 (super admin), pm / 123456 (product, for verifying button trimming).
Top 3 of 4 from juejin.cn, machine-translated. The original thread is authoritative.
Can't AI do it?
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 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.