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 { 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
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
key={i}
|
||||||
<Route path="/orders/:instanceId" element={<OrdersPage />} />
|
path={path}
|
||||||
|
element={<ProtectedRoute adminOnly={route.adminOnly} roles={route.roles}>{route.element}</ProtectedRoute>}
|
||||||
<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 />}>
|
|
||||||
<Route path=":id" element={<DatasetItemsPage />} />
|
<Route path=":id" element={<DatasetItemsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
key={i}
|
||||||
|
path={path}
|
||||||
|
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/daily" replace />} />
|
<Route path="*" element={<RootRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 {
|
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
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 { 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,6 +143,7 @@ export function ConsoleLayout() {
|
|||||||
|
|
||||||
<div className="my-2 border-t border-border-subtle" />
|
<div className="my-2 border-t border-border-subtle" />
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/admin/datasets"
|
to="/admin/datasets"
|
||||||
onClick={() => setIsMenuOpen(false)}
|
onClick={() => setIsMenuOpen(false)}
|
||||||
@ -143,6 +155,7 @@ export function ConsoleLayout() {
|
|||||||
<Database size={20} className="shrink-0" />
|
<Database size={20} className="shrink-0" />
|
||||||
<span>Manage Datasets</span>
|
<span>Manage Datasets</span>
|
||||||
</NavLink>
|
</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
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user