feat: route permission guards, home page per role — RolePermissionsContext, PermissionGuard, App routes, DB migration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 10:50:22 +03:00
parent 80429cd52a
commit a701366216
5 changed files with 129 additions and 56 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE role_permissions
ADD COLUMN IF NOT EXISTS home_page VARCHAR(100) DEFAULT NULL;

View File

@@ -41,7 +41,7 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
// ── PUT /api/hotels/:slug/role-permissions/:roleKey ──────────────────────── // ── PUT /api/hotels/:slug/role-permissions/:roleKey ────────────────────────
fastify.put<SlugRoleParam & { fastify.put<SlugRoleParam & {
Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean> } Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean>; homePage?: string }
}>( }>(
'/api/hotels/:slug/role-permissions/:roleKey', '/api/hotels/:slug/role-permissions/:roleKey',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
@@ -56,18 +56,19 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { name, color, isSystem = false, permissions } = request.body const { name, color, isSystem = false, permissions, homePage = null } = request.body
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions) `INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (hotel_id, role_key) DO UPDATE ON CONFLICT (hotel_id, role_key) DO UPDATE
SET name = EXCLUDED.name, SET name = EXCLUDED.name,
color = EXCLUDED.color, color = EXCLUDED.color,
permissions = EXCLUDED.permissions, permissions = EXCLUDED.permissions,
home_page = EXCLUDED.home_page,
updated_at = NOW() updated_at = NOW()
RETURNING *`, RETURNING *`,
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions)], [hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage],
) )
return rows[0] return rows[0]
}, },

View File

@@ -54,7 +54,23 @@ import { PayDepositPage } from './pages/PayDepositPage'
import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage' import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage'
import { BookingConfirmPage } from './pages/BookingConfirmPage' import { BookingConfirmPage } from './pages/BookingConfirmPage'
import { ModuleGuard } from './components/ModuleGuard' import { ModuleGuard } from './components/ModuleGuard'
import { RolePermissionsProvider } from './contexts/RolePermissionsContext' import { PermissionGuard } from './components/PermissionGuard'
import { RolePermissionsProvider, useRolePermissions } from './contexts/RolePermissionsContext'
import { useAuth } from './contexts/AuthContext'
// Redirects to the user's role home page after login
function HomeRedirect() {
const { user } = useAuth()
const { userHomePage, loading } = useRolePermissions()
if (!user) return <Navigate to="/login" replace />
if (loading) return null
return <Navigate to={userHomePage} replace />
}
// Shorthand: wrap element with PermissionGuard
function P({ perm, children }: { perm: string; children: React.ReactNode }) {
return <PermissionGuard permission={perm}>{children}</PermissionGuard>
}
export default function App() { export default function App() {
return ( return (
@@ -78,49 +94,49 @@ export default function App() {
{/* PMS routes */} {/* PMS routes */}
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/calendar" element={<CalendarPage />} /> <Route path="/calendar" element={<P perm="calendar"> <CalendarPage /> </P>} />
<Route path="/bookings" element={<BookingsPage />} /> <Route path="/bookings" element={<P perm="bookings"> <BookingsPage /> </P>} />
<Route path="/rooms" element={<RoomsPage />} /> <Route path="/guests" element={<P perm="guests"> <GuestsPage /> </P>} />
<Route path="/housekeeping" element={<ModuleGuard moduleId="housekeeping"><HousekeepingPage /></ModuleGuard>} /> <Route path="/rooms" element={<P perm="rooms"> <RoomsPage /> </P>} />
<Route path="/channels" element={<ModuleGuard moduleId="channel-manager"><ChannelsPage /></ModuleGuard>} /> <Route path="/room-categories" element={<P perm="rooms"> <RoomCategoriesPage /></P>} />
<Route path="/api-docs" element={<ApiDocsPage />} /> <Route path="/availability" element={<P perm="availability"><AvailabilityPage /></P>} />
<Route path="/modules" element={<ModulesPage />} /> <Route path="/floor-map" element={<P perm="floor_map"> <FloorMapPage /> </P>} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/housekeeping" element={<P perm="housekeeping"><ModuleGuard moduleId="housekeeping"><HousekeepingPage /></ModuleGuard></P>} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/technical" element={<P perm="maintenance"> <TechnicalPage /> </P>} />
<Route path="/website" element={<WebsitePage />} /> <Route path="/maintenance" element={<P perm="maintenance"> <MaintenancePage /> </P>} />
<Route path="/booking-widget" element={<BookingWidgetPage />} /> <Route path="/pos" element={<P perm="pos"> <ModuleGuard moduleId="pos"><PosPage /></ModuleGuard> </P>} />
<Route path="/availability" element={<AvailabilityPage />} /> <Route path="/room-service" element={<P perm="room_service"><ModuleGuard moduleId="room-service"><RoomServicePage /></ModuleGuard></P>} />
<Route path="/floor-map" element={<FloorMapPage />} /> <Route path="/reviews" element={<P perm="reviews"> <ModuleGuard moduleId="reviews"><ReviewsPage /></ModuleGuard> </P>} />
<Route path="/migration" element={<MigrationPage />} /> <Route path="/tariffs" element={<P perm="tariffs"> <TariffsPage /> </P>} />
<Route path="/pos" element={<ModuleGuard moduleId="pos"><PosPage /></ModuleGuard>} /> <Route path="/dynamic-pricing" element={<P perm="pricing"> <DynamicPricingPage /></P>} />
<Route path="/reviews" element={<ModuleGuard moduleId="reviews"><ReviewsPage /></ModuleGuard>} /> <Route path="/discounts" element={<P perm="discounts"> <DiscountsPage /> </P>} />
<Route path="/room-service" element={<ModuleGuard moduleId="room-service"><RoomServicePage /></ModuleGuard>} /> <Route path="/rental" element={<P perm="rental"> <ModuleGuard moduleId="rental"><RentalPage /></ModuleGuard> </P>} />
<Route path="/rental" element={<ModuleGuard moduleId="rental"><RentalPage /></ModuleGuard>} /> <Route path="/channels" element={<P perm="channels"> <ModuleGuard moduleId="channel-manager"><ChannelsPage /></ModuleGuard></P>} />
<Route path="/room-categories" element={<RoomCategoriesPage />} /> <Route path="/website" element={<P perm="website"> <WebsitePage /> </P>} />
<Route path="/documents" element={<DocumentsPage />} /> <Route path="/booking-widget" element={<P perm="website"> <BookingWidgetPage /></P>} />
<Route path="/users" element={<UsersPage />} /> <Route path="/reports" element={<P perm="reports"> <ReportsPage /> </P>} />
<Route path="/schedule" element={<SchedulePage />} /> <Route path="/loyalty" element={<P perm="loyalty"> <LoyaltyPage /> </P>} />
<Route path="/guests" element={<GuestsPage />} /> <Route path="/users" element={<P perm="users"> <UsersPage /> </P>} />
<Route path="/tariffs" element={<TariffsPage />} /> <Route path="/schedule" element={<P perm="users"> <SchedulePage /> </P>} />
<Route path="/dynamic-pricing" element={<DynamicPricingPage />} /> <Route path="/documents" element={<P perm="documents"> <DocumentsPage /> </P>} />
<Route path="/loyalty" element={<LoyaltyPage />} /> <Route path="/settings" element={<P perm="settings"> <SettingsPage /> </P>} />
<Route path="/maintenance" element={<MaintenancePage />} /> <Route path="/modules" element={<P perm="settings"> <ModulesPage /> </P>} />
<Route path="/discounts" element={<DiscountsPage />} /> <Route path="/billing" element={<P perm="settings"> <BillingPage /> </P>} />
<Route path="/tv-welcome" element={<TvWelcomePage />} /> <Route path="/equipment" element={<P perm="equipment"> <EquipmentPage /> </P>} />
<Route path="/technical" element={<TechnicalPage />} /> <Route path="/wifi" element={<P perm="wifi"> <WiFiPage /> </P>} />
<Route path="/billing" element={<BillingPage />} /> <Route path="/ttlock" element={<P perm="ttlock"> <TTLockPage /> </P>} />
<Route path="/equipment" element={<EquipmentPage />} /> <Route path="/settings/checklists" element={<P perm="settings"><ChecklistSettingsPage /></P>} />
<Route path="/wifi" element={<WiFiPage />} /> <Route path="/settings/payments" element={<P perm="settings"><PaymentSettingsPage /> </P>} />
<Route path="/ttlock" element={<TTLockPage />} /> <Route path="/settings/minibar" element={<P perm="settings"><MinibarSettingsPage /> </P>} />
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} /> <Route path="/settings/minibar/stock" element={<P perm="settings"><MinibarStockPage /> </P>} />
<Route path="/settings/payments" element={<PaymentSettingsPage />} /> <Route path="/settings/deposit" element={<P perm="settings"><DepositSettingsPage /> </P>} />
<Route path="/settings/minibar" element={<MinibarSettingsPage />} /> <Route path="/settings/deposit/history" element={<P perm="settings"><DepositHistoryPage /> </P>} />
<Route path="/settings/minibar/stock" element={<MinibarStockPage />} /> <Route path="/migration" element={<P perm="settings"> <MigrationPage /> </P>} />
<Route path="/settings/deposit" element={<DepositSettingsPage />} /> <Route path="/api-docs" element={<P perm="settings"> <ApiDocsPage /> </P>} />
<Route path="/settings/deposit/history" element={<DepositHistoryPage />} /> <Route path="/tv-welcome" element={<TvWelcomePage />} />
</Route> </Route>
<Route path="/" element={<Navigate to="/login" replace />} /> <Route path="/" element={<HomeRedirect />} />
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,24 @@
import { Navigate } from 'react-router-dom'
import { useRolePermissions } from '../contexts/RolePermissionsContext'
interface Props {
permission: string
children: React.ReactNode
}
/**
* Protects a route by permission key.
* - While permissions are loading from DB: renders nothing (no flash).
* - If user lacks the permission: redirects to their role's home page.
*/
export function PermissionGuard({ permission, children }: Props) {
const { can, loading, userHomePage } = useRolePermissions()
if (loading) return null
if (!can(permission)) {
return <Navigate to={userHomePage} replace />
}
return <>{children}</>
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' import { createContext, useContext, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
import { useAuth } from './AuthContext' import { useAuth } from './AuthContext'
import { api } from '../lib/api' import { api } from '../lib/api'
@@ -19,6 +19,17 @@ export const DEFAULT_ROLE_PERMS: Record<string, string[]> = {
technician: ['calendar', 'housekeeping', 'maintenance', 'rooms', 'floor_map', 'equipment', 'ttlock'], technician: ['calendar', 'housekeeping', 'maintenance', 'rooms', 'floor_map', 'equipment', 'ttlock'],
} }
// Default home pages per role (fallback)
export const DEFAULT_HOME_PAGES: Record<string, string> = {
hotel_admin: '/calendar',
manager: '/calendar',
receptionist: '/calendar',
housekeeper: '/housekeeping',
accountant: '/reports',
security: '/calendar',
technician: '/housekeeping',
}
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
export interface SavedRolePermission { export interface SavedRolePermission {
@@ -29,15 +40,19 @@ export interface SavedRolePermission {
color: string color: string
isSystem: boolean isSystem: boolean
permissions: Record<string, boolean> permissions: Record<string, boolean>
homePage?: string | null
} }
interface RolePermissionsContextValue { interface RolePermissionsContextValue {
/** Check if the current user has a given permission */ /** Check if the current user has a given permission */
can: (permission: string) => boolean can: (permission: string) => boolean
/** Home page path for the current user's role */
userHomePage: string
/** Raw saved permissions for all roles (used by RolesTab editor) */ /** Raw saved permissions for all roles (used by RolesTab editor) */
savedPermissions: SavedRolePermission[] savedPermissions: SavedRolePermission[]
/** Re-fetch from API */ /** Re-fetch from API */
reload: () => Promise<void> reload: () => Promise<void>
/** True while initial permissions are being fetched */
loading: boolean loading: boolean
} }
@@ -45,18 +60,23 @@ interface RolePermissionsContextValue {
const RolePermissionsContext = createContext<RolePermissionsContextValue>({ const RolePermissionsContext = createContext<RolePermissionsContextValue>({
can: () => false, can: () => false,
userHomePage: '/calendar',
savedPermissions: [], savedPermissions: [],
reload: async () => {}, reload: async () => {},
loading: false, loading: true,
}) })
export function RolePermissionsProvider({ children }: { children: ReactNode }) { export function RolePermissionsProvider({ children }: { children: ReactNode }) {
const { user } = useAuth() const { user } = useAuth()
const [savedPermissions, setSavedPermissions] = useState<SavedRolePermission[]>([]) const [savedPermissions, setSavedPermissions] = useState<SavedRolePermission[]>([])
const [loading, setLoading] = useState(false) // Start as true — PermissionGuard waits before rendering, preventing permission flash
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => { const reload = useCallback(async () => {
if (!user?.hotelSlug) return if (!user?.hotelSlug) {
setLoading(false)
return
}
setLoading(true) setLoading(true)
try { try {
const data = await api.rolePermissions.list(user.hotelSlug) const data = await api.rolePermissions.list(user.hotelSlug)
@@ -75,13 +95,10 @@ export function RolePermissionsProvider({ children }: { children: ReactNode }) {
const can = useCallback((permission: string): boolean => { const can = useCallback((permission: string): boolean => {
const role = user?.role ?? 'housekeeper' const role = user?.role ?? 'housekeeper'
// super_admin: full access everywhere
if (role === 'super_admin') return true if (role === 'super_admin') return true
// hotel_admin and manager: full access to all hotel modules
if (role === 'hotel_admin' || role === 'manager') return true if (role === 'hotel_admin' || role === 'manager') return true
// Check saved permissions from DB for this role // Check saved permissions from DB
const saved = savedPermissions.find(rp => rp.roleKey === role) const saved = savedPermissions.find(rp => rp.roleKey === role)
if (saved) { if (saved) {
return saved.permissions[permission] === true return saved.permissions[permission] === true
@@ -92,8 +109,21 @@ export function RolePermissionsProvider({ children }: { children: ReactNode }) {
return perms.includes('*') || perms.includes(permission) return perms.includes('*') || perms.includes(permission)
}, [user?.role, savedPermissions]) }, [user?.role, savedPermissions])
const userHomePage = useMemo(() => {
const role = user?.role ?? ''
if (role === 'super_admin') return '/admin'
if (role === 'hotel_admin' || role === 'manager') return '/calendar'
// Use saved home page from DB if set
const saved = savedPermissions.find(rp => rp.roleKey === role)
if (saved?.homePage) return saved.homePage
// Fall back to role default
return DEFAULT_HOME_PAGES[role] ?? '/calendar'
}, [user?.role, savedPermissions])
return ( return (
<RolePermissionsContext.Provider value={{ can, savedPermissions, reload, loading }}> <RolePermissionsContext.Provider value={{ can, userHomePage, savedPermissions, reload, loading }}>
{children} {children}
</RolePermissionsContext.Provider> </RolePermissionsContext.Provider>
) )