seperation of nearle admin
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<SessionUse
|
||||
}
|
||||
|
||||
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);
|
||||
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
|
||||
// know what to do with.
|
||||
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;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
108
src/auth/workspace.test.ts
Normal file
108
src/auth/workspace.test.ts
Normal 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
93
src/auth/workspace.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user