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"
|
||||
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
|
||||
|
||||
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 { 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() {
|
||||
<Routes>
|
||||
<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 */}
|
||||
{IS_PLATFORM ? (
|
||||
<Route
|
||||
path="/nearle"
|
||||
element={
|
||||
@@ -121,8 +149,11 @@ export function App() {
|
||||
workspace and loop. */}
|
||||
<Route path="*" element={<Navigate to="/nearle/stores" replace />} />
|
||||
</Route>
|
||||
) : null}
|
||||
|
||||
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
|
||||
{!IS_PLATFORM ? (
|
||||
<>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
@@ -185,7 +216,13 @@ export function App() {
|
||||
<Route path="setup" element={<StoreSetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
||||
</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
|
||||
path="*"
|
||||
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
|
||||
/**
|
||||
@@ -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. */}
|
||||
|
||||
<ErrorNote message={error} />
|
||||
<ConsoleNote message={notice} />
|
||||
|
||||
<SubmitButton
|
||||
canSubmit={canSubmit}
|
||||
@@ -683,6 +701,9 @@ function SetupPanel({
|
||||
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
|
||||
</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} />
|
||||
|
||||
<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({
|
||||
canSubmit,
|
||||
isBusy,
|
||||
|
||||
Reference in New Issue
Block a user