seperation of nearle admin
This commit is contained in:
25
Dockerfile
25
Dockerfile
@@ -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
|
||||||
|
|||||||
37
src/App.tsx
37
src/App.tsx
@@ -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 />}
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user