diff --git a/backend/migrations/081_role_permissions_home_page.sql b/backend/migrations/081_role_permissions_home_page.sql new file mode 100644 index 0000000..8f13707 --- /dev/null +++ b/backend/migrations/081_role_permissions_home_page.sql @@ -0,0 +1,2 @@ +ALTER TABLE role_permissions + ADD COLUMN IF NOT EXISTS home_page VARCHAR(100) DEFAULT NULL; diff --git a/backend/src/routes/role-permissions.ts b/backend/src/routes/role-permissions.ts index 45b8839..5a4045d 100644 --- a/backend/src/routes/role-permissions.ts +++ b/backend/src/routes/role-permissions.ts @@ -41,7 +41,7 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => { // ── PUT /api/hotels/:slug/role-permissions/:roleKey ──────────────────────── fastify.put } + Body: { name: string; color: string; isSystem?: boolean; permissions: Record; 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] }, diff --git a/src/App.tsx b/src/App.tsx index 9fd78a1..8af23c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 + if (loading) return null + return +} + +// Shorthand: wrap element with PermissionGuard +function P({ perm, children }: { perm: string; children: React.ReactNode }) { + return {children} +} export default function App() { return ( @@ -78,49 +94,49 @@ export default function App() { {/* PMS routes */} }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> +

} /> + } /> - } /> + } /> } /> diff --git a/src/components/PermissionGuard.tsx b/src/components/PermissionGuard.tsx new file mode 100644 index 0000000..aa834b7 --- /dev/null +++ b/src/components/PermissionGuard.tsx @@ -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 + } + + return <>{children} +} diff --git a/src/contexts/RolePermissionsContext.tsx b/src/contexts/RolePermissionsContext.tsx index 87e23a5..7f61d76 100644 --- a/src/contexts/RolePermissionsContext.tsx +++ b/src/contexts/RolePermissionsContext.tsx @@ -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 = { technician: ['calendar', 'housekeeping', 'maintenance', 'rooms', 'floor_map', 'equipment', 'ttlock'], } +// Default home pages per role (fallback) +export const DEFAULT_HOME_PAGES: Record = { + 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 + 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 + /** True while initial permissions are being fetched */ loading: boolean } @@ -45,18 +60,23 @@ interface RolePermissionsContextValue { const RolePermissionsContext = createContext({ can: () => false, + userHomePage: '/calendar', savedPermissions: [], reload: async () => {}, - loading: false, + loading: true, }) export function RolePermissionsProvider({ children }: { children: ReactNode }) { const { user } = useAuth() const [savedPermissions, setSavedPermissions] = useState([]) - 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 ( - + {children} )