Initial commit
This commit is contained in:
73
src/auth/AuthContext.tsx
Normal file
73
src/auth/AuthContext.tsx
Normal 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
88
src/auth/roles.ts
Normal 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
81
src/auth/session.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user