From a7013662160ea2be4ccae1f2e0873f4d7496d6a3 Mon Sep 17 00:00:00 2001
From: HotelSync
Date: Mon, 20 Apr 2026 10:50:22 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20route=20permission=20guards,=20home=20p?=
=?UTF-8?q?age=20per=20role=20=E2=80=94=20RolePermissionsContext,=20Permis?=
=?UTF-8?q?sionGuard,=20App=20routes,=20DB=20migration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Sonnet 4.6
---
.../081_role_permissions_home_page.sql | 2 +
backend/src/routes/role-permissions.ts | 11 +-
src/App.tsx | 100 ++++++++++--------
src/components/PermissionGuard.tsx | 24 +++++
src/contexts/RolePermissionsContext.tsx | 48 +++++++--
5 files changed, 129 insertions(+), 56 deletions(-)
create mode 100644 backend/migrations/081_role_permissions_home_page.sql
create mode 100644 src/components/PermissionGuard.tsx
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}
)