auto mail

This commit is contained in:
2026-09-29 16:51:30 +05:30
parent 8c878f842a
commit 5a18d56933
7 changed files with 372 additions and 312 deletions

View File

@@ -163,7 +163,17 @@ interface RequestOptions {
signal?: AbortSignal;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
/**
* 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<T>(path: string, options: RequestOptions = {}): Promise<FiestaEnvelope<T>> {
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<T>(path: string, options: RequestOptions = {}): Promise<T
);
}
// Most handlers put the payload in `details`, but a handful answer with
// `data` instead — `products/getallproducts` and `products/create` among the
// ones the console calls (`productController.go:400,206`). Reading only
// `details` handed those two callers `undefined` with no error anywhere.
return envelope;
}
/**
* The payload, unwrapped.
*
* Most handlers put it in `details`, but a handful answer with `data` instead —
* `products/getallproducts` and `products/create` among the ones the console
* calls (`productController.go:400,206`). Reading only `details` handed those
* two callers `undefined` with no error anywhere.
*/
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const envelope = await send<T>(path, options);
return (envelope.details ?? envelope.data) as T;
}
@@ -288,6 +307,16 @@ export const api = {
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(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: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
send<T>(path, { method: 'POST', body, params }),
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'PUT', body, params }),

View File

@@ -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<TenantInfo>(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
createTenant: (body: CreateTenantRequest): Promise<CreateTenantResult> =>
api
.postEnvelope<TenantInfo>(`${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<unknown>(`${WEB}/tenants/resendinvite`, { tenantid }),
/**
* Commissions a branch, and gives it somebody to run it.

View File

@@ -54,6 +54,18 @@ export interface FiestaEnvelope<T> {
* 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;
}
/* ────────────────────────────────────────────────────────────────────────────

View File

@@ -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<SessionUser> {
const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
const envelope = await api.envelope<FiestaUser & { setup?: boolean; userid?: number }>(
`${WEB}/users/applogin`,
{ method: 'POST', body },
);
const envelope = await api.envelope<FiestaUser & { setup?: boolean }>(`${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<SessionUse
*
* 409 + status false → no such account ("Invalid Email")
* 403 → account deactivated
* 409 + status true → exists, no password set (carries the userid)
* 409 + status true → exists, no password set
* 401 + status true → exists, has a password ("Password is required")
*
* The last one is the whole trick: a password-less attempt against a real
@@ -176,16 +181,30 @@ export async function login(email: string, password: string): Promise<SessionUse
* password, so the probe reveals nothing that was not already available with
* one more field filled in.
*/
export type AccountCheck = { state: 'password' } | { state: 'setup'; userid: number };
/**
* `setup` no longer carries a userid, and that is the point.
*
* It used to, and the pair of that and `setpassword` taking a bare userid was an
* account takeover: this probe answers an email with NO password, so anyone
* could POST a merchant's primary address — usually printed on their shopfront —
* receive their userid, set a password and own the business. The server has
* stopped returning it and stopped accepting it.
*
* So `setup` now means only "this account exists and has never been used", and
* this console has nothing to offer for it. Setting a first password happens on
* the MERCHANT console, from a signed link emailed at onboarding; there is no
* such link for a Nearle staff account, and the screen says so.
*/
export type AccountCheck = { state: 'password' } | { state: 'setup' };
export async function checkAccount(email: string): Promise<AccountCheck> {
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<AccountCheck> {
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<void> {
if (password.length < MIN_PASSWORD_LENGTH) {
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`);
}
await api.post<unknown>(`${WEB}/users/setpassword`, { userid, password });
}
/**
* The backend's own words, where they are usable, and ours where they are not.

View File

@@ -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<string | null>(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<string | null>(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<number | null>(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 (
<div
@@ -240,7 +217,6 @@ export function LoginPage() {
form vertically beside the brand. A plain block here would collapse
to its content's height and un-centre it. */}
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
{step !== 'setup' ? (
<FormPanel
step={step}
email={email}
@@ -256,22 +232,6 @@ export function LoginPage() {
onSubmit={step === 'email' ? handleEmail : handleSubmit}
onBack={restart}
/>
) : (
<SetupPanel
email={email}
newPassword={newPassword}
confirmPassword={confirmPassword}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSetup}
onNewPassword={setNewPassword}
onConfirmPassword={setConfirmPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSetup}
onBack={restart}
/>
)}
</div>
</div>
</div>
@@ -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 (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
marginBottom: 12,
padding: '4px 10px',
borderRadius: 999,
background: 'var(--color-surface-subtle)',
border: '1px solid var(--color-line)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-brand)',
}}
>
<ShieldCheck size={13} />
First sign-in
</div>
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 26,
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
Choose a password
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{email} has no password yet. Set one now and we will sign you straight in.
</p>
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field
label="New password"
htmlFor="new-password"
icon={<Lock size={15} />}
action={
<button
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
}
>
<input
id="new-password"
type={isPasswordVisible ? 'text' : 'password'}
value={newPassword}
onChange={(event) => onNewPassword(event.target.value)}
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
autoComplete="new-password"
autoFocus
required
aria-invalid={isTooShort}
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
<input
id="confirm-password"
type={isPasswordVisible ? 'text' : 'password'}
value={confirmPassword}
onChange={(event) => 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)',
}}
/>
</Field>
<Hint>
{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.'}
</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
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Setting it…"
label="Set password and sign in"
/>
</form>
<button type="button" onClick={onBack} style={backLinkStyle}>
Use a different account
</button>
</div>
</div>
);
}
/** A quiet line under the fields — advisory, never an error. */
function Hint({ children }: { children: ReactNode }) {
return (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
{children}
</p>
);
}
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

View File

@@ -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 (
<PageBody measure="reading">
<VStack gap={3}>
@@ -152,6 +153,16 @@ export function OnboardTenantPage() {
tenant&apos;s own list as a spreadsheet.
</Text>
{/* 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. */}
<InviteStatus
invite={mutation.data.invite}
email={form.primaryemail.trim()}
{...(created?.tenantid ? { tenantid: created.tenantid } : {})}
/>
{created?.tenantid && created?.locationid ? (
<Card padding={3} variant="transparent">
<StoreQrPanel
@@ -436,3 +447,98 @@ export function OnboardTenantPage() {
</PageBody>
);
}
/**
* 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 (
<HStack
align="center"
gap={1}
padding={1.5}
style={{
background: 'var(--color-success-muted, #eef8f3)',
borderRadius: 8,
color: 'var(--color-success, #10b981)',
border: '1px solid var(--color-success-border, #cceadd)',
}}
>
<Mail size={15} style={{ flex: 'none' }} />
<Text type="body" size="sm" style={{ color: 'inherit' }}>
An invitation is on its way to {email}. They choose their own password from the link,
which is good for seven days.
</Text>
</HStack>
);
}
return (
<VStack
gap={1.5}
padding={1.5}
style={{
background: 'var(--color-warning-muted, #fdf6e8)',
borderRadius: 8,
border: '1px solid var(--color-warning-border, #f3e0b5)',
}}
>
<HStack align="center" gap={1} style={{ color: 'var(--color-warning, #b8860b)' }}>
<AlertCircle size={15} style={{ flex: 'none' }} />
<Text type="body" size="sm" weight="semibold" style={{ color: 'inherit' }}>
No invitation was sent
</Text>
</HStack>
<Text type="body" size="sm" color="secondary">
{/* 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.
</Text>
{resend.isError ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{errorMessage(resend.error)}
</Text>
) : null}
{tenantid ? (
<HStack>
<Button
type="button"
variant="secondary"
label={resend.isPending ? 'Sending…' : 'Send invitation'}
isLoading={resend.isPending}
onClick={() => resend.mutate(tenantid)}
/>
</HStack>
) : null}
</VStack>
);
}

View File

@@ -6,7 +6,10 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert, Truck } from 'lucide-react';
import { IndianRupee, Mail, QrCode, ShoppingCart, Store, TriangleAlert, Truck } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { errorMessage } from '@/api/client';
import { tenantsApi } from '@/api/tenants';
import { DataState } from '@/components/DataState';
import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
@@ -217,6 +220,7 @@ export function StoreDetailPage() {
title={tenant?.tenantname ?? 'Tenant'}
actions={
<HStack gap={1}>
{tenantid ? <ResendInviteAction tenantid={tenantid} /> : null}
<Button
label="Delivery partner"
variant="secondary"
@@ -338,3 +342,65 @@ export function StoreDetailPage() {
</VStack>
);
}
/**
* Sends the merchant's first-password link again.
*
* ── Why this button exists ──────────────────────────────────────────────────
*
* Onboarding emails the primary address a signed link, and that link is the ONLY
* way to set a first password — the sign-in screen no longer offers a form,
* because a public form for it meant that knowing a merchant's address was
* enough to claim their business. So an invitation that is lost in a spam folder,
* sent to a typo'd address, or left to expire over a holiday leaves a merchant
* who cannot get in at all, and before this the only repair was editing the
* database.
*
* ── Why it is not a password reset ──────────────────────────────────────────
*
* The backend refuses a merchant who already HAS a password and says to send
* them to the sign-in page instead. That refusal is deliberate: re-issuing a
* working password link for any account on request is a password reset, and
* nothing here verifies identity well enough to have one. The refusal is shown
* as written rather than translated, because it names the business and says what
* to do next.
*/
function ResendInviteAction({ tenantid }: { tenantid: number }) {
const resend = useMutation({
mutationFn: () => tenantsApi.resendInvite(tenantid),
});
/* The server's words on failure, ours on success. It distinguishes an
unconfigured mail host from an address matching no login from an account
already set up, and each needs a different thing done about it. */
const note = resend.isSuccess
? 'Invitation sent.'
: resend.isError
? errorMessage(resend.error)
: null;
return (
<VStack gap={0.5} align="end">
<Button
label={resend.isPending ? 'Sending…' : 'Resend invite'}
variant="secondary"
icon={<Mail size={14} />}
isLoading={resend.isPending}
onClick={() => resend.mutate()}
/>
{note ? (
<Text
type="body"
size="sm"
style={{
maxWidth: 280,
textAlign: 'right',
color: resend.isError ? 'var(--color-error, #d64545)' : 'var(--color-ink-3, #657081)',
}}
>
{note}
</Text>
) : null}
</VStack>
);
}