domain change
This commit is contained in:
@@ -103,7 +103,17 @@ export function toSessionUser(user: FiestaUser): SessionUser {
|
||||
* sub-path is absorbed there and never reaches the global one.
|
||||
*/
|
||||
export const HOME_ROUTE: Record<ConsoleRole, string> = {
|
||||
'nearle-admin': '/nearle/stores',
|
||||
// `/nearle/stores` is not a route in this application any more — Nearle's own
|
||||
// staff have their own, `nearle-platform`. Pointing at it would be exactly
|
||||
// the failure the comment above describes: the router sends an unknown path
|
||||
// to `*`, `*` sends it back here, and React Router resolves the loop by
|
||||
// rendering nothing — a blank page with no console error.
|
||||
//
|
||||
// It cannot be reached today, because `login` and `restore` both refuse this
|
||||
// role before a session exists. It is `/login` rather than that unreachable
|
||||
// path so that if one of those two checks is ever weakened, the result is a
|
||||
// sign-in screen rather than a white screen nobody can diagnose.
|
||||
'nearle-admin': '/login',
|
||||
'store-admin': '/admin/console',
|
||||
'store-manager': '/store/console',
|
||||
};
|
||||
|
||||
@@ -1,108 +1,57 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { IS_PLATFORM, isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
|
||||
import { isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
|
||||
import type { ConsoleRole } from './roles';
|
||||
|
||||
/*
|
||||
Two consoles, one source.
|
||||
Who may sign in to the merchant console.
|
||||
|
||||
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.
|
||||
Merchants and their branch users sign in here. Nearle's own staff have their own
|
||||
application — `nearle-platform`, a separate repository with its own deploy — and
|
||||
a staff account is turned away rather than redirected: no session crosses to
|
||||
another site half-made.
|
||||
|
||||
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.
|
||||
The role is not known until the password has been checked, so the refusal
|
||||
happens after credentials are verified. What matters is that it happens BEFORE
|
||||
the session is written: `login` refuses first and persists second, and `restore`
|
||||
applies the same check to what is already in storage. A staff member who typed a
|
||||
correct password must not end up signed in here with no routes to reach.
|
||||
*/
|
||||
|
||||
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.
|
||||
test('this build is the merchant console, with nothing to configure', () => {
|
||||
// A constant, not a build flag. The flag existed only while one codebase
|
||||
// served both consoles; a variable now would be a way to deploy this
|
||||
// application as something it is not.
|
||||
assert.equal(WORKSPACE, 'merchant');
|
||||
assert.equal(IS_PLATFORM, false);
|
||||
});
|
||||
|
||||
test('the merchant console admits merchants and refuses Nearle staff', () => {
|
||||
test('both merchant roles are admitted and Nearle staff are not', () => {
|
||||
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(', ')}`);
|
||||
// A role added later must be listed deliberately. Falling through to allowed
|
||||
// would quietly admit it to the merchant console.
|
||||
const admitted = ROLES.filter(isAllowedHere);
|
||||
assert.deepEqual(admitted, ['store-admin', 'store-manager']);
|
||||
});
|
||||
|
||||
test('the refusal names the other console rather than blaming the account', () => {
|
||||
test('the refusal names the platform 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.
|
||||
// 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 at all.
|
||||
for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) {
|
||||
assert.ok(
|
||||
!message.toLowerCase().includes(blame),
|
||||
`the refusal reads as a fault: ${message}`,
|
||||
);
|
||||
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.
|
||||
test('the refused role is named in words a person uses', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,90 +1,63 @@
|
||||
import type { ConsoleRole } from './roles';
|
||||
|
||||
/**
|
||||
* Which console this build is.
|
||||
* Who may sign in to the merchant console.
|
||||
*
|
||||
* ── Why one codebase produces two sites ─────────────────────────────────────
|
||||
* ── Why this is a constant ──────────────────────────────────────────────────
|
||||
*
|
||||
* 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.
|
||||
* There was a `VITE_WORKSPACE` build flag here while one codebase served both
|
||||
* consoles and had to be told which it was. That is over: Nearle's own staff
|
||||
* have their own application, `nearle-platform`, and this repository is the
|
||||
* merchant console and nothing else. A flag would only be a way to deploy this
|
||||
* application as something it is not.
|
||||
*
|
||||
* 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 separation is a refusal, not a redirect ─────────────────────────────
|
||||
*
|
||||
* ── The default is `merchant`, deliberately ─────────────────────────────────
|
||||
* A Nearle staff account is turned away here with a sentence naming where it
|
||||
* belongs. It is not bounced to the other site carrying a half-made session.
|
||||
*
|
||||
* 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.
|
||||
* The role is not known until the password has been checked — `applogin`
|
||||
* returns it — so the refusal can only happen after credentials are verified.
|
||||
* `login` applies it BEFORE the session is written and `restore` applies it to
|
||||
* what is already stored. That order is the point: a session persisted first
|
||||
* and refused afterwards leaves somebody signed in by every measure the shell
|
||||
* uses, with a nav built from a role this application serves no routes for.
|
||||
*/
|
||||
export type Workspace = 'platform' | 'merchant';
|
||||
export const WORKSPACE = 'merchant' as const;
|
||||
|
||||
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']),
|
||||
};
|
||||
const ALLOWED: ReadonlySet<ConsoleRole> = new Set<ConsoleRole>(['store-admin', 'store-manager']);
|
||||
|
||||
export function isAllowedHere(role: ConsoleRole): boolean {
|
||||
return ALLOWED[WORKSPACE].has(role);
|
||||
return ALLOWED.has(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The other console's address, for the sentence shown to somebody in the wrong
|
||||
* place.
|
||||
* The platform console's address, for the sentence shown to a staff member who
|
||||
* signs in at the wrong site.
|
||||
*
|
||||
* 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.
|
||||
* A build variable rather than a constant, because the two are separate
|
||||
* deployments and either can move. The fallback is the production hostname,
|
||||
* which is right far more often than saying nothing would be.
|
||||
*/
|
||||
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',
|
||||
};
|
||||
const PLATFORM_HOST =
|
||||
(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.
|
||||
* Names the host rather than linking to it. A live link from one sign-in screen
|
||||
* to another 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.`;
|
||||
return `${roleWord(role)} accounts sign in at ${PLATFORM_HOST}, not here.`;
|
||||
}
|
||||
|
||||
function roleWord(role: ConsoleRole): string {
|
||||
switch (role) {
|
||||
case 'nearle-admin':
|
||||
return 'Nearle staff';
|
||||
// Unreachable: both merchant roles are allowed here, so neither reaches the
|
||||
// refusal. Present because the switch is exhaustive over the union and a
|
||||
// missing arm would be a type error the day a role is added.
|
||||
case 'store-admin':
|
||||
return 'Store admin';
|
||||
case 'store-manager':
|
||||
|
||||
Reference in New Issue
Block a user