auto mail
This commit is contained in:
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user