Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

73
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,73 @@
import { createContext, use, useCallback, useMemo, useState, type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { HOME_ROUTE, type ConsoleRole, type SessionUser } from './roles';
import { clear, login as loginRequest, persist, restore } from './session';
import { disableDemo, enableDemo } from '@/demo';
interface AuthContextValue {
user: SessionUser | null;
signIn: (email: string, password: string) => Promise<SessionUser>;
/** Development only — see `src/demo`. Absent from a production build. */
signInAsDemo: (session: SessionUser) => void;
signOut: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionUser | null>(() => restore());
const signIn = useCallback(async (email: string, password: string) => {
const session = await loginRequest(email, password);
setUser(session);
return session;
}, []);
/**
* Seeds a session without touching the API. Demo mode is switched on at the
* same moment, so the fixture backend and the fixture session can never be
* out of step — a demo user paired with live data would be the worst of both.
*/
const signInAsDemo = useCallback((session: SessionUser) => {
if (!import.meta.env.DEV) return;
enableDemo();
persist(session);
setUser(session);
}, []);
const signOut = useCallback(() => {
if (import.meta.env.DEV) disableDemo();
clear();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, signIn, signInAsDemo, signOut }),
[user, signIn, signInAsDemo, signOut],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth(): AuthContextValue {
const context = use(AuthContext);
if (!context) throw new Error('useAuth must be used inside <AuthProvider>');
return context;
}
/**
* Route guard.
*
* A role that reaches a workspace it does not own is redirected to its own home
* rather than shown an error — the same partition the old console enforces, and
* the same one the three logins imply.
*/
export function RequireRole({ role, children }: { role: ConsoleRole; children: ReactNode }) {
const { user } = useAuth();
const location = useLocation();
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
if (user.role !== role) return <Navigate to={HOME_ROUTE[user.role]} replace />;
return <>{children}</>;
}

88
src/auth/roles.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Role resolution.
*
* The backend issues no session token: the login endpoints look the user up and
* return the record. So "signed in" here means "we hold a verified user object",
* and the role is DERIVED from that record rather than asserted by the client.
*
* When the backend does start issuing tokens, this file and `session.ts` are
* the only two that should need to change.
*/
import type { FiestaUser } from '@/api/types';
export type ConsoleRole = 'nearle-admin' | 'store-admin' | 'store-manager';
/**
* Roleids that reach the Store Admin workspace.
*
* 7 (Supervisor) and 8 (Cashier) must NEVER appear here: they are till roles,
* and a cashier landing in the tenant console is a privilege escalation, not a
* cosmetic bug.
*/
const STORE_ADMIN_ROLE_IDS: ReadonlySet<number> = new Set([1, 3]);
/** Till-only roles, listed so the exclusion is explicit rather than implied. */
export const TILL_ROLE_IDS: ReadonlySet<number> = new Set([7, 8]);
export interface SessionUser {
userid: number;
role: ConsoleRole;
name: string;
email: string;
roleid: number;
tenantid: number;
locationid: number;
issuperadmin: boolean;
}
/**
* `issuperadmin` is checked FIRST because it is server-derived. A roleid cannot
* be trusted to imply platform access, so the flag wins over the numeric split.
*/
export function resolveRole(user: Pick<FiestaUser, 'roleid' | 'issuperadmin'>): ConsoleRole {
if (user.issuperadmin === true) return 'nearle-admin';
if (STORE_ADMIN_ROLE_IDS.has(user.roleid)) return 'store-admin';
return 'store-manager';
}
export function toSessionUser(user: FiestaUser): SessionUser {
const name = [user.firstname, user.lastname].filter(Boolean).join(' ').trim();
return {
userid: user.userid,
role: resolveRole(user),
name: name || user.fullname || user.authname || user.email,
email: user.email,
roleid: user.roleid,
tenantid: user.tenantid,
locationid: user.locationid,
issuperadmin: user.issuperadmin === true,
};
}
/** Where each role lands when it has nowhere more specific to go. */
/**
* Where each role lands.
*
* These MUST be paths that actually resolve. The global `*` route redirects
* here, so a HOME_ROUTE pointing at a path with no matching route sends the
* router straight back to `*`, which sends it here again: an infinite redirect
* that React Router resolves by rendering nothing at all. It fails as a blank
* page with no console error, which is the worst way for a routing bug to
* present. `/admin/dashboard` did exactly that — it was the old console's name
* for the page this one calls Console.
*
* Each workspace also carries its own catch-all in `App.tsx`, so a wrong
* sub-path is absorbed there and never reaches the global one.
*/
export const HOME_ROUTE: Record<ConsoleRole, string> = {
'nearle-admin': '/nearle/stores',
'store-admin': '/admin/console',
'store-manager': '/store/console',
};
export const ROLE_LABEL: Record<ConsoleRole, string> = {
'nearle-admin': 'Nearle Admin',
'store-admin': 'Store Admin',
'store-manager': 'Store Manager',
};

81
src/auth/session.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* Sign-in and session persistence.
*
* There is no token to hold. `TenantWebLogin` returns the user record and
* nothing else, so the session IS that record. It is kept in sessionStorage
* rather than localStorage: a shared back-office machine should not stay signed
* in after the browser closes, and there is no server-side session to revoke.
*/
import { api, WEB } from '@/api/client';
import type { FiestaUser } from '@/api/types';
import { toSessionUser, type SessionUser } from './roles';
const STORAGE_KEY = 'nearle.session.v1';
/** Thrown when the account exists but has never had a password set. */
export class PasswordSetupRequiredError extends Error {
readonly userid: number;
constructor(userid: number) {
super('This account needs a password before it can sign in.');
this.name = 'PasswordSetupRequiredError';
this.userid = userid;
}
}
interface LoginBody {
authname: string;
password: string;
roleid?: number;
configid?: number;
}
/**
* Signs in against the web login endpoint.
*
* The handler answers HTTP 200 with `status: false` for a wrong password, and
* with `code: 409` plus `details.setup` when no password has been set, so the
* envelope is inspected rather than trusting the HTTP status.
*/
export async function login(email: string, password: string): Promise<SessionUser> {
const body: LoginBody = { authname: email.trim(), password };
const envelope = await api.envelope<FiestaUser & { setup?: boolean; userid?: number }>(
`${WEB}/users/tenant/weblogin`,
{ method: 'POST', body },
);
if (envelope.code === 409 && envelope.details?.setup === true) {
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
}
if (envelope.status !== true || !envelope.details) {
throw new Error(envelope.message ?? 'Sign-in failed');
}
const session = toSessionUser(envelope.details);
persist(session);
return session;
}
export function persist(session: SessionUser): void {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
export function restore(): SessionUser | null {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as SessionUser;
// A stored blob is only as trustworthy as the tab it came from; a shape
// check keeps a corrupted value from crashing the shell on boot.
if (typeof parsed?.userid !== 'number' || typeof parsed?.role !== 'string') return null;
return parsed;
} catch {
return null;
}
}
export function clear(): void {
sessionStorage.removeItem(STORAGE_KEY);
}