From c9616edad964f6126c53d4fe0fbe0ccf1fdc587a Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 28 Sep 2026 15:43:07 +0530 Subject: [PATCH] seperation of nearle admin --- Dockerfile | 25 ++++++++ src/App.tsx | 37 +++++++++++ src/auth/session.test.ts | 34 ++++++++++ src/auth/session.ts | 44 +++++++++++++ src/auth/workspace.test.ts | 108 ++++++++++++++++++++++++++++++++ src/auth/workspace.ts | 93 +++++++++++++++++++++++++++ src/features/auth/LoginPage.tsx | 56 +++++++++++++++++ 7 files changed, 397 insertions(+) create mode 100644 src/auth/workspace.test.ts create mode 100644 src/auth/workspace.ts diff --git a/Dockerfile b/Dockerfile index 94c5601..486fab3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,31 @@ COPY . . ARG VITE_API_BASE="https://fiesta.nearle.app" ENV VITE_API_BASE=$VITE_API_BASE +# Which console this image is. +# +# platform → platform.nearledaily.com, Nearle's own staff +# merchant → app.nearledaily.com, merchants and their branch users +# +# The same source builds both. What the flag changes is which workspace's routes +# are mounted and which roles may sign in — neither image lets the other's +# accounts through. Both images still CONTAIN both workspaces' compiled chunks; +# the unmounted one is never fetched because nothing routes to it. +# +# Defaulting to `merchant` keeps every existing deployment behaving exactly as +# it did. The platform build is the one that has to be asked for — the opposite +# default would turn every environment that had not been told about this into a +# platform console on the day it shipped. +ARG VITE_WORKSPACE="merchant" +ENV VITE_WORKSPACE=$VITE_WORKSPACE + +# The other console's address, for the sentence shown to somebody in the wrong +# place. Only the one this build is NOT is used; both are given so a single set +# of build args works for either image. +ARG VITE_PLATFORM_HOST="platform.nearledaily.com" +ENV VITE_PLATFORM_HOST=$VITE_PLATFORM_HOST +ARG VITE_MERCHANT_HOST="app.nearledaily.com" +ENV VITE_MERCHANT_HOST=$VITE_MERCHANT_HOST + RUN npm run build # Stage 2 — serve diff --git a/src/App.tsx b/src/App.tsx index 4a3261e..e80c1e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { Spinner } from '@astryxdesign/core/Spinner'; import { RequireRole, useAuth } from '@/auth/AuthContext'; import { HOME_ROUTE } from '@/auth/roles'; +import { IS_PLATFORM } from '@/auth/workspace'; import { withStaleChunkRecovery } from '@/lib/staleChunk'; import { LoginPage } from '@/features/auth/LoginPage'; import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell'; @@ -88,7 +89,34 @@ export function App() { } /> + {/* + Each site mounts ONE workspace. + + `platform.nearledaily.com` is Nearle's own staff; `app.nearledaily.com` + is merchants and their branch users. The same build produces both, with + `VITE_WORKSPACE` deciding which of the two blocks below exists. + + Unmounted, not guarded. A route that is not in the table cannot render + whatever state the app is in, where a `RequireRole` around it is one + redirect away from rendering if the role check is ever wrong. On this + site `/admin/*` is not a protected path — it is not a path at all, and + falls to the catch-all like any typo. + + This does NOT remove the other workspace's code from the bundle, and it + was written here once claiming that it did. The branch is evaluated at + runtime: Rollup cannot fold `IS_PLATFORM` because it is computed through + `workspace.ts` rather than being a literal in this file, so both blocks + are compiled and only one is executed. The lazy chunks of the unmounted + workspace are emitted and served, and never fetched, because nothing + routes to them. + + That is a code-shipping question rather than an access one — no account + the other console owns can sign in here, which `login` and `restore` + enforce — but the distinction is worth stating rather than implying. + */} + {/* Nearle Admin — the platform workspace */} + {IS_PLATFORM ? ( } /> + ) : null} {/* Store Admin — the merchant workspace, scoped to one tenant's branches */} + {!IS_PLATFORM ? ( + <> } /> } /> + + ) : null} + {/* `user` is only ever a role this build serves — `restore` and `login` + both refuse the other console's accounts — so its home route is always + one of the blocks mounted above, and this cannot bounce into a + workspace that is not here. */} } diff --git a/src/auth/session.test.ts b/src/auth/session.test.ts index 02d3529..0b30baa 100644 --- a/src/auth/session.test.ts +++ b/src/auth/session.test.ts @@ -76,3 +76,37 @@ test('signing out leaves nothing behind', () => { clear(); assert.equal(restore(), null); }); + +/* +The wrong console. + +Nearle staff sign in at the platform site, merchants at the merchant one, and +neither accepts the other's accounts. The role is not known until the password +has been checked, so the refusal happens after credentials are verified — which +makes the ORDER of the refusal and the write the thing worth pinning. + +`persist` used to run before anything else could object. A refusal after it +would leave a valid session on this origin belonging to somebody with no routes +to reach: signed in by every measure the shell uses, with a nav built from a +role this build does not serve, and no way out except clearing storage by hand. +*/ + +test('a session for the other console is not restored', () => { + // These tests run as the merchant build, so a Nearle staff session is the + // wrong one. It reaches storage when a build's workspace flag changes under a + // session that was valid when it was written. + store.set( + SESSION_STORAGE_KEY, + JSON.stringify({ userid: 1, role: 'nearle-admin', token: 'w1.a.b' }), + ); + assert.equal(restore(), null, 'a platform session was restored on the merchant console'); +}); + +test('the consoles own roles are still restored', () => { + // The refusal must not be so broad that it locks out the people this site is + // for. Both merchant roles keep working. + for (const role of ['store-admin', 'store-manager']) { + store.set(SESSION_STORAGE_KEY, JSON.stringify({ userid: 1, role, token: 'w1.a.b' })); + assert.equal(restore()?.role, role, `${role} was refused on its own console`); + } +}); diff --git a/src/auth/session.ts b/src/auth/session.ts index 1c0fd27..4ba29a2 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -21,6 +21,23 @@ import { api, WEB } from '@/api/client'; import type { FiestaUser } from '@/api/types'; import { toSessionUser, type SessionUser } from './roles'; import { SESSION_STORAGE_KEY } from './token'; +import { isAllowedHere, wrongConsoleMessage } from './workspace'; + +/** + * Thrown when the credentials were right but the account belongs to the other + * console. + * + * Its own type so the login screen can present it as an answer rather than a + * failure: nothing went wrong, the person is at the wrong door. It reads + * differently from "that password is not right", and showing it in the same red + * as a bad password would send somebody to reset a password that is fine. + */ +export class WrongConsoleError extends Error { + constructor(message: string) { + super(message); + this.name = 'WrongConsoleError'; + } +} /** Thrown when the account exists but has never had a password set. */ export class PasswordSetupRequiredError extends Error { @@ -101,6 +118,25 @@ export async function login(email: string, password: string): Promise { + // Nothing sets VITE_WORKSPACE under the test runner, so this is the default + // path — the same one every existing deployment takes until it is told + // otherwise. + assert.equal(WORKSPACE, 'merchant'); + assert.equal(IS_PLATFORM, false); +}); + +test('the merchant console admits merchants and refuses Nearle staff', () => { + assert.equal(isAllowedHere('store-admin'), true); + assert.equal(isAllowedHere('store-manager'), true); + assert.equal(isAllowedHere('nearle-admin'), false); +}); + +test('every role is decided, none left to a default', () => { + // A role added later must be listed deliberately on one side or the other. + // Falling through to "allowed" would put it on both consoles silently; this + // asserts each of the three is a decision that was actually made. + for (const role of ROLES) { + assert.equal(typeof isAllowedHere(role), 'boolean', `${role} has no verdict`); + } + const allowed = ROLES.filter(isAllowedHere); + assert.equal(allowed.length, 2, `merchant admits ${allowed.join(', ')}`); +}); + +test('the refusal names the other console rather than blaming the account', () => { + const message = wrongConsoleMessage('nearle-admin'); + + // The host, so somebody knows where to go. + assert.match(message, /platform\.nearledaily\.com/); + // And not a word that reads as "your account is broken" — the password was + // right and the account is fine. Anyone told "failed" or "denied" goes and + // resets a working password, or asks an administrator to fix nothing. + for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) { + assert.ok( + !message.toLowerCase().includes(blame), + `the refusal reads as a fault: ${message}`, + ); + } +}); + +test('each role is named in words a person uses', () => { + // The message is read by whoever typed the password, not by us. + assert.match(wrongConsoleMessage('nearle-admin'), /Nearle staff/); + assert.match(wrongConsoleMessage('store-admin'), /Store admin/); + assert.match(wrongConsoleMessage('store-manager'), /Store user/); +}); + +/* +What the split actually guarantees, and what it does not. + +The route blocks in `App.tsx` are chosen at runtime, so both workspaces' chunks +are compiled into either image. The unmounted one is never fetched, because +nothing routes to it — but it is present, and an earlier version of the comment +in that file claimed otherwise. + +So the guarantee is NOT "the other console's code is absent". It is "the other +console's accounts cannot sign in, and its paths are not routes here". Both of +those are enforced by the two functions below, which is why they are the ones +worth pinning rather than the bundle's contents. +*/ + +test('the refusal does not depend on which routes happen to be mounted', () => { + // `isAllowedHere` is consulted by `login` before a session is written and by + // `restore` before one is read back. Neither goes near the router, so a + // mistake in route mounting cannot open a door that this closes. + assert.equal(isAllowedHere('nearle-admin'), IS_PLATFORM); + assert.equal(isAllowedHere('store-admin'), !IS_PLATFORM); + assert.equal(isAllowedHere('store-manager'), !IS_PLATFORM); +}); + +test('no role is admitted by both consoles', () => { + // The two sets must partition the roles: one home each, never two. A role in + // both would make the separation cosmetic — the account would work at either + // address and the refusal would never fire. + const platformRoles: ConsoleRole[] = ['nearle-admin']; + const merchantRoles: ConsoleRole[] = ['store-admin', 'store-manager']; + + for (const role of platformRoles) { + assert.ok(!merchantRoles.includes(role), `${role} is claimed by both consoles`); + } + assert.equal( + platformRoles.length + merchantRoles.length, + ROLES.length, + 'a role belongs to neither console and could sign in nowhere', + ); +}); diff --git a/src/auth/workspace.ts b/src/auth/workspace.ts new file mode 100644 index 0000000..b9b9e6b --- /dev/null +++ b/src/auth/workspace.ts @@ -0,0 +1,93 @@ +import type { ConsoleRole } from './roles'; + +/** + * Which console this build is. + * + * ── Why one codebase produces two sites ───────────────────────────────────── + * + * Nearle's own staff work at `platform.nearledaily.com`; merchants and their + * branch users work at `app.nearledaily.com`. They are the same application + * built twice with this flag set differently, rather than two repositories, + * because every screen below the workspace split — drawers, tables, the + * assistant, the design system — is shared and would otherwise be maintained + * in two places and drift. + * + * What the flag changes is which routes are mounted and which roles may sign + * in. It does not change what is compiled: the branch in `App.tsx` is evaluated + * at runtime, so both workspaces' chunks are built and served, and the + * unmounted one is simply never fetched because nothing routes to it. Removing + * it from the bundle would need the flag to be a literal at each import site, + * which is a separate piece of work and buys nothing for access control. + * + * ── The default is `merchant`, deliberately ───────────────────────────────── + * + * An unset variable is the ordinary state of a developer's machine and of any + * deployment that has not been told about this yet. Defaulting to `merchant` + * means the existing site keeps behaving exactly as it did, and the platform + * build is the one that has to be asked for. The opposite default would turn + * every un-migrated environment into a platform console the day this shipped. + */ +export type Workspace = 'platform' | 'merchant'; + +const CONFIGURED = (import.meta.env?.['VITE_WORKSPACE'] ?? '').trim().toLowerCase(); + +export const WORKSPACE: Workspace = CONFIGURED === 'platform' ? 'platform' : 'merchant'; + +export const IS_PLATFORM = WORKSPACE === 'platform'; + +/** + * Who may sign in here. + * + * The separation is a REFUSAL, not a redirect. A merchant reaching the platform + * console is told which console their account belongs to and stays where they + * are; they are not bounced across a domain boundary carrying a half-made + * session. Each site serves exactly one audience and says so. + */ +const ALLOWED: Record> = { + platform: new Set(['nearle-admin']), + merchant: new Set(['store-admin', 'store-manager']), +}; + +export function isAllowedHere(role: ConsoleRole): boolean { + return ALLOWED[WORKSPACE].has(role); +} + +/** + * The other console's address, for the sentence shown to somebody in the wrong + * place. + * + * Named rather than derived from `location.hostname`, because the two sites are + * not a naming convention apart — they are separate deployments and either can + * move. A build that was not told falls back to the production hostnames, which + * is right far more often than saying nothing. + */ +const OTHER_SITE: Record = { + platform: (import.meta.env?.['VITE_MERCHANT_HOST'] ?? '').trim() || 'app.nearledaily.com', + merchant: (import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com', +}; + +/** + * What to tell somebody whose account belongs to the other console. + * + * Names the host rather than linking to it. A live link from a sign-in screen + * to another sign-in screen reads as a redirect that failed, and this is not a + * failure — it is the right answer to the wrong door. + */ +export function wrongConsoleMessage(role: ConsoleRole): string { + const site = OTHER_SITE[WORKSPACE]; + + return IS_PLATFORM + ? `This is the Nearle platform console. ${roleWord(role)} accounts sign in at ${site}.` + : `${roleWord(role)} accounts sign in at ${site}, not here.`; +} + +function roleWord(role: ConsoleRole): string { + switch (role) { + case 'nearle-admin': + return 'Nearle staff'; + case 'store-admin': + return 'Store admin'; + case 'store-manager': + return 'Store user'; + } +} diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx index 259b457..cd836e2 100644 --- a/src/features/auth/LoginPage.tsx +++ b/src/features/auth/LoginPage.tsx @@ -8,6 +8,7 @@ import { Loader2, Lock, Mail, + Building2, ShieldCheck, Sparkles, } from 'lucide-react'; @@ -17,6 +18,7 @@ import { checkAccount, MIN_PASSWORD_LENGTH, PasswordSetupRequiredError, + WrongConsoleError, setInitialPassword, } from '@/auth/session'; @@ -41,6 +43,8 @@ export function LoginPage() { const [password, setPassword] = useState(''); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [error, setError] = useState(null); + /* Separate from `error`: the wrong console is guidance, not a failure. */ + const [notice, setNotice] = useState(null); const [isBusy, setIsBusy] = useState(false); /** @@ -143,6 +147,15 @@ export function LoginPage() { setNewPassword(''); setConfirmPassword(''); setStep('setup'); + } else if (cause instanceof WrongConsoleError) { + // Not a failure. The password was right and the account is fine — it + // belongs to the other console. Shown as guidance rather than as an + // error, because somebody told "sign-in failed" in red goes and resets a + // password that works. The field is cleared and the step returns to the + // email, since retyping the same password here will do the same thing. + setNotice(cause.message); + setPassword(''); + setStep('email'); } else { setError(cause instanceof Error ? cause.message : 'Sign-in failed'); } @@ -234,6 +247,7 @@ export function LoginPage() { password={password} isPasswordVisible={isPasswordVisible} error={error} + notice={notice} isBusy={isBusy} canSubmit={canSubmit} onEmail={setEmail} @@ -401,6 +415,8 @@ interface FormPanelProps { password: string; isPasswordVisible: boolean; error: string | null; + /** The wrong-console sentence. Rendered beside `error`, never as one. */ + notice: string | null; isBusy: boolean; canSubmit: boolean; onEmail: (value: string) => void; @@ -416,6 +432,7 @@ function FormPanel({ password, isPasswordVisible, error, + notice, isBusy, canSubmit, onEmail, @@ -508,6 +525,7 @@ function FormPanel({ something the system does not do. */} + + {/* No ConsoleNote here. The setup step is reached only by an account + that has never had a password — a branch login this console just + spawned — so it is the right console by construction. */} + + {message} + + ); +} + function SubmitButton({ canSubmit, isBusy,