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 { ReportPage } from './screens/ReportPage'
import { DatasetsPage } from './screens/admin/DatasetsPage' import { DatasetsPage } from './screens/admin/DatasetsPage'
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage' 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() { function App() {
return ( return (
@ -21,35 +30,32 @@ function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ConsoleLayout />}> <Route element={<ConsoleLayout />}>
<Route path="orders" element={<OrdersPage />} /> {routeConfig.map((route, i) => {
<Route path="orders/:instanceId" element={<OrdersPage />} /> const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
<Route path="my-orders" element={<MyOrdersPage />} /> // Maintain nested route for Dataset items specifically
<Route path="my-orders/:instanceId" element={<MyOrdersPage />} /> if (path === 'admin/datasets') {
return (
<Route path="calls" element={<CallsPage />} /> <Route
<Route path="calls/:instanceId" element={<CallsPage />} /> key={i}
path={path}
<Route path="my-calls" element={<MyCallsPage />} /> element={<ProtectedRoute adminOnly={route.adminOnly} roles={route.roles}>{route.element}</ProtectedRoute>}
<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 path=":id" element={<DatasetItemsPage />} />
</Route> </Route>
);
}
<Route path="*" element={<Navigate to="/orders" replace />} /> return (
<Route
key={i}
path={path}
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
/>
);
})}
<Route path="*" element={<RootRedirect />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -122,6 +122,17 @@ export class ZinoClient {
this.setToken(null); 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. */ /** Decode the persisted JWT into a User (no network) or use the saved user. */
currentUser(): User | null { currentUser(): User | null {
if (!this.token) return 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) { export async function loginAll(email: string, password: string, orgId?: string) {
const res = await orderBookingClient.login(email, password, orgId); const res = await orderBookingClient.login(email, password, orgId);
ALL.forEach((c) => c.setToken(res.token)); 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; return res;
} }

View File

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

View File

@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode, useEffect } from 'react';
import { loginAll, logoutAll, currentToken } from '../api/clients'; import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients';
import { AuthCtx, type AuthValue } from './context'; import { AuthCtx, type AuthValue } from './context';
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
@ -7,9 +7,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [userEmail, setUserEmail] = useState<string | null>(() => { const [userEmail, setUserEmail] = useState<string | null>(() => {
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_user_email') : 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 = { const value: AuthValue = {
authed, authed,
isAdmin,
roles,
userEmail, userEmail,
login: async (email, password, orgId) => { login: async (email, password, orgId) => {
const res = await loginAll(email, password, orgId); const res = await loginAll(email, password, orgId);
@ -18,11 +36,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUserEmail(res.user.email); setUserEmail(res.user.email);
localStorage.setItem('krishna_sales_user_email', res.user.email); localStorage.setItem('krishna_sales_user_email', res.user.email);
} }
setIsAdmin(!!res.user?.is_admin);
setRoles(res.user?.roles ?? []);
}, },
logout: () => { logout: () => {
logoutAll(); logoutAll();
setAuthed(false); setAuthed(false);
setUserEmail(null); setUserEmail(null);
setIsAdmin(false);
setRoles([]);
localStorage.removeItem('krishna_sales_user_email'); 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 { export interface AuthValue {
authed: boolean; authed: boolean;
isAdmin: boolean;
roles: string[];
userEmail: string | null; userEmail: string | null;
login: (email: string, password: string, orgId?: string) => Promise<void>; login: (email: string, password: string, orgId?: string) => Promise<void>;
logout: () => 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 { SCREENS } from './tabs';
import { REPORT_MAP } from './ReportPage'; 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>. */ /** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
export function ConsoleLayout() { export function ConsoleLayout() {
const { authed, logout } = useAuth(); const { authed, logout, isAdmin, roles: userRoles } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = orderBookingClient.currentUser(); const user = orderBookingClient.currentUser();
@ -28,6 +41,13 @@ export function ConsoleLayout() {
if (!authed) return <Navigate to="/login" replace />; 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 ( return (
<div className="h-screen bg-[var(--secondary-color)] flex flex-col"> <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)' }}> <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> <span className="text-lg font-bold text-white tracking-wide leading-none">Krishna Sales</span>
</div> </div>
<nav className="flex items-center justify-end gap-2 h-full flex-1 mr-4"> <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 ( return (
<NavLink <NavLink
key={t.key} key={key}
to={`/${t.key}`} to={route.path}
className={({ isActive }) => className={({ isActive }) =>
cn( 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', '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,11 +77,12 @@ export function ConsoleLayout() {
) )
} }
> >
{t.label} {PATH_LABELS[route.path]}
</NavLink> </NavLink>
); );
})} })}
{canSeeReports && (
<div className="relative group flex items-center h-full"> <div className="relative group flex items-center h-full">
<button className={cn( <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", "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",
@ -78,7 +109,7 @@ export function ConsoleLayout() {
))} ))}
</div> </div>
</div> </div>
)}
</nav> </nav>
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2"> <div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
<button <button
@ -92,6 +123,7 @@ export function ConsoleLayout() {
<p className="text-sm font-bold text-gray-900 truncate">{user?.name || 'User'}</p> <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> <p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
</div> </div>
{isAdmin && (
<div className="px-2 pb-1 border-b border-gray-100 mb-1"> <div className="px-2 pb-1 border-b border-gray-100 mb-1">
<NavLink <NavLink
to="/admin/datasets" to="/admin/datasets"
@ -101,6 +133,7 @@ export function ConsoleLayout() {
Manage Datasets Manage Datasets
</NavLink> </NavLink>
</div> </div>
)}
<div className="px-2"> <div className="px-2">
<button <button
onClick={() => { onClick={() => {

View File

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

View File

@ -13,6 +13,7 @@ import {
DailyLogDetail, DailyLogDetail,
type WiredDetailViewProps, type WiredDetailViewProps,
} from '../components/dv'; } from '../components/dv';
import { routeConfig } from '../routesConfig';
export type ScreenKey = 'orders' | 'my-orders' | 'calls' | 'my-calls' | 'stores' | 'daily' | 'my-daily' | 'sales-report'; 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; Detail: (p: WiredDetailViewProps) => React.JSX.Element;
/** Singular noun for the detail title. */ /** Singular noun for the detail title. */
noun: string; 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[] = [ export const SCREENS: ScreenDef[] = [
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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 { export function screenByKey(key: string | undefined): ScreenDef | undefined {