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 ────────────────────────
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',
{ onRequest: [fastify.authenticate] },
@@ -56,18 +56,19 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
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(
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (hotel_id, role_key) DO UPDATE
SET name = EXCLUDED.name,
color = EXCLUDED.color,
permissions = EXCLUDED.permissions,
home_page = EXCLUDED.home_page,
updated_at = NOW()
RETURNING *`,
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions)],
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage],
)
return rows[0]
},

View File

@@ -54,7 +54,23 @@ import { PayDepositPage } from './pages/PayDepositPage'
import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage'
import { BookingConfirmPage } from './pages/BookingConfirmPage'
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() {
return (
@@ -78,49 +94,49 @@ export default function App() {
{/* PMS routes */}
<Route element={<AppLayout />}>
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/rooms" element={<RoomsPage />} />
<Route path="/housekeeping" element={<ModuleGuard moduleId="housekeeping"><HousekeepingPage /></ModuleGuard>} />
<Route path="/channels" element={<ModuleGuard moduleId="channel-manager"><ChannelsPage /></ModuleGuard>} />
<Route path="/api-docs" element={<ApiDocsPage />} />
<Route path="/modules" element={<ModulesPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/website" element={<WebsitePage />} />
<Route path="/booking-widget" element={<BookingWidgetPage />} />
<Route path="/availability" element={<AvailabilityPage />} />
<Route path="/floor-map" element={<FloorMapPage />} />
<Route path="/migration" element={<MigrationPage />} />
<Route path="/pos" element={<ModuleGuard moduleId="pos"><PosPage /></ModuleGuard>} />
<Route path="/reviews" element={<ModuleGuard moduleId="reviews"><ReviewsPage /></ModuleGuard>} />
<Route path="/room-service" element={<ModuleGuard moduleId="room-service"><RoomServicePage /></ModuleGuard>} />
<Route path="/rental" element={<ModuleGuard moduleId="rental"><RentalPage /></ModuleGuard>} />
<Route path="/room-categories" element={<RoomCategoriesPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/schedule" element={<SchedulePage />} />
<Route path="/guests" element={<GuestsPage />} />
<Route path="/tariffs" element={<TariffsPage />} />
<Route path="/dynamic-pricing" element={<DynamicPricingPage />} />
<Route path="/loyalty" element={<LoyaltyPage />} />
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/discounts" element={<DiscountsPage />} />
<Route path="/tv-welcome" element={<TvWelcomePage />} />
<Route path="/technical" element={<TechnicalPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/equipment" element={<EquipmentPage />} />
<Route path="/wifi" element={<WiFiPage />} />
<Route path="/ttlock" element={<TTLockPage />} />
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
<Route path="/settings/payments" element={<PaymentSettingsPage />} />
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
<Route path="/settings/minibar/stock" element={<MinibarStockPage />} />
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
<Route path="/settings/deposit/history" element={<DepositHistoryPage />} />
<Route path="/calendar" element={<P perm="calendar"> <CalendarPage /> </P>} />
<Route path="/bookings" element={<P perm="bookings"> <BookingsPage /> </P>} />
<Route path="/guests" element={<P perm="guests"> <GuestsPage /> </P>} />
<Route path="/rooms" element={<P perm="rooms"> <RoomsPage /> </P>} />
<Route path="/room-categories" element={<P perm="rooms"> <RoomCategoriesPage /></P>} />
<Route path="/availability" element={<P perm="availability"><AvailabilityPage /></P>} />
<Route path="/floor-map" element={<P perm="floor_map"> <FloorMapPage /> </P>} />
<Route path="/housekeeping" element={<P perm="housekeeping"><ModuleGuard moduleId="housekeeping"><HousekeepingPage /></ModuleGuard></P>} />
<Route path="/technical" element={<P perm="maintenance"> <TechnicalPage /> </P>} />
<Route path="/maintenance" element={<P perm="maintenance"> <MaintenancePage /> </P>} />
<Route path="/pos" element={<P perm="pos"> <ModuleGuard moduleId="pos"><PosPage /></ModuleGuard> </P>} />
<Route path="/room-service" element={<P perm="room_service"><ModuleGuard moduleId="room-service"><RoomServicePage /></ModuleGuard></P>} />
<Route path="/reviews" element={<P perm="reviews"> <ModuleGuard moduleId="reviews"><ReviewsPage /></ModuleGuard> </P>} />
<Route path="/tariffs" element={<P perm="tariffs"> <TariffsPage /> </P>} />
<Route path="/dynamic-pricing" element={<P perm="pricing"> <DynamicPricingPage /></P>} />
<Route path="/discounts" element={<P perm="discounts"> <DiscountsPage /> </P>} />
<Route path="/rental" element={<P perm="rental"> <ModuleGuard moduleId="rental"><RentalPage /></ModuleGuard> </P>} />
<Route path="/channels" element={<P perm="channels"> <ModuleGuard moduleId="channel-manager"><ChannelsPage /></ModuleGuard></P>} />
<Route path="/website" element={<P perm="website"> <WebsitePage /> </P>} />
<Route path="/booking-widget" element={<P perm="website"> <BookingWidgetPage /></P>} />
<Route path="/reports" element={<P perm="reports"> <ReportsPage /> </P>} />
<Route path="/loyalty" element={<P perm="loyalty"> <LoyaltyPage /> </P>} />
<Route path="/users" element={<P perm="users"> <UsersPage /> </P>} />
<Route path="/schedule" element={<P perm="users"> <SchedulePage /> </P>} />
<Route path="/documents" element={<P perm="documents"> <DocumentsPage /> </P>} />
<Route path="/settings" element={<P perm="settings"> <SettingsPage /> </P>} />
<Route path="/modules" element={<P perm="settings"> <ModulesPage /> </P>} />
<Route path="/billing" element={<P perm="settings"> <BillingPage /> </P>} />
<Route path="/equipment" element={<P perm="equipment"> <EquipmentPage /> </P>} />
<Route path="/wifi" element={<P perm="wifi"> <WiFiPage /> </P>} />
<Route path="/ttlock" element={<P perm="ttlock"> <TTLockPage /> </P>} />
<Route path="/settings/checklists" element={<P perm="settings"><ChecklistSettingsPage /></P>} />
<Route path="/settings/payments" element={<P perm="settings"><PaymentSettingsPage /> </P>} />
<Route path="/settings/minibar" element={<P perm="settings"><MinibarSettingsPage /> </P>} />
<Route path="/settings/minibar/stock" element={<P perm="settings"><MinibarStockPage /> </P>} />
<Route path="/settings/deposit" element={<P perm="settings"><DepositSettingsPage /> </P>} />
<Route path="/settings/deposit/history" element={<P perm="settings"><DepositHistoryPage /> </P>} />
<Route path="/migration" element={<P perm="settings"> <MigrationPage /> </P>} />
<Route path="/api-docs" element={<P perm="settings"> <ApiDocsPage /> </P>} />
<Route path="/tv-welcome" element={<TvWelcomePage />} />
</Route>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/" element={<HomeRedirect />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</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 { 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'],
}
// 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 ─────────────────────────────────────────────────────────────────────
export interface SavedRolePermission {
@@ -29,15 +40,19 @@ export interface SavedRolePermission {
color: string
isSystem: boolean
permissions: Record<string, boolean>
homePage?: string | null
}
interface RolePermissionsContextValue {
/** Check if the current user has a given permission */
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) */
savedPermissions: SavedRolePermission[]
/** Re-fetch from API */
reload: () => Promise<void>
/** True while initial permissions are being fetched */
loading: boolean
}
@@ -45,18 +60,23 @@ interface RolePermissionsContextValue {
const RolePermissionsContext = createContext<RolePermissionsContextValue>({
can: () => false,
userHomePage: '/calendar',
savedPermissions: [],
reload: async () => {},
loading: false,
loading: true,
})
export function RolePermissionsProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
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 () => {
if (!user?.hotelSlug) return
if (!user?.hotelSlug) {
setLoading(false)
return
}
setLoading(true)
try {
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 role = user?.role ?? 'housekeeper'
// super_admin: full access everywhere
if (role === 'super_admin') return true
// hotel_admin and manager: full access to all hotel modules
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)
if (saved) {
return saved.permissions[permission] === true
@@ -92,8 +109,21 @@ export function RolePermissionsProvider({ children }: { children: ReactNode }) {
return perms.includes('*') || perms.includes(permission)
}, [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 (
<RolePermissionsContext.Provider value={{ can, savedPermissions, reload, loading }}>
<RolePermissionsContext.Provider value={{ can, userHomePage, savedPermissions, reload, loading }}>
{children}
</RolePermissionsContext.Provider>
)