roles based access given

This commit is contained in:
suryacp23 2026-07-30 11:54:40 +05:30
parent 5097d183fa
commit 90d892090d
10 changed files with 281 additions and 52 deletions

View File

@ -2,15 +2,16 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider' import { AuthProvider } from './auth/AuthProvider'
import { LoginPage } from './screens/LoginPage' import { LoginPage } from './screens/LoginPage'
import { ConsoleLayout } from './screens/ConsoleLayout' import { ConsoleLayout } from './screens/ConsoleLayout'
import { OrdersPage } from './screens/OrdersPage' import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
import { CallsPage } from './screens/CallsPage' import { useAuth } from './auth/context'
import { StoresPage } from './screens/StoresPage' import { routeConfig } from './routesConfig'
import { DailyLogsPage } from './screens/DailyLogsPage'
import { AllDailyLogsPage } from './screens/AllDailyLogsPage'
import { AnalyticsPage } from './screens/AnalyticsPage'
import { DailySalesReportPage } from './screens/DailySalesReportPage'
import { DatasetsPage } from './screens/admin/DatasetsPage'
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage' import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
function RootRedirect() {
const { roles, isAdmin } = useAuth();
return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
}
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
@ -18,31 +19,31 @@ function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ConsoleLayout />}> <Route element={<ConsoleLayout />}>
{routeConfig.map((route, i) => {
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
<Route path="/daily" element={<DailyLogsPage />} /> if (path === 'admin/datasets') {
<Route path="/daily/:instanceId" element={<DailyLogsPage />} /> 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="/orders" element={<OrdersPage />} /> return (
<Route path="/orders/:instanceId" element={<OrdersPage />} /> <Route
key={i}
<Route path="/calls" element={<CallsPage />} /> path={path}
<Route path="/calls/:instanceId" element={<CallsPage />} /> element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
/>
<Route path="/stores" element={<StoresPage />} /> );
<Route path="/stores/:instanceId" element={<StoresPage />} /> })}
<Route path="/all-daily" element={<AllDailyLogsPage />} />
<Route path="/all-daily/:instanceId" element={<AllDailyLogsPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/sales-report" element={<DailySalesReportPage />} />
<Route path="/admin/datasets" element={<DatasetsPage />}>
<Route path=":id" element={<DatasetItemsPage />} />
</Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/daily" replace />} /> <Route path="*" element={<RootRedirect />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>

View File

@ -110,6 +110,23 @@ 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 (typeof window !== 'undefined') {
try {
const storedUser = localStorage.getItem(TOKEN_KEY + '_user');
if (storedUser) {
const u = JSON.parse(storedUser);
const updated = { ...u, ...res };
localStorage.setItem(TOKEN_KEY + '_user', JSON.stringify(updated));
}
} catch (e) {
// ignore
}
}
return res;
}
/** Decode the persisted JWT into a User (no network). */ /** Decode the persisted JWT into a User (no network). */
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

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

View File

@ -1,19 +1,57 @@
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 }) {
const [authed, setAuthed] = useState(() => !!currentToken()); const [authed, setAuthed] = useState(() => !!currentToken());
const [userEmail, setUserEmail] = useState<string | null>(() => {
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_mobile_user_email') : null;
});
const [isAdmin, setIsAdmin] = useState<boolean>(() => {
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_mobile_is_admin') === 'true' : false;
});
const [roles, setRoles] = useState<string[]>(() => {
const r = typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_mobile_roles') : null;
return r ? JSON.parse(r) : [];
});
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,
login: async (email, password, orgId) => { login: async (email, password, orgId) => {
await loginAll(email, password, orgId); const res = await loginAll(email, password, orgId);
setAuthed(true); setAuthed(true);
if (res.user?.email) {
setUserEmail(res.user.email);
localStorage.setItem('krishna_sales_mobile_user_email', res.user.email);
}
const userRoles = res.user?.roles ?? [];
const userIsAdmin = !!res.user?.is_admin;
setIsAdmin(userIsAdmin);
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
setRoles(userRoles);
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles));
}, },
logout: () => { logout: () => {
logoutAll(); logoutAll();
setAuthed(false); setAuthed(false);
setUserEmail(null);
setIsAdmin(false);
setRoles([]);
localStorage.removeItem('krishna_sales_mobile_user_email');
localStorage.removeItem('krishna_sales_mobile_is_admin');
localStorage.removeItem('krishna_sales_mobile_roles');
}, },
}; };

View File

