roles based access given
This commit is contained in:
parent
5097d183fa
commit
90d892090d
59
src/App.tsx
59
src/App.tsx
@ -2,15 +2,16 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './auth/AuthProvider'
|
||||
import { LoginPage } from './screens/LoginPage'
|
||||
import { ConsoleLayout } from './screens/ConsoleLayout'
|
||||
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'
|
||||
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
|
||||
import { useAuth } from './auth/context'
|
||||
import { routeConfig } from './routesConfig'
|
||||
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
||||
|
||||
function RootRedirect() {
|
||||
const { roles, isAdmin } = useAuth();
|
||||
return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
@ -18,31 +19,31 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ConsoleLayout />}>
|
||||
{routeConfig.map((route, i) => {
|
||||
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
|
||||
|
||||
<Route path="/daily" element={<DailyLogsPage />} />
|
||||
<Route path="/daily/:instanceId" element={<DailyLogsPage />} />
|
||||
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/orders/:instanceId" element={<OrdersPage />} />
|
||||
|
||||
<Route path="/calls" element={<CallsPage />} />
|
||||
<Route path="/calls/:instanceId" element={<CallsPage />} />
|
||||
|
||||
<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 />}>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Route
|
||||
key={i}
|
||||
path={path}
|
||||
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/daily" replace />} />
|
||||
<Route path="*" element={<RootRedirect />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
|
||||
@ -110,6 +110,23 @@ export class ZinoClient {
|
||||
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). */
|
||||
currentUser(): User | null {
|
||||
if (!this.token) return null;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ export interface User {
|
||||
email: string;
|
||||
roles: string[];
|
||||
groups: string[];
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
|
||||
@ -1,19 +1,57 @@
|
||||
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 }) {
|
||||
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 = {
|
||||
authed,
|
||||
isAdmin,
|
||||
roles,
|
||||
userEmail,
|
||||
login: async (email, password, orgId) => {
|
||||
await loginAll(email, password, orgId);
|
||||
const res = await loginAll(email, password, orgId);
|
||||
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: () => {
|
||||
logoutAll();
|
||||
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');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
63
src/auth/ProtectedRoute.tsx
Normal file
63
src/auth/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
@ -2,6 +2,9 @@ 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;
|
||||
}
|
||||
|
||||
86
src/routesConfig.tsx
Normal file
86
src/routesConfig.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
@ -5,11 +5,22 @@ import { cn } from '../lib/cn';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||
import { SCREENS } from './tabs';
|
||||
import { routeConfig } from '../routesConfig';
|
||||
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>. */
|
||||
export function ConsoleLayout() {
|
||||
const { authed, logout } = useAuth();
|
||||
const { authed, logout, isAdmin, roles: userRoles } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const user = orderBookingClient.currentUser();
|
||||
|
||||
@ -52,9 +63,9 @@ export function ConsoleLayout() {
|
||||
|
||||
<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)]"
|
||||
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;
|
||||
return (
|
||||
<NavLink
|
||||
@ -112,7 +123,7 @@ export function ConsoleLayout() {
|
||||
</button>
|
||||
</div>
|
||||
<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;
|
||||
return (
|
||||
<NavLink
|
||||
@ -132,6 +143,7 @@ export function ConsoleLayout() {
|
||||
|
||||
<div className="my-2 border-t border-border-subtle" />
|
||||
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
to="/admin/datasets"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
@ -143,6 +155,7 @@ export function ConsoleLayout() {
|
||||
<Database size={20} className="shrink-0" />
|
||||
<span>Manage Datasets</span>
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
<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>
|
||||
)}
|
||||
<button
|
||||
|
||||
@ -36,12 +36,10 @@ export function LoginPage() {
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<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>
|
||||
<p className="m-0 text-sm text-faint">Field Sales console · Sandbox {APP_ID}</p>
|
||||
</div>
|
||||
<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="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>}
|
||||
<Button type="submit" full disabled={busy}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user