added role based access

This commit is contained in:
suryacp23 2026-07-30 10:36:58 +05:30
parent f9ea463ea8
commit dc4a119a7a
11 changed files with 344 additions and 79 deletions

View File

@ -13,6 +13,15 @@ import { DailySalesReportPage } from './screens/DailySalesReportPage'
import { ReportPage } from './screens/ReportPage'
import { DatasetsPage } from './screens/admin/DatasetsPage'
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
import { useAuth } from './auth/context'
function RootRedirect() {
const { roles, isAdmin } = useAuth();
return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
}
import { routeConfig } from './routesConfig'
function App() {
return (
@ -21,35 +30,32 @@ function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ConsoleLayout />}>
<Route path="orders" element={<OrdersPage />} />
<Route path="orders/:instanceId" element={<OrdersPage />} />
{routeConfig.map((route, i) => {
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
<Route path="my-orders" element={<MyOrdersPage />} />
<Route path="my-orders/:instanceId" element={<MyOrdersPage />} />
// Maintain nested route for Dataset items specifically
if (path === 'admin/datasets') {
return (
<Route
key={i}
path={path}
element={<ProtectedRoute adminOnly={route.adminOnly} roles={route.roles}>{route.element}</ProtectedRoute>}
>
<Route path=":id" element={<DatasetItemsPage />} />
</Route>
);
}
<Route path="calls" element={<CallsPage />} />
<Route path="calls/:instanceId" element={<CallsPage />} />
return (
<Route
key={i}
path={path}
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
/>
);
})}
<Route path="my-calls" element={<MyCallsPage />} />
<Route path="my-calls/:instanceId" element={<MyCallsPage />} />
<Route path="stores" element={<StoresPage />} />
<Route path="stores/:instanceId" element={<StoresPage />} />
<Route path="daily" element={<DailyLogsPage />} />
<Route path="daily/:instanceId" element={<DailyLogsPage />} />
<Route path="my-daily" element={<MyDailyLogsPage />} />
<Route path="my-daily/:instanceId" element={<MyDailyLogsPage />} />
<Route path="reports/:reportType" element={<ReportPage />} />
<Route path="sales-report" element={<DailySalesReportPage />} />
<Route path="admin/datasets" element={<DatasetsPage />}>
<Route path=":id" element={<DatasetItemsPage />} />
</Route>
<Route path="*" element={<Navigate to="/orders" replace />} />
<Route path="*" element={<RootRedirect />} />
</Route>
</Routes>
</BrowserRouter>

View File

@ -122,6 +122,17 @@ export class ZinoClient {
this.setToken(null);
}
async getMe(): Promise<User> {
const res = await this.request<User>('GET', `/usr/app/${APP_ID}/me`);
if (this.user) {
this.user = { ...this.user, ...res };
if (typeof window !== 'undefined') {
localStorage.setItem(USER_KEY, JSON.stringify(this.user));
}
}
return res;
}
/** Decode the persisted JWT into a User (no network) or use the saved user. */
currentUser(): User | null {
if (!this.token) return null;

View File

@ -30,6 +30,12 @@ const ALL = [orderBookingClient, storeClient, dailyReportsClient];
export async function loginAll(email: string, password: string, orgId?: string) {
const res = await orderBookingClient.login(email, password, orgId);
ALL.forEach((c) => c.setToken(res.token));
try {
const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me };
} catch (e) {
console.error('Failed to fetch user profile:', e);
}
return res;
}

View File

@ -13,6 +13,7 @@ export interface User {
mobile?: string;
roles: string[];
groups: string[];
is_admin?: boolean;
}
export interface LoginResponse {

View File

@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
import { loginAll, logoutAll, currentToken } from '../api/clients';
import { useState, type ReactNode, useEffect } from 'react';
import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients';
import { AuthCtx, type AuthValue } from './context';
export function AuthProvider({ children }: { children: ReactNode }) {
@ -7,9 +7,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [userEmail, setUserEmail] = useState<string | null>(() => {
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_user_email') : null;
});
const [isAdmin, setIsAdmin] = useState<boolean>(() => {
return orderBookingClient.currentUser()?.is_admin ?? false;
});
const [roles, setRoles] = useState<string[]>(() => {
return orderBookingClient.currentUser()?.roles ?? [];
});
// If we are authenticated but don't have isAdmin from cache, we might want to fetch it
useEffect(() => {
if (authed && (!isAdmin || roles.length === 0)) {
orderBookingClient.getMe().then(me => {
if (me.is_admin) setIsAdmin(true);
if (me.roles) setRoles(me.roles);
}).catch(console.error);
}
}, [authed]);
const value: AuthValue = {
authed,
isAdmin,
roles,
userEmail,
login: async (email, password, orgId) => {
const res = await loginAll(email, password, orgId);
@ -18,11 +36,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUserEmail(res.user.email);
localStorage.setItem('krishna_sales_user_email', res.user.email);
}
setIsAdmin(!!res.user?.is_admin);
setRoles(res.user?.roles ?? []);
},
logout: () => {
logoutAll();
setAuthed(false);
setUserEmail(null);
setIsAdmin(false);
setRoles([]);
localStorage.removeItem('krishna_sales_user_email');
},
};

View File

@ -0,0 +1,63 @@
import { Navigate, useNavigate } from 'react-router-dom';
import { ShieldAlert } from 'lucide-react';
import { useAuth } from './context';
import { Button } from '../components/buttons';
import { Card } from '../components/reusable';
export function getDefaultRoute(roles: string[], isAdmin: boolean) {
if (isAdmin) return '/orders';
if (roles.includes('Manager')) return '/orders';
if (roles.includes('Sales Officer')) return '/my-orders';
return '/stores';
}
function AccessDenied() {
const { roles, isAdmin } = useAuth();
const navigate = useNavigate();
return (
<div className="flex flex-col items-center justify-center min-h-[500px] h-full p-4 m-4">
<Card
className="max-w-md w-full shadow-sm mx-auto"
bodyClassName="flex flex-col items-center justify-center text-center p-8"
pad={false}
>
<ShieldAlert className="w-16 h-16 text-red-500 mb-4 opacity-90 mx-auto" />
<h2 className="text-2xl font-bold text-gray-800 mb-2">Access Denied</h2>
<p className="text-gray-500 mb-8">
You don't have permission to view this page. If you believe this is an error, please contact your administrator.
</p>
<Button
variant="primary"
onClick={() => navigate(getDefaultRoute(roles, isAdmin))}
>
Return to Homepage
</Button>
</Card>
</div>
);
}
export function ProtectedRoute({
children,
roles,
adminOnly
}: {
children: React.ReactNode,
roles?: string[],
adminOnly?: boolean
}) {
const { roles: userRoles, isAdmin } = useAuth();
if (adminOnly && !isAdmin) {
return <AccessDenied />;
}
if (roles && roles.length > 0) {
const hasRole = roles.some(role => userRoles.includes(role));
if (!hasRole) {
return <AccessDenied />;
}
}
return <>{children}</>;
}

View File

@ -2,6 +2,8 @@ import { createContext, useContext } from 'react';
export interface AuthValue {
authed: boolean;
isAdmin: boolean;
roles: string[];
userEmail: string | null;
login: (email: string, password: string, orgId?: string) => Promise<void>;
logout: () => void;

107
src/routesConfig.tsx Normal file
View File

@ -0,0 +1,107 @@
import { OrdersPage } from './screens/OrdersPage'
import { MyOrdersPage } from './screens/MyOrdersPage'
import { CallsPage } from './screens/CallsPage'
import { MyCallsPage } from './screens/MyCallsPage'
import { StoresPage } from './screens/StoresPage'
import { DailyLogsPage } from './screens/DailyLogsPage'
import { MyDailyLogsPage } from './screens/MyDailyLogsPage'
import { DailySalesReportPage } from './screens/DailySalesReportPage'
import { ReportPage } from './screens/ReportPage'
import { DatasetsPage } from './screens/admin/DatasetsPage'
export const routeConfig = [
// Sales Officer
{
path: "/my-orders",
element: <MyOrdersPage />,
roles: ["Sales Officer"],
},
{
path: "/my-orders/:instanceId",
element: <MyOrdersPage />,
roles: ["Sales Officer"],
},
{
path: "/my-calls",
element: <MyCallsPage />,
roles: ["Sales Officer"],
},
{
path: "/my-calls/:instanceId",
element: <MyCallsPage />,
roles: ["Sales Officer"],
},
{
path: "/my-daily",
element: <MyDailyLogsPage />,
roles: ["Sales Officer"],
},
{
path: "/my-daily/:instanceId",
element: <MyDailyLogsPage />,
roles: ["Sales Officer"],
},
// Manager & Admin
{
path: "/orders",
element: <OrdersPage />,
roles: ["Manager", "Admin"],
},
{
path: "/orders/:instanceId",
element: <OrdersPage />,
roles: ["Manager", "Admin"],
},
{
path: "/calls",
element: <CallsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/calls/:instanceId",
element: <CallsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/daily",
element: <DailyLogsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/daily/:instanceId",
element: <DailyLogsPage />,
roles: ["Manager", "Admin"],
},
// Everyone
{
path: "/stores",
element: <StoresPage />,
roles: ["Sales Officer", "Manager", "Admin"],
},
{
path: "/stores/:instanceId",
element: <StoresPage />,
roles: ["Sales Officer", "Manager", "Admin"],
},
{
path: "/reports/:reportType",
element: <ReportPage />,
roles: ["Manager", "Admin"],
},
// Manager + Admin
{
path: "/sales-report",
element: <DailySalesReportPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
// Admin Panel
{
path: "/admin/datasets",
element: <DatasetsPage />,
adminOnly: true,
},
];

View File

@ -9,9 +9,22 @@ import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { SCREENS } from './tabs';
import { REPORT_MAP } from './ReportPage';
import { routeConfig } from '../routesConfig';
const PATH_LABELS: Record<string, string> = {
"/my-orders": "My Orders",
"/my-calls": "My Calls",
"/my-daily": "My Daily Logs",
"/orders": "Orders",
"/calls": "Calls",
"/daily": "Daily Logs",
"/stores": "Stores",
"/sales-report": "DSR",
};
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
export function ConsoleLayout() {
const { authed, logout } = useAuth();
const { authed, logout, isAdmin, roles: userRoles } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const user = orderBookingClient.currentUser();
@ -28,6 +41,13 @@ export function ConsoleLayout() {
if (!authed) return <Navigate to="/login" replace />;
const reportRoute = routeConfig.find(r => r.path === '/reports/:reportType');
const canSeeReports = reportRoute && (
(reportRoute.adminOnly && isAdmin) ||
(reportRoute.roles && reportRoute.roles.some(r => userRoles.includes(r))) ||
(!reportRoute.adminOnly && !reportRoute.roles) // open to all
);
return (
<div className="h-screen bg-[var(--secondary-color)] flex flex-col">
<header className="sticky top-0 z-50 shrink-0 flex items-center justify-between gap-6 px-6 h-[56px] shadow-sm" style={{ background: 'var(--nav-bg-color)' }}>
@ -35,11 +55,21 @@ export function ConsoleLayout() {
<span className="text-lg font-bold text-white tracking-wide leading-none">Krishna Sales</span>
</div>
<nav className="flex items-center justify-end gap-2 h-full flex-1 mr-4">
{SCREENS.map((t) => {
{routeConfig.filter(route => {
if (route.path.includes('/:')) return false; // Skip detail pages
if (!PATH_LABELS[route.path]) return false; // Skip unmapped routes
if (route.adminOnly && !isAdmin) return false;
if (!route.adminOnly && route.roles) {
return route.roles.some(r => userRoles.includes(r));
}
return true;
}).map((route) => {
const key = route.path.replace('/', '');
return (
<NavLink
key={t.key}
to={`/${t.key}`}
key={key}
to={route.path}
className={({ isActive }) =>
cn(
'flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150',
@ -47,38 +77,39 @@ export function ConsoleLayout() {
)
}
>
{t.label}
{PATH_LABELS[route.path]}
</NavLink>
);
})}
<div className="relative group flex items-center h-full">
<button className={cn(
"flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
)}>
Reports
<ChevronDown size={14} className="ml-0.5 opacity-70" />
</button>
{canSeeReports && (
<div className="relative group flex items-center h-full">
<button className={cn(
"flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
)}>
Reports
<ChevronDown size={14} className="ml-0.5 opacity-70" />
</button>
<div className="absolute top-[80%] right-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
{Object.entries(REPORT_MAP).map(([key, report]) => (
<NavLink
key={key}
to={`/reports/${key}`}
className={({ isActive }) =>
cn(
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
isActive && "bg-gray-100 font-semibold"
)
}
>
{report.title}
</NavLink>
))}
<div className="absolute top-[80%] right-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
{Object.entries(REPORT_MAP).map(([key, report]) => (
<NavLink
key={key}
to={`/reports/${key}`}
className={({ isActive }) =>
cn(
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
isActive && "bg-gray-100 font-semibold"
)
}
>
{report.title}
</NavLink>
))}
</div>
</div>
</div>
)}
</nav>
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
<button
@ -92,15 +123,17 @@ export function ConsoleLayout() {
<p className="text-sm font-bold text-gray-900 truncate">{user?.name || 'User'}</p>
<p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
</div>
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
<NavLink
to="/admin/datasets"
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
>
<Database size={16} />
Manage Datasets
</NavLink>
</div>
{isAdmin && (
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
<NavLink
to="/admin/datasets"
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
>
<Database size={16} />
Manage Datasets
</NavLink>
</div>
)}
<div className="px-2">
<button
onClick={() => {

View File

@ -3,20 +3,28 @@ import { orderBookingClient } from '../../api/clients';
import type { Dataset } from '../../api/types';
import { Spinner } from '../../components/reusable/Spinner';
import { Database } from 'lucide-react';
import { useNavigate, useLocation, Outlet, useParams } from 'react-router-dom';
import { useNavigate, useLocation, Outlet, useParams, Navigate } from 'react-router-dom';
import { useAuth } from '../../auth/context';
export function DatasetsPage() {
const navigate = useNavigate();
const location = useLocation();
const { id } = useParams<{ id: string }>();
const { isAdmin } = useAuth();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchDatasets();
}, []);
if (isAdmin) {
fetchDatasets();
}
}, [isAdmin]);
if (!isAdmin) {
return <Navigate to="/orders" replace />;
}
const fetchDatasets = async () => {
try {

View File

@ -13,6 +13,7 @@ import {
DailyLogDetail,
type WiredDetailViewProps,
} from '../components/dv';
import { routeConfig } from '../routesConfig';
export type ScreenKey = 'orders' | 'my-orders' | 'calls' | 'my-calls' | 'stores' | 'daily' | 'my-daily' | 'sales-report';
@ -24,17 +25,22 @@ export interface ScreenDef {
Detail: (p: WiredDetailViewProps) => React.JSX.Element;
/** Singular noun for the detail title. */
noun: string;
roles?: string[];
adminOnly?: boolean;
}
const getRoles = (path: string) => routeConfig.find(r => r.path === `/${path}`)?.roles;
const getAdminOnly = (path: string) => routeConfig.find(r => r.path === `/${path}`)?.adminOnly;
export const SCREENS: ScreenDef[] = [
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order' },
{ key: 'my-orders', label: 'My Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'My Order' },
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call' },
{ key: 'my-calls', label: 'My Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'My Call' },
{ key: 'stores', label: 'Stores', icon: Store, View: StoresView, Detail: StoreDetail, noun: 'Store' },
{ key: 'daily', label: 'Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'Daily Log' },
{ key: 'my-daily', label: 'My Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'My Daily Log' },
{ key: 'sales-report', label: 'DSR', icon: FileText, View: null as any, Detail: null as any, noun: 'Sales Report' },
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order', roles: getRoles('orders'), adminOnly: getAdminOnly('orders') },
{ key: 'my-orders', label: 'My Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'My Order', roles: getRoles('my-orders'), adminOnly: getAdminOnly('my-orders') },
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call', roles: getRoles('calls'), adminOnly: getAdminOnly('calls') },
{ key: 'my-calls', label: 'My Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'My Call', roles: getRoles('my-calls'), adminOnly: getAdminOnly('my-calls') },
{ key: 'stores', label: 'Stores', icon: Store, View: StoresView, Detail: StoreDetail, noun: 'Store', roles: getRoles('stores'), adminOnly: getAdminOnly('stores') },
{ key: 'daily', label: 'Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'Daily Log', roles: getRoles('daily'), adminOnly: getAdminOnly('daily') },
{ key: 'my-daily', label: 'My Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'My Daily Log', roles: getRoles('my-daily'), adminOnly: getAdminOnly('my-daily') },
{ key: 'sales-report', label: 'DSR', icon: FileText, View: null as any, Detail: null as any, noun: 'Sales Report', roles: getRoles('sales-report'), adminOnly: getAdminOnly('sales-report') },
];
export function screenByKey(key: string | undefined): ScreenDef | undefined {