@ -0,0 +1,63 @@
import { 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 '/daily';
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,9 @@ import { createContext, useContext } from 'react';
export interface AuthValue { export interface AuthValue {
authed: boolean; authed: boolean;
isAdmin: boolean;
roles: string[];
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;
} }

86
src/routesConfig.tsx Normal file
View File

@ -0,0 +1,86 @@
import { OrdersPage } from './screens/OrdersPage'
import { CallsPage } from './screens/CallsPage'
import { StoresPage } from './screens/StoresPage'
import { DailyLogsPage } from './screens/DailyLogsPage'
import { AllDailyLogsPage } from './screens/AllDailyLogsPage'
import { AnalyticsPage } from './screens/AnalyticsPage'
import { DailySalesReportPage } from './screens/DailySalesReportPage'
import { DatasetsPage } from './screens/admin/DatasetsPage'
export const routeConfig = [
// Manager & Admin (and maybe Sales Officer for daily logs, adapting from krishna_sales where my-daily was Sales Officer and daily was Manager/Admin. In mobile we have daily and all-daily)
{
path: "/daily",
element: <DailyLogsPage />,
roles: ["Sales Officer", "Manager", "Admin"],
},
{
path: "/daily/:instanceId",
element: <DailyLogsPage />,
roles: ["Sales Officer", "Manager", "Admin"],
},
{
path: "/all-daily",
element: <AllDailyLogsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/all-daily/:instanceId",
element: <AllDailyLogsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/orders",
element: <OrdersPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
{
path: "/orders/:instanceId",
element: <OrdersPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
{
path: "/calls",
element: <CallsPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
{
path: "/calls/:instanceId",
element: <CallsPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
// Everyone
{
path: "/stores",
element: <StoresPage />,
roles: ["Sales Officer", "Manager", "Admin"],
},
{
path: "/stores/:instanceId",
element: <StoresPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
// Analytics
{
path: "/analytics",
element: <AnalyticsPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
// Manager + Admin
{
path: "/sales-report",
element: <DailySalesReportPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
// Admin Panel
{
path: "/admin/datasets",
element: <DatasetsPage />,
adminOnly: true,
},
];

View File

@ -5,11 +5,22 @@ import { cn } from '../lib/cn';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { SCREENS } from './tabs'; import { SCREENS } from './tabs';
import { routeConfig } from '../routesConfig';
import './screen.css'; import './screen.css';
const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => {
const route = routeConfig.find(r => r.path === `/${key}`);
if (!route) return true;
if (route.adminOnly && !isAdmin) return false;
if (!route.adminOnly && route.roles) {
return route.roles.some(r => roles.includes(r));
}
return true;
};
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */ /** Auth-guarded shell: slim navy top bar + fixed bottom 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 user = orderBookingClient.currentUser(); const user = orderBookingClient.currentUser();
@ -52,9 +63,9 @@ export function ConsoleLayout() {
<nav <nav
className="print:hidden fixed bottom-0 inset-x-0 z-20 h-16 pb-[env(safe-area-inset-bottom)] bg-app border-t border-border-subtle grid shadow-[0_-2px_16px_rgba(11,27,59,0.06)]" className="print:hidden fixed bottom-0 inset-x-0 z-20 h-16 pb-[env(safe-area-inset-bottom)] bg-app border-t border-border-subtle grid shadow-[0_-2px_16px_rgba(11,27,59,0.06)]"
style={{ gridTemplateColumns: `repeat(${SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report').length}, minmax(0, 1fr))` }} style={{ gridTemplateColumns: `repeat(${SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report' && canAccessScreen(t.key, isAdmin, userRoles)).length}, minmax(0, 1fr))` }}
> >
{SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report').map((t) => { {SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report' && canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
const Icon = t.icon; const Icon = t.icon;
return ( return (
<NavLink <NavLink
@ -112,7 +123,7 @@ export function ConsoleLayout() {
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
{SCREENS.map((t) => { {SCREENS.filter(t => canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
const Icon = t.icon; const Icon = t.icon;
return ( return (
<NavLink <NavLink
@ -132,17 +143,19 @@ export function ConsoleLayout() {
<div className="my-2 border-t border-border-subtle" /> <div className="my-2 border-t border-border-subtle" />
<NavLink {isAdmin && (
to="/admin/datasets" <NavLink
onClick={() => setIsMenuOpen(false)} to="/admin/datasets"
className={({ isActive }) => cn( onClick={() => setIsMenuOpen(false)}
"flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item", className={({ isActive }) => cn(
isActive ? "active" : "" "flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item",
)} isActive ? "active" : ""
> )}
<Database size={20} className="shrink-0" /> >
<span>Manage Datasets</span> <Database size={20} className="shrink-0" />
</NavLink> <span>Manage Datasets</span>
</NavLink>
)}
</div> </div>
<div className="p-4 border-t border-border-subtle shrink-0 flex flex-col gap-3 mb-[env(safe-area-inset-bottom)]"> <div className="p-4 border-t border-border-subtle shrink-0 flex flex-col gap-3 mb-[env(safe-area-inset-bottom)]">
@ -150,7 +163,10 @@ export function ConsoleLayout() {
<div className="flex flex-col px-1"> <div className="flex flex-col px-1">
<span className="text-sm font-medium text-foreground truncate">{user.name || 'User'}</span> <span className="text-sm font-medium text-foreground truncate">{user.name || 'User'}</span>
<span className="font-medium text-foreground truncate">{user.email || 'user@email.com'}</span> <span className="text-xs text-foreground/70 truncate">{user.email || 'user@email.com'}</span>
{userRoles && userRoles.length > 0 && (
<span className="text-xs font-semibold text-primary mt-0.5 truncate">{userRoles.join(', ')}</span>
)}
</div> </div>
)} )}
<button <button

View File

@ -36,12 +36,10 @@ export function LoginPage() {
<Card className="w-full max-w-[400px]"> <Card className="w-full max-w-[400px]">
<div className="flex flex-col gap-1 mb-5"> <div className="flex flex-col gap-1 mb-5">
<h1 className="m-0 text-2xl font-extrabold text-strong tracking-[-0.02em]">Krishna Sales</h1> <h1 className="m-0 text-2xl font-extrabold text-strong tracking-[-0.02em]">Krishna Sales</h1>
<p className="m-0 text-sm text-faint">Field Sales console · Sandbox {APP_ID}</p>
</div> </div>
<form onSubmit={submit} className="flex flex-col gap-4"> <form onSubmit={submit} className="flex flex-col gap-4">
<Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus /> <Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
<Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required /> <Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<Input label="Org ID" hint="Optional" value={orgId} onChange={(e) => setOrgId(e.target.value)} />
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>} {error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
<Button type="submit" full disabled={busy}> <Button type="submit" full disabled={busy}>
{busy ? 'Signing in…' : 'Sign in'} {busy ? 'Signing in…' : 'Sign in'}