seperation of nearle admin

This commit is contained in:
2026-09-28 15:43:07 +05:30
parent 153f87b577
commit c9616edad9
7 changed files with 397 additions and 0 deletions

View File

@@ -54,6 +54,31 @@ COPY . .
ARG VITE_API_BASE="https://fiesta.nearle.app" ARG VITE_API_BASE="https://fiesta.nearle.app"
ENV VITE_API_BASE=$VITE_API_BASE 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 RUN npm run build
# Stage 2 — serve # Stage 2 — serve

View File

@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { Spinner } from '@astryxdesign/core/Spinner'; import { Spinner } from '@astryxdesign/core/Spinner';
import { RequireRole, useAuth } from '@/auth/AuthContext'; import { RequireRole, useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles'; import { HOME_ROUTE } from '@/auth/roles';
import { IS_PLATFORM } from '@/auth/workspace';
import { withStaleChunkRecovery } from '@/lib/staleChunk'; import { withStaleChunkRecovery } from '@/lib/staleChunk';
import { LoginPage } from '@/features/auth/LoginPage'; import { LoginPage } from '@/features/auth/LoginPage';
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell'; import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
@@ -88,7 +89,34 @@ export function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
{/*
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 */} {/* Nearle Admin — the platform workspace */}
{IS_PLATFORM ? (
<Route <Route
path="/nearle" path="/nearle"
element={ element={
@@ -121,8 +149,11 @@ export function App() {
workspace and loop. */} workspace and loop. */}
<Route path="*" element={<Navigate to="/nearle/stores" replace />} /> <Route path="*" element={<Navigate to="/nearle/stores" replace />} />
</Route> </Route>
) : null}
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */} {/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
{!IS_PLATFORM ? (
<>
<Route <Route
path="/admin" path="/admin"
element={ element={
@@ -185,7 +216,13 @@ export function App() {
<Route path="setup" element={<StoreSetupPage />} /> <Route path="setup" element={<StoreSetupPage />} />
<Route path="*" element={<Navigate to="/store/console" replace />} /> <Route path="*" element={<Navigate to="/store/console" replace />} />
</Route> </Route>
</>
) : 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. */}
<Route <Route
path="*" path="*"
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />} element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}

View File

@@ -76,3 +76,37 @@ test('signing out leaves nothing behind', () => {
clear(); clear();
assert.equal(restore(), null); 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`);
}
});

View File

@@ -21,6 +21,23 @@ import { api, WEB } from '@/api/client';
import type { FiestaUser } from '@/api/types'; import type { FiestaUser } from '@/api/types';
import { toSessionUser, type SessionUser } from './roles'; import { toSessionUser, type SessionUser } from './roles';
import { SESSION_STORAGE_KEY } from './token'; 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. */ /** Thrown when the account exists but has never had a password set. */
export class PasswordSetupRequiredError extends Error { export class PasswordSetupRequiredError extends Error {
@@ -101,6 +118,25 @@ export async function login(email: string, password: string): Promise<SessionUse
} }
const session = { ...toSessionUser(envelope.details), token: envelope.token }; const session = { ...toSessionUser(envelope.details), token: envelope.token };
/*
* The wrong console, refused before anything is written down.
*
* Nearle's staff sign in at the platform site and merchants at the merchant
* one, and neither may sign in at the other. The role is not known until the
* password has been checked — `applogin` returns it — so the refusal can only
* happen here, after credentials are verified and before a session exists.
*
* The ORDER is the whole point. `persist` used to run first, so refusing
* afterwards 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. Throwing before the write
* leaves storage untouched and the login screen exactly as it was.
*/
if (!isAllowedHere(session.role)) {
throw new WrongConsoleError(wrongConsoleMessage(session.role));
}
persist(session); persist(session);
return session; return session;
} }
@@ -243,6 +279,14 @@ export function restore(): SessionUser | null {
// Returning null sends them to the sign-in screen, which is a state people // Returning null sends them to the sign-in screen, which is a state people
// know what to do with. // know what to do with.
if (typeof parsed.token !== 'string' || parsed.token.trim() === '') return null; if (typeof parsed.token !== 'string' || parsed.token.trim() === '') return null;
// The same refusal as sign-in, applied to what is already in storage.
//
// A session stored before the two consoles were split, or one held on a
// site whose workspace flag has since changed, belongs to somebody this
// build serves no routes for. Restoring it renders a shell with a nav built
// from a role that has nowhere to go — so it is dropped and they are asked
// to sign in, which is where they learn which console is theirs.
if (!isAllowedHere(parsed.role)) return null;
return parsed; return parsed;
} catch { } catch {
return null; return null;

108
src/auth/workspace.test.ts Normal file
View File

@@ -0,0 +1,108 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { IS_PLATFORM, isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
import type { ConsoleRole } from './roles';
/*
Two consoles, one source.
Nearle's own staff sign in at platform.nearledaily.com; merchants and their
branch users at app.nearledaily.com. Neither site accepts the other's accounts,
and the refusal is a refusal — not a redirect carrying a half-made session
across a domain boundary.
These run against the DEFAULT build, which is `merchant`. That default is itself
the thing most worth pinning: an unset flag is the ordinary state of a developer
machine and of any deployment not yet told about the split, and the wrong
default would turn every one of them into a platform console.
*/
const ROLES: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
test('an unset flag builds the merchant console', () => {
// 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',
);
});

93
src/auth/workspace.ts Normal file
View File

@@ -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<Workspace, ReadonlySet<ConsoleRole>> = {
platform: new Set<ConsoleRole>(['nearle-admin']),
merchant: new Set<ConsoleRole>(['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<Workspace, string> = {
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';
}
}

View File

@@ -8,6 +8,7 @@ import {
Loader2, Loader2,
Lock, Lock,
Mail, Mail,
Building2,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
@@ -17,6 +18,7 @@ import {
checkAccount, checkAccount,
MIN_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH,
PasswordSetupRequiredError, PasswordSetupRequiredError,
WrongConsoleError,
setInitialPassword, setInitialPassword,
} from '@/auth/session'; } from '@/auth/session';
@@ -41,6 +43,8 @@ export function LoginPage() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/* Separate from `error`: the wrong console is guidance, not a failure. */
const [notice, setNotice] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false); const [isBusy, setIsBusy] = useState(false);
/** /**
@@ -143,6 +147,15 @@ export function LoginPage() {
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
setStep('setup'); 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 { } else {
setError(cause instanceof Error ? cause.message : 'Sign-in failed'); setError(cause instanceof Error ? cause.message : 'Sign-in failed');
} }
@@ -234,6 +247,7 @@ export function LoginPage() {
password={password} password={password}
isPasswordVisible={isPasswordVisible} isPasswordVisible={isPasswordVisible}
error={error} error={error}
notice={notice}
isBusy={isBusy} isBusy={isBusy}
canSubmit={canSubmit} canSubmit={canSubmit}
onEmail={setEmail} onEmail={setEmail}
@@ -401,6 +415,8 @@ interface FormPanelProps {
password: string; password: string;
isPasswordVisible: boolean; isPasswordVisible: boolean;
error: string | null; error: string | null;
/** The wrong-console sentence. Rendered beside `error`, never as one. */
notice: string | null;
isBusy: boolean; isBusy: boolean;
canSubmit: boolean; canSubmit: boolean;
onEmail: (value: string) => void; onEmail: (value: string) => void;
@@ -416,6 +432,7 @@ function FormPanel({
password, password,
isPasswordVisible, isPasswordVisible,
error, error,
notice,
isBusy, isBusy,
canSubmit, canSubmit,
onEmail, onEmail,
@@ -508,6 +525,7 @@ function FormPanel({
something the system does not do. */} something the system does not do. */}
<ErrorNote message={error} /> <ErrorNote message={error} />
<ConsoleNote message={notice} />
<SubmitButton <SubmitButton
canSubmit={canSubmit} canSubmit={canSubmit}
@@ -683,6 +701,9 @@ function SetupPanel({
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'} : 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
</Hint> </Hint>
{/* 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. */}
<ErrorNote message={error} /> <ErrorNote message={error} />
<SubmitButton <SubmitButton
@@ -738,6 +759,41 @@ function ErrorNote({ message }: { message: string | null }) {
); );
} }
/**
* The right account at the wrong console.
*
* Deliberately not `ErrorNote`. Nothing failed: the password was correct and
* the account is in good standing — it simply belongs to the other site. Shown
* in red beside "Sign-in failed", it sends people to reset a password that
* works, or to ask an administrator to fix an account that is not broken.
*
* `role="status"` rather than `role="alert"` for the same reason: a screen
* reader should read this as information, not as something that went wrong.
*/
function ConsoleNote({ message }: { message: string | null }) {
if (!message) return null;
return (
<div
role="status"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 9,
padding: '11px 13px',
borderRadius: 12,
background: 'var(--color-surface-sunken, #F4F5F7)',
border: '1px solid var(--color-line, #E6E8EB)',
color: 'var(--color-ink-2, #52606D)',
fontSize: 13,
lineHeight: 1.55,
}}
>
<Building2 size={16} style={{ flex: 'none', marginTop: 1 }} />
{message}
</div>
);
}
function SubmitButton({ function SubmitButton({
canSubmit, canSubmit,
isBusy, isBusy,