diff --git a/src/api/client.ts b/src/api/client.ts index e944cbd..9e9a0bb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -163,7 +163,17 @@ interface RequestOptions { signal?: AbortSignal; } -async function request(path: string, options: RequestOptions = {}): Promise { +/** + * One request, answered with the WHOLE envelope. + * + * `request` below is this plus "take the payload out", which is what nearly + * every caller wants. A handful need a field that sits BESIDE the payload — + * `invited` on onboarding is the one this was extracted for — and the only way + * to read one used to be `requestEnvelope`, which does none of the checking: no + * 401 handling, no `status: false`, no thrown `FiestaError`. So a caller that + * wanted one extra field had to give up all the error handling to get it. + */ +async function send(path: string, options: RequestOptions = {}): Promise> { const { method = 'GET', params, body, signal } = options; // There is exactly one path out of this function and it goes to `fetch`. @@ -223,10 +233,19 @@ async function request(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise { + const envelope = await send(path, options); return (envelope.details ?? envelope.data) as T; } @@ -288,6 +307,16 @@ export const api = { post: (path: string, body?: unknown, params?: Record) => request(path, { method: 'POST', body, params }), + /** + * A POST whose answer carries something beside the payload. + * + * Same checking as `post` — a failure still throws a `FiestaError` — so a + * caller reading one extra envelope field does not give up the error handling + * to get at it. `tenants/createtenantuser` needs `invited`. + */ + postEnvelope: (path: string, body?: unknown, params?: Record) => + send(path, { method: 'POST', body, params }), + put: (path: string, body?: unknown, params?: Record) => request(path, { method: 'PUT', body, params }), diff --git a/src/api/tenants.ts b/src/api/tenants.ts index a360ebc..b0cb994 100644 --- a/src/api/tenants.ts +++ b/src/api/tenants.ts @@ -101,6 +101,25 @@ export interface CreateBranchRequest { operatorid?: number; } +/** + * What happened to the invitation email, reported beside the tenant. + * + * Onboarding does not fail when the mail does not go — the business exists, and + * a merchant who exists and has not been emailed is a task for whoever + * onboarded them, not an onboarding to retry. So the two outcomes are returned + * together and the screen shows both. + */ +export interface InviteOutcome { + sent: boolean; + /** Why not — a missing `MAIL_HOST`, a rejecting relay. Absent when it sent. */ + reason?: string; +} + +export interface CreateTenantResult { + tenant: TenantInfo; + invite: InviteOutcome; +} + export interface TenantListQuery { pageno?: number; pagesize?: number; @@ -162,8 +181,34 @@ export const tenantsApi = { * `Tenants.Tenantlocations` and creates it in the same transaction, so a * tenant can never exist without somewhere to trade from. */ - createTenant: (body: CreateTenantRequest) => - api.post(`${WEB}/tenants/createtenantuser`, toTenantBody(body)), + createTenant: (body: CreateTenantRequest): Promise => + api + .postEnvelope(`${WEB}/tenants/createtenantuser`, toTenantBody(body)) + .then((envelope) => ({ + tenant: (envelope.details ?? {}) as TenantInfo, + invite: { + // `invited` absent means a backend that predates the invitation — + // read as "not sent" rather than as sent, so a deploy where the two + // halves have not met yet cannot claim an email that never left. + sent: envelope.invited === true, + ...(envelope.invitereason ? { reason: envelope.invitereason } : {}), + }, + })), + + /** + * Sends the first-password link again. + * + * Nearle staff only, and the backend enforces it rather than trusting this + * screen — see `ResendInvite` in `tenantController.go`. + * + * It refuses a merchant who already has a password, and says to send them to + * the sign-in page instead. That refusal is the whole point: an endpoint that + * re-issues a working password link for ANY account is a password reset, and + * nothing on this backend verifies identity well enough to have one. The + * message comes back as a `FiestaError` and is shown as written. + */ + resendInvite: (tenantid: number) => + api.post(`${WEB}/tenants/resendinvite`, { tenantid }), /** * Commissions a branch, and gives it somebody to run it. diff --git a/src/api/types.ts b/src/api/types.ts index 1b982f6..bb711d5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -54,6 +54,18 @@ export interface FiestaEnvelope { * a number Fiesta has already worked out. */ pricedetails?: { orderamount?: number; totaltaxamount?: number }; + /** + * Whether the new merchant was emailed their first-password link. + * `tenants/createtenantuser` only. + * + * Beside `details` rather than inside it because it is not a fact about the + * tenant — the tenant exists either way. It is what happened to a separate + * side effect, which the operator has to know about and can act on: resend, + * or correct the address. + */ + invited?: boolean; + /** Why it did not send. Absent when it did. */ + invitereason?: string; } /* ──────────────────────────────────────────────────────────────────────────── diff --git a/src/auth/session.ts b/src/auth/session.ts index 4ba29a2..5fad1fe 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -39,13 +39,18 @@ export class WrongConsoleError extends Error { } } -/** Thrown when the account exists but has never had a password set. */ +/** + * Thrown when the account exists but has never had a password set. + * + * It carried the userid, for the setup form that used to be on the login screen. + * Both are gone: a userid was all it took to set any account's password, and the + * probe below handed one to anybody who typed an email. The signed invitation + * replaced it, so there is nothing left for this to carry. + */ export class PasswordSetupRequiredError extends Error { - readonly userid: number; - constructor(userid: number) { + constructor() { super('This account needs a password before it can sign in.'); this.name = 'PasswordSetupRequiredError'; - this.userid = userid; } } @@ -82,16 +87,16 @@ const CONFIG_ID = 1; export async function login(email: string, password: string): Promise { const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID }; - const envelope = await api.envelope( - `${WEB}/users/applogin`, - { method: 'POST', body }, - ); + const envelope = await api.envelope(`${WEB}/users/applogin`, { + method: 'POST', + body, + }); // A brand-new account — `createtenantlocation` spawns branch logins with an - // empty password — answers `status: true` with a 409 and the userid to set - // one against. It is not a failure, it is the first step. + // empty password — answers `status: true` with a 409. Not a failure: the + // account is real and has simply never been used. if (envelope.code === 409 && envelope.details?.setup === true) { - throw new PasswordSetupRequiredError(envelope.details.userid ?? 0); + throw new PasswordSetupRequiredError(); } if (envelope.status !== true || !envelope.details) { @@ -163,7 +168,7 @@ export async function login(email: string, password: string): Promise { - const envelope = await api.envelope<{ setup?: boolean; userid?: number }>( - `${WEB}/users/applogin`, - { method: 'POST', body: { authname: email.trim(), configid: CONFIG_ID } }, - ); + const envelope = await api.envelope<{ setup?: boolean }>(`${WEB}/users/applogin`, { + method: 'POST', + body: { authname: email.trim(), configid: CONFIG_ID }, + }); if (envelope.code === 409 && envelope.details?.setup === true) { - return { state: 'setup', userid: envelope.details.userid ?? 0 }; + return { state: 'setup' }; } // "Password is required" — the account is real and has one. Exactly what we // wanted to learn, arriving as a refusal. @@ -195,36 +214,19 @@ export async function checkAccount(email: string): Promise { throw new Error(loginMessage(envelope.code, envelope.message)); } -/** The backend's floor, enforced here too so the refusal is instant. */ -export const MIN_PASSWORD_LENGTH = 6; - -/** - * Sets the password on an account that has never had one. +/* + * `MIN_PASSWORD_LENGTH` and `setInitialPassword` stood here. * - * `POST /users/setpassword`, which is public — it has to be. This runs when - * nobody is signed in and cannot be: the account has no password yet, so there - * is no way to obtain a session first. + * `setpassword` no longer accepts a userid — it requires a signed invitation + * token — so the function could not have worked, and the login screen no longer + * has a form that would call it. The page that does own that flow is + * `SetPasswordPage` on the MERCHANT console, which is where the invitation link + * points; this console never mints one. * - * It used to call `PUT /users/update`, which doubles as a password write but - * sits behind the session guard. Once `WEB_AUTH_REQUIRED` began defaulting on, - * that returned "a session token is required; sign in again" to somebody who - * could not sign in — sign-in needs a password, and setting the password needed - * a sign-in. Every branch login created with an empty password was unusable. - * - * The server refuses this on any account that already HAS a password, which is - * what makes leaving it open safe. It is a setup call, never a reset — nothing - * here verifies an old password, because there is no old password. - * - * Passwords are stored in clear on this backend. That is not something the - * console can fix, and it is the reason this flow exists at all rather than an - * emailed setup link. + * Nothing replaces it here on purpose. See the `'setup'` branch in + * `LoginPage.handleEmail` for what a Nearle staff account with no password is + * told instead. */ -export async function setInitialPassword(userid: number, password: string): Promise { - if (password.length < MIN_PASSWORD_LENGTH) { - throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`); - } - await api.post(`${WEB}/users/setpassword`, { userid, password }); -} /** * The backend's own words, where they are usable, and ours where they are not. diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx index cd836e2..4ff5ec7 100644 --- a/src/features/auth/LoginPage.tsx +++ b/src/features/auth/LoginPage.tsx @@ -9,18 +9,31 @@ import { Lock, Mail, Building2, - ShieldCheck, Sparkles, } from 'lucide-react'; import { useAuth } from '@/auth/AuthContext'; import { HOME_ROUTE } from '@/auth/roles'; -import { - checkAccount, - MIN_PASSWORD_LENGTH, - PasswordSetupRequiredError, - WrongConsoleError, - setInitialPassword, -} from '@/auth/session'; +import { checkAccount, PasswordSetupRequiredError, WrongConsoleError } from '@/auth/session'; + +/** + * What an account that has never had a password is told. + * + * Both halves are needed because the probe cannot tell the two apart. It posts + * an email with no password, and the answer says "exists, never used" without + * saying which kind of account it is — so whichever of these two people has + * typed their address, one of the sentences is for them. + * + * A merchant or shop user's way in is the invitation email, on the merchant + * console. A Nearle staff account has no invitation: nothing mints one for a + * platform login, so another administrator has to set it up. Saying that plainly + * beats a form that cannot work — this screen used to offer one, and the pair of + * that and a probe answering any email was how any merchant's account could be + * claimed by anyone who knew their address. + */ +const SETUP_NOTICE = + 'This account has never been used. Merchants and shop staff set their first password ' + + 'from the link in their invitation email, on the merchant console — ask for another if ' + + 'it is lost. A Nearle account needs another Nearle administrator to set it up.'; /** * Sign-in — KROW's full-bleed auth archetype. @@ -43,35 +56,25 @@ export function LoginPage() { const [password, setPassword] = useState(''); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [error, setError] = useState(null); - /* Separate from `error`: the wrong console is guidance, not a failure. */ + /* Separate from `error`: the wrong console is guidance, not a failure. So is + an account that has never been used — nothing is broken about it. */ const [notice, setNotice] = useState(null); const [isBusy, setIsBusy] = useState(false); /** - * Which of the three steps is on screen. + * Which of the two steps is on screen. * * Email first, always. A tenant made by `createtenantuser` and every branch * made by `createtenantlocation` is spawned with an EMPTY password, so their * owner's first sign-in cannot succeed — and a form that asks for the * password up front asks them for something that does not exist yet. They - * guess, it fails, and only then are they told to invent one. + * guess, it fails, and only then are they told what is actually wrong. * - * So the email is checked before a password field is ever shown, and the page - * goes straight to whichever step that account actually needs. This is what - * the old console does, and it is the right shape. + * So the email is checked before a password field is ever shown. An account + * with no password never reaches the second step; there was a third step here + * that set one, and it is gone — see `SETUP_NOTICE`. */ - const [step, setStep] = useState<'email' | 'password' | 'setup'>('email'); - - /** - * The userid the probe returned for an account with no password. - * - * Deliberately not a route: it exists only because a check just produced it, - * and a `/set-password` URL that could be opened cold would be a way to set - * any account's password from nothing. - */ - const [setupUserid, setSetupUserid] = useState(null); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); + const [step, setStep] = useState<'email' | 'password'>('email'); /* Each step brings its own panel into view. @@ -113,14 +116,16 @@ export function LoginPage() { async function handleEmail(event: FormEvent) { event.preventDefault(); setError(null); + /* Cleared on every attempt, not only on the one that sets it. The notice is + about the address that was typed — leaving it up while a second one is + checked says the wrong thing about that one. */ + setNotice(null); setIsBusy(true); try { const check = await checkAccount(email); if (check.state === 'setup') { - setSetupUserid(check.userid); - setNewPassword(''); - setConfirmPassword(''); - setStep('setup'); + setNotice(SETUP_NOTICE); + setPassword(''); } else { setStep('password'); } @@ -134,6 +139,7 @@ export function LoginPage() { async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(null); + setNotice(null); setIsBusy(true); try { const session = await signIn(email, password); @@ -143,10 +149,9 @@ export function LoginPage() { // check and the submit an administrator could have cleared the password, // and the account would otherwise dead-end on "Invalid Email". if (cause instanceof PasswordSetupRequiredError) { - setSetupUserid(cause.userid); - setNewPassword(''); - setConfirmPassword(''); - setStep('setup'); + setNotice(SETUP_NOTICE); + setPassword(''); + setStep('email'); } 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 @@ -164,49 +169,21 @@ export function LoginPage() { } } - /** Back to the email field, from either of the two second steps. */ + /** Back to the email field, from the password step. */ function restart() { setStep('email'); - setSetupUserid(null); setPassword(''); - setNewPassword(''); - setConfirmPassword(''); setError(null); + setNotice(null); } - /** - * Set the password, then sign in with it. - * - * Signing in afterwards rather than sending the person back to the form: they - * have just typed the password twice, and `applogin` is the only proof the - * write actually took. - */ - async function handleSetup(event: FormEvent) { - event.preventDefault(); - if (setupUserid === null) return; - setError(null); - - if (newPassword !== confirmPassword) { - setError('Those two passwords do not match.'); - return; - } - - setIsBusy(true); - try { - await setInitialPassword(setupUserid, newPassword); - const session = await signIn(email, newPassword); - navigate(HOME_ROUTE[session.role], { replace: true }); - } catch (cause) { - setError(cause instanceof Error ? cause.message : 'Could not set the password'); - } finally { - setIsBusy(false); - } - } - + /* `handleSetup` stood here, with its own password form. It is gone, and not + replaced: setting a first password happens on the MERCHANT console, at + `/set-password`, reached only from a signed invitation. A form on a public + login screen meant that knowing somebody's email address — a merchant's is + usually printed on their shopfront — was enough to claim their account. */ const canSubmit = step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy; - const canSetup = - newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy; return (
- {step !== 'setup' ? ( - - ) : ( - setIsPasswordVisible((visible) => !visible)} - onSubmit={handleSetup} - onBack={restart} - /> - )}
@@ -559,177 +519,17 @@ function FormPanel({ ); } -/* ──────────────────────────────────────────────────────────────────────────── - Right — first sign-in, setting the password - ──────────────────────────────────────────────────────────────────────────── */ +/* `SetupPanelProps`, `SetupPanel` and the `Hint` line they used stood here — a + third step with its own password fields, which set a first password and then + signed the person in. -interface SetupPanelProps { - email: string; - newPassword: string; - confirmPassword: string; - isPasswordVisible: boolean; - error: string | null; - isBusy: boolean; - canSubmit: boolean; - onNewPassword: (value: string) => void; - onConfirmPassword: (value: string) => void; - onToggleVisible: () => void; - onSubmit: (event: FormEvent) => void; - onBack: () => void; -} - -/** - * The second state of this page, not a second page. - * - * An account created by `createtenantuser` or `createtenantlocation` is spawned - * with an empty password, so its owner's first sign-in cannot succeed and there - * is no reset email to fall back on. Before this existed the page detected the - * condition and then told the person to go and find an administrator — for an - * account that was working as designed. - */ -function SetupPanel({ - email, - newPassword, - confirmPassword, - isPasswordVisible, - error, - isBusy, - canSubmit, - onNewPassword, - onConfirmPassword, - onToggleVisible, - onSubmit, - onBack, -}: SetupPanelProps) { - const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH; - const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword; - - return ( -
-
-
-
- - First sign-in -
-

- Choose a password -

-

- {email} has no password yet. Set one now and we will sign you straight in. -

-
- -
- } - action={ - - } - > - onNewPassword(event.target.value)} - placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`} - autoComplete="new-password" - autoFocus - required - aria-invalid={isTooShort} - style={{ ...inputStyle, paddingRight: 40 }} - /> - - - }> - onConfirmPassword(event.target.value)} - placeholder="Type it again" - autoComplete="new-password" - required - aria-invalid={isMismatched} - style={{ - ...inputStyle, - borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)', - }} - /> - - - - {isTooShort - ? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.` - : isMismatched - ? 'Those two do not match yet.' - : 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'} - - - {/* 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. */} - - - - - - -
-
- ); -} - -/** A quiet line under the fields — advisory, never an error. */ -function Hint({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); -} + All of it is gone. `setpassword` no longer accepts a userid, so the form could + not work; and it should not have existed anyway, because a public login screen + offering to set a password for any email that has none meant that knowing a + merchant's address was enough to claim their business. Setting a first + password now happens on the merchant console at `/set-password`, behind a + signed invitation link, and this console never mints one. See `SETUP_NOTICE` + for what somebody in that position is told here. */ /* ──────────────────────────────────────────────────────────────────────────── Shared form furniture diff --git a/src/features/nearle-admin/pages/OnboardTenantPage.tsx b/src/features/nearle-admin/pages/OnboardTenantPage.tsx index cc0149f..f64caa1 100644 --- a/src/features/nearle-admin/pages/OnboardTenantPage.tsx +++ b/src/features/nearle-admin/pages/OnboardTenantPage.tsx @@ -13,9 +13,10 @@ import { ArrowLeft, Building2, CheckCircle2, + Mail, MapPin, } from 'lucide-react'; -import { tenantsApi, type CreateTenantRequest } from '@/api/tenants'; +import { tenantsApi, type CreateTenantRequest, type InviteOutcome } from '@/api/tenants'; import { errorMessage } from '@/api/client'; import { PageBody } from '@/components/PageBody'; import { queryKeys } from '@/queries/keys'; @@ -112,7 +113,7 @@ export function OnboardTenantPage() { } if (mutation.isSuccess) { - const created = mutation.data; + const created = mutation.data.tenant; return ( @@ -152,6 +153,16 @@ export function OnboardTenantPage() { tenant's own list as a spreadsheet. + {/* Whether the merchant can actually get in. + Shown on the success screen rather than in a toast that + disappears: if the mail did not go, this is the only moment + anybody is looking, and the tenant is otherwise finished. */} + + {created?.tenantid && created?.locationid ? ( ); } + +/** + * Did the merchant get their way in? + * + * Onboarding creates the business and then emails its primary address a signed + * link to set a first password. The two are deliberately not one act: the + * invitation is sent AFTER the transaction commits, so a slow mail relay can + * never roll back a tenant somebody has already been told is live. + * + * Which means the mail can fail on its own, and this is where that is said. A + * merchant who exists and was never emailed cannot sign in at all — there is no + * other way to set a first password — and nothing else in the product would ever + * mention it. + */ +function InviteStatus({ + invite, + email, + tenantid, +}: { + invite: InviteOutcome; + email: string; + tenantid?: number; +}) { + const [sentAgain, setSentAgain] = useState(false); + + const resend = useMutation({ + mutationFn: (id: number) => tenantsApi.resendInvite(id), + onSuccess: () => setSentAgain(true), + }); + + if (invite.sent || sentAgain) { + return ( + + + + An invitation is on its way to {email}. They choose their own password from the link, + which is good for seven days. + + + ); + } + + return ( + + + + + No invitation was sent + + + + {/* The backend's own words. It distinguishes "no mail server is + configured" from "the relay refused this address", and those need + different people to fix them. */} + {invite.reason ?? 'The server did not say why.'} Until one is sent, nobody at{' '} + {email || 'this business'} can sign in — setting a first password happens only from the + invitation link. + + {resend.isError ? ( + + {errorMessage(resend.error)} + + ) : null} + {tenantid ? ( + +