Skip to content

Commit 59e03d1

Browse files
wn0x00claude
andcommitted
feat(template)!: modern <Access> permission system (RBAC + fine-grained)
BREAKING — replaces `<PermissionWrapper>` + `requiredPermissions` arrays with a centralised, type-safe access policy. No back-compat shim. Migration is one find-replace per call site; the auth/ folder docs the new pattern. New API in `src/auth/access.tsx`: defineAccess(userInfo) → AccessMap // pure factory useAccess() → AccessMap // hook reading TanStack cache <Access accessible fallback> // declarative gate <ProtectedRoute requireAccess={fn}> // route-level + redirect Hybrid RBAC + ACL backend shape: UserInfo { roles?: string[] // coarse, drives most flags permissions?: Record<string, string[]> // fine-grained overlay } `defineAccess` exposes two helpers (`is(...roles)`, `can(resource, action)`) that the policy author combines into named boolean flags (`canViewMonitor`, `canManageContent`, `isAdmin`, …). Pages never inspect raw roles/permissions — they only consume the booleans. `src/routes.ts` route nodes lose `requiredPermissions`/`oneOfPerm` and gain an `access?: (a: AccessMap) => boolean` predicate. `useRoute` filters the tree against `defineAccess(userInfo)` and picks a sensible default landing. Mock backend (`src/mock/user.ts`) now ships both `roles: ['admin'|'user']` and the legacy `permissions: {...}` overlay so the demo exercises both code paths. Switching role at runtime (via the dropdown in the navbar that updates `userRole` in localStorage) flips which menu items appear just like before. Migrated 3 call sites: `pages/list/search-table/index.tsx`, `pages/list/card/card-block.tsx` (×2). Removed `src/components/PermissionWrapper/` and `src/utils/authentication.ts`. Verified: - `tsc --noEmit` clean - vitest 3/3 - Playwright smoke 4/4 (login, search-table, theme cycle, 404) - CLI tests 62/63 (1 skipped, unchanged) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5c7857c commit 59e03d1

12 files changed

