diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index bf304d1..702d6c8 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -121,6 +121,7 @@ import { Badge } from '@/components/ui/badge'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useAppStore, type ViewName } from '@/lib/store'; +import { canAccessView, filterSectionsForRole, getFallbackView } from '@/lib/role-access'; import { t } from '@/lib/i18n'; import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api'; import { apiGet } from '@/lib/api'; @@ -571,6 +572,16 @@ export default function AppLayout() { const isStudent = currentUser?.role === 'STUDENT'; const isParent = currentUser?.role === 'PARENT'; const activeNavSections = isStudent ? studentNavSections : isParent ? parentNavSections : navSections; + const visibleNavSections = useMemo( + () => filterSectionsForRole(currentUser?.role, activeNavSections), + [activeNavSections, currentUser?.role], + ); + + useEffect(() => { + if (currentUser && !canAccessView(currentUser.role, currentView)) { + setCurrentView(getFallbackView(currentUser.role)); + } + }, [currentUser, currentView, setCurrentView]); const isDemoUser = currentUser?.isDemo === true || (currentUser?.email?.endsWith('@competencetrack.org') && currentUser?.email?.startsWith('demo')); @@ -1016,7 +1027,7 @@ export default function AppLayout() { - {activeNavSections.map((section) => ( + {visibleNavSections.map((section) => ( {section.id === 'analysis' && } diff --git a/src/lib/role-access.test.ts b/src/lib/role-access.test.ts new file mode 100644 index 0000000..141deab --- /dev/null +++ b/src/lib/role-access.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { canAccessView, filterSectionsForRole, filterViewsForRole, getFallbackView } from './role-access'; + +const mixedItems = [ + { key: 'dashboard' as const }, + { key: 'students' as const }, + { key: 'classes' as const }, +]; + +describe('role access', () => { + test('hides teacher-inaccessible views from a mixed menu', () => { + expect(filterViewsForRole('TEACHER', mixedItems).map(({ key }) => key)).toEqual(['dashboard', 'classes']); + }); + + test('removes empty sections for parent, student, and teacher', () => { + const sections = [ + { id: 'visible', items: [{ key: 'dashboard' as const }] }, + { id: 'hidden', items: [{ key: 'students' as const }] }, + ]; + + for (const role of ['PARENT', 'STUDENT', 'TEACHER'] as const) { + expect(filterSectionsForRole(role, sections).map(({ id }) => id)).toEqual(['visible']); + } + }); + + test('falls back to dashboard for an inaccessible active view', () => { + expect(canAccessView('PARENT', 'students')).toBeFalse(); + expect(getFallbackView('PARENT')).toBe('dashboard'); + }); + + test('keeps every view available to administrators', () => { + for (const view of mixedItems.map(({ key }) => key)) { + expect(canAccessView('SCHOOL_ADMIN', view)).toBeTrue(); + expect(canAccessView('SUPER_ADMIN', view)).toBeTrue(); + } + }); +}); diff --git a/src/lib/role-access.ts b/src/lib/role-access.ts new file mode 100644 index 0000000..8dd8880 --- /dev/null +++ b/src/lib/role-access.ts @@ -0,0 +1,59 @@ +import type { ViewName } from '@/lib/store'; + +export type AppRole = 'PARENT' | 'STUDENT' | 'TEACHER' | 'VICE_PRINCIPAL' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'DPO'; + +const parentViews: ViewName[] = [ + 'dashboard', 'parent-portal', 'parents', 'grading', 'attendance', 'calendar', + 'communication', 'illness', 'report-cards', 'school-events', 'school-newsletter', + 'notification-center', 'announcements', 'settings', +]; + +const studentViews: ViewName[] = [ + 'dashboard', 'student-portal', 'notebooks', 'flower', 'grading', 'grade-analytics', + 'homework', 'attendance', 'portfolio', 'calendar', 'competitions', 'subjects', + 'illness', 'communication', 'counseling', 'ai-tests', 'exam-calendar', + 'peer-assessment', 'report-cards', 'student-achievements', 'student-study-planner', + 'student-wellness', 'student-career', 'resources', 'school-library', 'school-events', + 'school-newsletter', 'school-transport', 'notification-center', 'announcements', 'settings', +]; + +const teacherViews: ViewName[] = [ + 'dashboard', 'classes', 'competencies', 'progress', 'flower', 'assessments', 'grading', + 'reports', 'settings', 'student-detail', 'matrix', 'attendance', 'calendar', 'lesson-plans', + 'parents', 'behavior', 'coverage', 'rubrics', 'comments', 'notebooks', 'drawing', 'homework', + 'portfolio', 'timetable', 'resources', 'competitions', 'subjects', 'illness', 'communication', + 'counseling', 'ai-tests', 'notification-center', 'announcements', 'tablet-grading', + 'exam-calendar', 'peer-assessment', 'seating-chart', 'school-library', 'report-cards', + 'student-wellness', 'student-career', 'school-events', 'school-newsletter', +]; + +const vicePrincipalViews: ViewName[] = [ + ...teacherViews, + 'analytics', 'disciplinary', 'school-transport', 'substitute-teacher', +]; + +export function canAccessView(role: string | undefined, view: ViewName): boolean { + if (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') return true; + const allowed = role === 'PARENT' ? parentViews + : role === 'STUDENT' ? studentViews + : role === 'VICE_PRINCIPAL' ? vicePrincipalViews + : role === 'DPO' ? ['dashboard', 'settings', 'reports'] + : teacherViews; + return allowed.includes(view); +} + +export function filterViewsForRole(role: string | undefined, items: T[]): T[] { + return items.filter((item) => canAccessView(role, item.key)); +} + +export function filterSectionsForRole(role: string | undefined, sections: T[]): T[] { + return sections + .map((section) => ({ ...section, items: filterViewsForRole(role, section.items) })) + .filter((section) => section.items.length > 0) as T[]; +} + +export function getFallbackView(role: string | undefined): ViewName { + return role === 'PARENT' || role === 'STUDENT' || role === 'TEACHER' || role === 'VICE_PRINCIPAL' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'DPO' + ? 'dashboard' + : 'dashboard'; +}