fix: align permission navigation and page access
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
findFirstAccessiblePath,
|
||||
getRequiredPermission,
|
||||
canAccessPath,
|
||||
} from './permission-navigation';
|
||||
|
||||
describe('permission navigation', () => {
|
||||
it('does not default to dashboard when dashboard permission is absent', () => {
|
||||
const permissions = ['class:view', 'schedule:view'];
|
||||
expect(findFirstAccessiblePath(permissions)).toBe('/classes');
|
||||
});
|
||||
|
||||
it('uses dashboard when it is the first accessible page', () => {
|
||||
expect(findFirstAccessiblePath(['dashboard:view', 'student:view'])).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('returns null when the user has no page permissions', () => {
|
||||
expect(findFirstAccessiblePath(['profile:view'])).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps route permission lookup aligned for nested detail routes', () => {
|
||||
expect(getRequiredPermission('/classes/12')).toBe('class:view');
|
||||
expect(getRequiredPermission('/students/8/profile')).toBe('student:view');
|
||||
expect(canAccessPath('/ai-config', ['ai:config:read'])).toBe(true);
|
||||
expect(canAccessPath('/ai-config', ['integration:read'])).toBe(false);
|
||||
});
|
||||
});
|
||||
53
apps/admin/src/auth/permission-navigation.ts
Normal file
53
apps/admin/src/auth/permission-navigation.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface PermissionPage {
|
||||
path: string;
|
||||
permission: string;
|
||||
matches?: (pathname: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for page-level navigation permissions.
|
||||
* Order also defines the landing-page priority after login.
|
||||
*/
|
||||
export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/dashboard', permission: 'dashboard:view' },
|
||||
{ path: '/room-visual', permission: 'room:view' },
|
||||
{ path: '/rooms', permission: 'room:view' },
|
||||
{ path: '/occupancies', permission: 'occupancy:view' },
|
||||
{ path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) },
|
||||
{ path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) },
|
||||
{ path: '/attendance', permission: 'attendance:view' },
|
||||
{ path: '/schedules', permission: 'schedule:view' },
|
||||
{ path: '/teacher-workspace', permission: 'class:view' },
|
||||
{ path: '/classroom-schedule', permission: 'rental:view' },
|
||||
{ path: '/classrooms', permission: 'classroom:view' },
|
||||
{ path: '/classroom-rentals', permission: 'rental:view' },
|
||||
{ path: '/organizations', permission: 'organization:view' },
|
||||
{ path: '/expenses', permission: 'expense:view' },
|
||||
{ path: '/deposits', permission: 'deposit:view' },
|
||||
{ path: '/bills', permission: 'bill:view' },
|
||||
{ path: '/notifications', permission: 'notification:view' },
|
||||
{ path: '/operation-logs', permission: 'log:view' },
|
||||
{ path: '/roles', permission: 'role:view' },
|
||||
{ path: '/permissions', permission: 'role:view' },
|
||||
{ path: '/integration-config', permission: 'integration:read' },
|
||||
{ path: '/ai-config', permission: 'ai:config:read' },
|
||||
{ path: '/users', permission: 'user:view' },
|
||||
{ path: '/teachers', permission: 'user:view' },
|
||||
] as const;
|
||||
|
||||
function matchesPage(page: PermissionPage, pathname: string): boolean {
|
||||
return page.matches ? page.matches(pathname) : page.path === pathname;
|
||||
}
|
||||
|
||||
export function getRequiredPermission(pathname: string): string | null {
|
||||
return PERMISSION_PAGES.find((page) => matchesPage(page, pathname))?.permission ?? null;
|
||||
}
|
||||
|
||||
export function canAccessPath(pathname: string, permissions: readonly string[]): boolean {
|
||||
const required = getRequiredPermission(pathname);
|
||||
return required === null || permissions.includes(required);
|
||||
}
|
||||
|
||||
export function findFirstAccessiblePath(permissions: readonly string[]): string | null {
|
||||
return PERMISSION_PAGES.find((page) => permissions.includes(page.permission))?.path ?? null;
|
||||
}
|
||||
19
apps/admin/src/auth/permission-tabs.integration.test.ts
Normal file
19
apps/admin/src/auth/permission-tabs.integration.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterTabsByPermission } from './permission-tabs';
|
||||
|
||||
describe('permission-aware tabs', () => {
|
||||
const tabs = [
|
||||
{ key: 'all', requiredPermission: 'deposit:view' },
|
||||
{ key: 'pending', requiredPermission: 'deposit:approve' },
|
||||
];
|
||||
|
||||
it('hides tabs whose backing API permission is missing', () => {
|
||||
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('shows a privileged tab only when its permission is present', () => {
|
||||
expect(
|
||||
filterTabsByPermission(tabs, ['deposit:view', 'deposit:approve']).map((tab) => tab.key),
|
||||
).toEqual(['all', 'pending']);
|
||||
});
|
||||
});
|
||||
13
apps/admin/src/auth/permission-tabs.ts
Normal file
13
apps/admin/src/auth/permission-tabs.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface PermissionTab {
|
||||
key: string;
|
||||
requiredPermission?: string;
|
||||
}
|
||||
|
||||
export function filterTabsByPermission<T extends PermissionTab>(
|
||||
tabs: readonly T[],
|
||||
permissions: readonly string[],
|
||||
): T[] {
|
||||
return tabs.filter(
|
||||
(tab) => !tab.requiredPermission || permissions.includes(tab.requiredPermission),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user