Lines changed: 272 additions & 251 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
55
versions follow [SemVer](https://semver.org/). The npm registry is the
66
source of truth: <https://www.npmjs.com/package/@guanzhu.me/arco-cli>.
77

8+
## [0.13.0] — 2026-05-21
9+
- **Feat (breaking, template)** Modern `<Access>` permission system. Replaces the previous `<PermissionWrapper>` + `requiredPermissions` array convention with a hybrid RBAC + fine-grained model that mirrors ant-design-pro's `<Access>` API:
10+
- **`src/auth/access.tsx`**`defineAccess(userInfo)` factory + `useAccess()` hook + `<Access accessible fallback>` component. All boolean flags consumed by the UI live in one place; backend shape changes are a one-file edit.
11+
- Backend shape: `userInfo` now exposes both **`roles: string[]`** (coarse RBAC) and **`permissions?: Record<string, string[]>`** (optional fine-grained overlay). The frontend `defineAccess` uses both — most flags resolve from roles alone, occasional `can(resource, action)` checks cover "give this user one extra capability outside their role" cases.
12+
- **`src/routes.ts`**: route nodes carry an `access?: (a: AccessMap) => boolean` predicate instead of `requiredPermissions: [{ resource, actions }]`. Cleaner type-checked references to central flags.
13+
- **`src/auth/ProtectedRoute.tsx`** gains a `requireAccess` prop for route-level gating with redirect to `/exception/403` (override via `denyRedirect`).
14+
- Migrated 3 call sites in `pages/list/{search-table,card}` to use `<Access accessible={access.canX}>`.
15+
- **Removed** `src/components/PermissionWrapper/` and `src/utils/authentication.ts` — no backward-compat shim.
16+
817
## [0.12.1] — 2026-05-21
918
- **Security** `pages/login/form.tsx` no longer JSON.stringifies the password into localStorage when "Remember me" is checked. Only the username is persisted, and is rehydrated into the form on next visit (password field stays blank). The locale key + label changed from "Remember password" → "Remember me" / "记住账号".
1019
- **Fix** `/login` no longer force-sets `arco-theme=light` on mount, so dark-mode users bounced to the login page (e.g. after a 401) don't see a theme flash.
@@ -161,6 +170,7 @@ source of truth: <https://www.npmjs.com/package/@guanzhu.me/arco-cli>.
161170
## [0.1.0] — 2026-05
162171
- **Initial fork** of the abandoned `arco-cli` under `@guanzhu.me/arco-cli`. Preserves the original interactive template selection and `init` flow; modernises packaging, types, and engines.
163172

173+
[0.13.0]: https://github.com/wn0x00/arco-cli/releases/tag/v0.13.0
164174
[0.12.1]: https://github.com/wn0x00/arco-cli/releases/tag/v0.12.1
165175
[0.12.0]: https://github.com/wn0x00/arco-cli/releases/tag/v0.12.0
166176
[0.11.2]: https://github.com/wn0x00/arco-cli/releases/tag/v0.11.2

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@guanzhu.me/arco-cli",
3-
"version": "0.12.1",
3+
"version": "0.13.0",
44
"description": "Scaffold a modern Arco Design admin (Vite 7 + React 18 + strict TS) — community-maintained CLI with a bundled, modernized arco-design-pro starter.",
55
"keywords": [
66
"arco",

templates/arco-pro-recommend-full/src/api/user.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ export interface UserInfo {
1717
phoneNumber?: string;
1818
accountId?: string;
1919
registrationTime?: string;
20+
/**
21+
* Role keys assigned to the user (RBAC). The frontend's
22+
* `defineAccess` (`src/auth/access.ts`) computes most permission
23+
* flags by checking membership in this list.
24+
*/
25+
roles?: string[];
26+
/**
27+
* Optional fine-grained permission overlay — `{ resource: actions[] }`.
28+
* Used for "give *this* user one extra capability outside their
29+
* role" cases. Most apps will leave this empty and rely on `roles`.
30+
*/
2031
permissions?: Record<string, string[]>;
2132
}
2233

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,49 @@
11
import { Navigate, useLocation } from 'react-router-dom';
22
import { useAuth } from './useAuth';
3+
import { useAccess, type AccessMap } from './access';
4+
5+
interface ProtectedRouteProps {
6+
children: React.ReactNode;
7+
/**
8+
* Optional access check on top of authentication. Receives the flags
9+
* from `useAccess()` and returns `true` to allow rendering. When
10+
* false, the user is sent to `/exception/403` (override via
11+
* `denyRedirect`).
12+
*
13+
* ```tsx
14+
* <ProtectedRoute requireAccess={(a) => a.isAdmin}>
15+
* <AdminPage />
16+
* </ProtectedRoute>
17+
* ```
18+
*/
19+
requireAccess?: (access: AccessMap) => boolean;
20+
/** Where to send the user when `requireAccess` returns false. */
21+
denyRedirect?: string;
22+
}
323

424
/**
5-
* Gate the rendered children behind authentication. Unauthenticated
6-
* users get bounced to `/login` with the original destination tucked
7-
* into history state so the login form can route them back on success
8-
* (consumer can read `location.state.from` in the post-login redirect).
25+
* Two-layer gate: authentication first, then optional access policy.
26+
*
27+
* - Unauthenticated visitors get bounced to `/login` with the original
28+
* destination tucked into `location.state.from` so the login form
29+
* can route them back on success.
30+
* - Authenticated users without the required access land on a 403 —
31+
* the standard "you are logged in but cannot see this" signal.
932
*/
10-
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
33+
export function ProtectedRoute({
34+
children,
35+
requireAccess,
36+
denyRedirect = '/exception/403',
37+
}: ProtectedRouteProps) {
1138
const { isAuthenticated } = useAuth();
39+
const access = useAccess();
1240
const location = useLocation();
41+
1342
if (!isAuthenticated) {
1443
return <Navigate to="/login" replace state={{ from: location }} />;
1544
}
45+
if (requireAccess && !requireAccess(access)) {
46+
return <Navigate to={denyRedirect} replace />;
47+
}
1648
return <>{children}</>;
1749
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useMemo, type ReactNode } from 'react';
2+
import { useUserInfoQuery, type UserInfo } from '@/api/user';
3+
4+
/**
5+
* Central permission policy.
6+
*
7+
* The backend ships **both** structures on `userInfo`:
8+
* - `roles: string[]` — coarse, RBAC-style
9+
* - `permissions: Record<string, string[]>` — optional fine-grained overlay
10+
*
11+
* Most flags are computed from roles alone. A handful of "give *this*
12+
* user a specific extra capability" overrides come from `permissions`.
13+
* Pages and components only consume the resolved booleans returned by
14+
* `defineAccess` / `useAccess` — they never touch the raw structures.
15+
*
16+
* When the backend renames a role or restructures permissions, the
17+
* fix is one edit to this file. When a new feature needs a gate, add
18+
* a flag here and consume it via `useAccess()` from any component.
19+
*
20+
* `userInfo` is `undefined` while the first fetch is in flight — every
21+
* flag defaults to `false` (fail closed) so sensitive UI never flashes
22+
* visible before permissions resolve.
23+
*/
24+
export function defineAccess(userInfo: UserInfo | undefined) {
25+
const roles = userInfo?.roles ?? [];
26+
const perms = userInfo?.permissions ?? {};
27+
28+
/** Has at least one of the listed roles. */
29+
const is = (...roleKeys: string[]): boolean =>
30+
roleKeys.some((r) => roles.includes(r));
31+
32+
/** Has `action` on `resource` (or the wildcard `'*'`) via permissions overlay. */
33+
const can = (resource: string, action: string): boolean => {
34+
const granted = perms[resource];
35+
if (!granted) return false;
36+
return granted.includes('*') || granted.includes(action);
37+
};
38+
39+
return {
40+
// ─── Role-based (mirrors the mock's 'admin' / 'user' seed) ─────
41+
isAdmin: is('admin'),
42+
43+
// ─── Feature gates (role OR explicit permission grant) ─────────
44+
// Most apps lean almost entirely on roles and sprinkle `can(...)`
45+
// overrides only when a single user needs a capability outside
46+
// their role.
47+
canViewMonitor: is('admin') || can('menu.dashboard.monitor', 'write'),
48+
canViewAnalytics:
49+
is('admin', 'analyst') || can('menu.visualization.dataAnalysis', 'read'),
50+
canViewMultiDim:
51+
is('admin') ||
52+
can('menu.visualization.dataAnalysis', 'write') ||
53+
can('menu.visualization.multiDimensionDataAnalysis', 'write'),
54+
canUseGroupForm: is('admin') || can('menu.form.group', 'write'),
55+
canUseStepForm: is('admin', 'user') || can('menu.form.step', 'read'),
56+
57+
// ─── Element-level (used by buttons inside pages) ──────────────
58+
canViewLists:
59+
is('admin', 'user', 'editor') ||
60+
can('menu.list.searchTable', 'read') ||
61+
can('menu.list.cardList', 'read'),
62+
canManageContent: is('admin', 'editor') || can('menu.list.searchTable', 'write'),
63+
};
64+
}
65+
66+
export type AccessMap = ReturnType<typeof defineAccess>;
67+
68+
/**
69+
* Pre-computed permission flags for the current user. Pages read these
70+
* via `access.canX` and pass them to `<Access>` or use them directly
71+
* for non-rendering logic (disabled state, tab visibility, etc.).
72+
*
73+
* The underlying `useUserInfoQuery` is cached by TanStack Query so
74+
* calling `useAccess()` in many components is cheap — they all share
75+
* the one userInfo fetch.
76+
*/
77+
export function useAccess(): AccessMap {
78+
const { data: userInfo } = useUserInfoQuery();
79+
return useMemo(() => defineAccess(userInfo), [userInfo]);
80+
}
81+
82+
interface AccessProps {
83+
/** Boolean flag, usually `access.canX` from `useAccess()`. */
84+
accessible: boolean;
85+
/**
86+
* Rendered when `accessible` is false. Default: `null` (hide).
87+
* Pass a disabled-but-visible variant (Tooltip + disabled Button)
88+
* when "no permission" needs to be discoverable rather than silent.
89+
*/
90+
fallback?: ReactNode;
91+
children: ReactNode;
92+
}
93+
94+
/**
95+
* Declarative permission gate. Mirrors ant-design-pro's `<Access>` API
96+
* so the muscle memory transfers.
97+
*
98+
* ```tsx
99+
* const access = useAccess();
100+
* return (
101+
* <Access accessible={access.canEditUser}>
102+
* <Button onClick={onEdit}>Edit</Button>
103+
* </Access>
104+
* );
105+
* ```
106+
*
107+
* For route-level gating use `<ProtectedRoute requireAccess={…}>`
108+
* instead — it can redirect to `/exception/403` rather than just
109+
* hiding the children.
110+
*/
111+
export function Access({ accessible, fallback = null, children }: AccessProps) {
112+
return <>{accessible ? children : fallback}</>;
113+
}

templates/arco-pro-recommend-full/src/components/PermissionWrapper/index.tsx

Lines changed: 0 additions & 38 deletions
This file was deleted.

templates/arco-pro-recommend-full/src/layout/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function PageLayout() {
6363
bindNavigator((path, opts) => navigate(path, opts));
6464
}, [navigate]);
6565

66-
const [routes, defaultRoute] = useRoute(userInfo?.permissions);
66+
const [routes, defaultRoute] = useRoute(userInfo);
6767
const defaultSelectedKeys = [currentComponent || defaultRoute];
6868
const paths = (currentComponent || defaultRoute).split('/');
6969
const defaultOpenKeys = paths.slice(0, paths.length - 1);

templates/arco-pro-recommend-full/src/mock/user.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import { http, HttpResponse } from 'msw';
22
import { random } from './random';
33
import { generatePermission } from '@/routes';
44

5+
// Demo permission seed. A real backend would derive these from the
6+
// user's DB record + their org's role definitions. Here we hardcode
7+
// two profiles so the access.ts examples have something to read.
8+
const ROLE_GRANTS: Record<string, string[]> = {
9+
admin: ['admin'],
10+
user: ['user'],
11+
};
12+
513
const userInfo = (userRole: string) => ({
614
name: 'admin',
715
avatar: 'https://avatars.githubusercontent.com/u/8186665?s=200&v=4',
@@ -18,6 +26,9 @@ const userInfo = (userRole: string) => ({
1826
phoneNumber: `177${random.fromCharClass('digit', 6).replace(/./g, '*')}${random.fromCharClass('digit', 2)}`,
1927
accountId: `${random.fromCharClass('lower', 4)}-${random.fromCharClass('digit', 8)}`,
2028
registrationTime: random.datetime(),
29+
// Hybrid shape: coarse RBAC roles + optional fine-grained overlay.
30+
// Frontend's `defineAccess` consumes both.
31+
roles: ROLE_GRANTS[userRole] ?? ['user'],
2132
permissions: generatePermission(userRole),
2233
});
2334

templates/arco-pro-recommend-full/src/pages/list/card/card-block.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
IconCloseCircleFill,
2222
IconMore,
2323
} from '@arco-design/web-react/icon';
24-
import PermissionWrapper from '@/components/PermissionWrapper';
24+
import { Access, useAccess } from '@/auth/access';
2525
import useLocale from '@/utils/useLocale';
2626
import locale from './locale';
2727
import { QualityInspection, BasicCard } from './interface';
@@ -50,6 +50,7 @@ function CardBlock(props: CardBlockType) {
5050
const [loading, setLoading] = useState(props.loading);
5151

5252
const t = useLocale(locale);
53+
const access = useAccess();
5354
const changeStatus = async () => {
5455
setLoading(true);
5556
await new Promise((resolve) =>
@@ -85,27 +86,19 @@ function CardBlock(props: CardBlockType) {
8586
if (type === 'quality') {
8687
return (
8788
<>
88-
<PermissionWrapper
89-
requiredPermissions={[
90-
{ resource: /^menu.list.*/, actions: ['read'] },
91-
]}
92-
>
89+
<Access accessible={access.canViewLists}>
9390
<Button
9491
type="primary"
9592
style={{ marginLeft: '12px' }}
9693
loading={loading}
9794
>
9895
{t['cardList.options.qualityInspection']}
9996
</Button>
100-
</PermissionWrapper>
97+
</Access>
10198

102-
<PermissionWrapper
103-
requiredPermissions={[
104-
{ resource: /^menu.list.*/, actions: ['write'] },
105-
]}
106-
>
99+
<Access accessible={access.canManageContent}>
107100
<Button loading={loading}>{t['cardList.options.remove']}</Button>
108-
</PermissionWrapper>
101+
</Access>
109102
</>
110103
);
111104
}

templates/arco-pro-recommend-full/src/pages/list/search-table/index.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
Space,
88
Typography,
99
} from '@arco-design/web-react';
10-
import PermissionWrapper from '@/components/PermissionWrapper';
10+
import { Access, useAccess } from '@/auth/access';
1111
import { IconDownload, IconPlus } from '@arco-design/web-react/icon';
1212
import { useSearchTableQuery } from '@/api/list';
1313
import useLocale from '@/utils/useLocale';
@@ -24,6 +24,7 @@ export const Status = ['已上线', '未上线'];
2424

2525
function SearchTable() {
2626
const t = useLocale(locale);
27+
const access = useAccess();
2728

2829
const tableCallback = async (
2930
_record: Record<string, unknown>,
@@ -75,11 +76,7 @@ function SearchTable() {
7576
<Card>
7677
<Title heading={6}>{t['menu.list.searchTable']}</Title>
7778
<SearchForm onSearch={handleSearch} />
78-
<PermissionWrapper
79-
requiredPermissions={[
80-
{ resource: 'menu.list.searchTable', actions: ['write'] },
81-
]}
82-
>
79+
<Access accessible={access.canManageContent}>
8380
<div className={styles['button-group']}>
8481
<Space>
8582
<Button type="primary" icon={<IconPlus />}>
@@ -93,7 +90,7 @@ function SearchTable() {
9390
</Button>
9491
</Space>
9592
</div>
96-
</PermissionWrapper>
93+
</Access>
9794
<Table
9895
rowKey="id"
9996
loading={loading}

0 commit comments

Comments
 (0)