auto mail generation

This commit is contained in:
2026-09-29 16:48:10 +05:30
parent f1299fd053
commit e3fc144d05
12 changed files with 880 additions and 298 deletions

View File

@@ -5,6 +5,7 @@ import { RequireRole, useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import { withStaleChunkRecovery } from '@/lib/staleChunk';
import { LoginPage } from '@/features/auth/LoginPage';
import { SetPasswordPage } from '@/features/auth/SetPasswordPage';
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
@@ -79,6 +80,10 @@ export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* Reached only from an invitation email, and public by necessity — the
account has no password yet, so there is no way to obtain a session
first. The token in the query string is the whole credential. */}
<Route path="/set-password" element={<SetPasswordPage />} />
{/* Nearle staff have their own application.

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

@@ -37,6 +37,24 @@ export interface CreateStaffRequest {
status?: string;
}
/**
* What happened to somebody's first-password invitation.
*
* Reported beside the person rather than folded into success or failure: they are
* hired either way, and an unsent invitation is a task — resend, or correct the
* address — not a hire to retry.
*/
export interface InviteOutcome {
sent: boolean;
/** Why not — an unconfigured mail host, a rejecting relay. Absent when it sent. */
reason?: string;
}
export interface CreateStaffResult {
person: StaffInfo;
invite: InviteOutcome;
}
export interface UpdateStaffRequest {
userid: number;
firstname?: string;
@@ -85,7 +103,42 @@ export const staffApi = {
unassign: (body: { tenantid: number; userid: number }) =>
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
/**
* Hires somebody, and emails them their way in.
*
* The account is created with NO password — nothing on this path sets one —
* and since the sign-in screen stopped offering to set a first password, the
* emailed link is the only way in. So the outcome of that email comes back
* beside the person, and the screen shows it: an invitation that did not send
* means somebody has been added to the directory who cannot sign in, and
* nothing else in the product would ever mention it.
*/
create: (body: CreateStaffRequest): Promise<CreateStaffResult> =>
api.postEnvelope<StaffInfo>(`${WEB}/users/create`, body).then((envelope) => ({
person: (envelope.details ?? {}) as StaffInfo,
invite: {
// Absent means a backend that predates the invitation — read as "not
// sent", so a half-deployed pair cannot claim an email that never left.
sent: envelope.invited === true,
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
},
})),
/**
* Sends somebody's first-password link again.
*
* By `userid`, not by tenant: the owner's invitation is reachable by tenantid
* because a business has one owner, but staff and branch logins are many, so
* an operator chasing one names the person.
*
* The backend refuses anybody who already has a password and says to send them
* to the sign-in page instead. That refusal is the point — an endpoint that
* re-issues a working password link for any account on request is a password
* reset, and nothing here verifies identity well enough to have one. It arrives
* as a `FiestaError` and is shown as written.
*/
resendInvite: (userid: number) =>
api.post<unknown>(`${WEB}/tenants/resendinvite`, { userid }),
/**
* Update a person.

View File

@@ -2,6 +2,7 @@
import { api, WEB } from './client';
import type { AppLocation } from './deliveries';
import type { InviteOutcome } from './people';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
@@ -101,6 +102,20 @@ export interface CreateBranchRequest {
operatorid?: number;
}
/**
* A commissioned branch, and what happened to its operator's invitation.
*
* The branch exists either way. A login that was not emailed is a task for
* whoever commissioned it — resend, or fix the address — and not a branch to
* create again.
*/
export interface CreateBranchResult {
branch: TenantLocation;
invite: InviteOutcome;
/** The login the branch spawned, for a resend. 0 when a person was placed. */
operatorUserid: number;
}
export interface TenantListQuery {
pageno?: number;
pagesize?: number;
@@ -176,9 +191,24 @@ export const tenantsApi = {
* created row, and the new `locationid` is what a QR code and every
* follow-up write need. `createlocation` answers 201 with a message and no
* `details` at all.
*
* `invite` reports the spawned login's first-password email. It is `sent:
* false` with NO reason when an `operatorid` was named — nothing was created,
* so there was nothing to send, and that is not a failure to report.
*/
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
createBranch: (body: CreateBranchRequest): Promise<CreateBranchResult> =>
api
.postEnvelope<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body)
.then((envelope) => ({
branch: (envelope.details ?? {}) as TenantLocation,
invite: {
sent: envelope.invited === true,
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
},
// The spawned login, so a failed invitation can be resent without
// hunting for the row by eye. 0 when an existing person was placed.
operatorUserid: envelope.inviteuserid ?? 0,
})),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),

View File

@@ -54,6 +54,26 @@ export interface FiestaEnvelope<T> {
* a number Fiesta has already worked out.
*/
pricedetails?: { orderamount?: number; totaltaxamount?: number };
/**
* Whether a newly created account was emailed its first-password link.
* On the three endpoints that create one: `users/create`,
* `tenants/createstaff` and `tenants/createtenantlocation`.
*
* Beside `details` rather than inside it because it is not a fact about the
* person or the branch — those exist either way. It is what happened to a
* separate side effect, and the only one an operator can act on.
*/
invited?: boolean;
/** Why it did not send. Absent when it did. */
invitereason?: string;
/**
* Who to resend to, from `tenants/createtenantlocation` only.
*
* `details` there is the branch row, and the login the branch spawned lives in
* `app_users` — so this is the only way to name it. 0 when an existing person
* was placed and no account was created.
*/
inviteuserid?: number;
}
/* ────────────────────────────────────────────────────────────────────────────
@@ -947,6 +967,19 @@ export interface StaffInfo {
locationid?: number;
locationname?: string;
status?: string;
/**
* Whether they have ever chosen a password.
*
* Every person here is created with an empty one and emailed a link to set it.
* Until that link is used they are in this list, in every branch picker, and
* cannot sign in — and `status` does not say so, because an Active account with
* no password is refused at the login screen like any other.
*
* So `false` is the row that needs an action: a lost invitation, waiting to be
* sent again. Optional because a backend that predates the column sends
* nothing, and the directory then simply does not make the claim.
*/
issetup?: boolean;
}
/**

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 is waiting for its invitation to be 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,29 @@ 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". The
* way in is the invitation emailed at onboarding, and the screen says so rather
* than offering a form.
*/
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.
@@ -215,15 +233,34 @@ export const MIN_PASSWORD_LENGTH = 6;
* 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.
*
* ── Why it takes a token and not a userid ───────────────────────────────────
*
* It took a userid, which `checkAccount` obtained by POSTing an email with no
* password. That pair was an account takeover: read a merchant's primary
* address off their shopfront, POST it, receive their userid, set a password,
* own the business. No guessing at any step, and the empty-password check was
* no defence — an un-set-up account is exactly what such an attacker wants.
*
* The invitation emailed at onboarding replaced it. The userid lives inside a
* signature the server produced, so knowing an email is no longer enough and
* neither is knowing a userid. This is now reached only from `/set-password`,
* with a token out of the link.
*
* 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.
* console can fix.
*/
export async function setInitialPassword(userid: number, password: string): Promise<void> {
export async function setInitialPassword(token: string, 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 });
if (!token.trim()) {
// Reached when somebody opens /set-password with no `t` in the URL — a
// truncated link, or a copy that lost the query string. Said here so the
// screen can explain it rather than the server answering "not an
// invitation link" to a request that was never going to work.
throw new Error('This link is incomplete. Use the full link from your invitation email.');
}
await api.post<unknown>(`${WEB}/users/setpassword`, { token, password });
}
/**

View File

@@ -9,18 +9,11 @@ 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';
/**
* Sign-in — KROW's full-bleed auth archetype.
@@ -43,35 +36,27 @@ 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 not been set up — the way in is the invitation email,
and nothing about that is broken. */
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
* that has no password never reaches the second step: it is told to use its
* invitation, because setting a first password happens on `SetPasswordPage`
* behind a signed link and nowhere else.
*/
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 +98,24 @@ 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
names the address that was typed — leaving it up while a second, valid
address is being checked tells the reader the wrong thing about it. */
setNotice(null);
setIsBusy(true);
try {
const check = await checkAccount(email);
if (check.state === 'setup') {
setSetupUserid(check.userid);
setNewPassword('');
setConfirmPassword('');
setStep('setup');
// An account that exists and has never been used. It is NOT offered a
// password form any more: this branch is reached by anybody who types
// an email, so a form here meant that knowing a merchant's address —
// usually printed on their shopfront — was enough to claim their
// account. The way in is the invitation, and this says so.
setNotice(
'This account has not been set up yet. Use the link in your invitation email, ' +
'or ask whoever set you up to send another.',
);
setPassword('');
} else {
setStep('password');
}
@@ -134,6 +129,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 +139,15 @@ 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');
// Between the probe and the submit, an administrator could have cleared
// the password. Same answer as the probe gives: the invitation is the
// only way to set one, so there is nothing to offer here.
setNotice(
'This account has not been set up yet. Use the link in your invitation email, ' +
'or ask whoever set you up to send another.',
);
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 +165,20 @@ 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. Setting a first
password moved to `SetPasswordPage`, reached only from a signed invitation
— this screen is public, so a form here meant knowing a merchant's email
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,8 +212,7 @@ 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
<FormPanel
step={step}
email={email}
password={password}
@@ -255,23 +226,7 @@ export function LoginPage() {
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
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 +514,11 @@ function FormPanel({
);
}
/* ────────────────────────────────────────────────────────────────────────────
Right — first sign-in, setting the password
──────────────────────────────────────────────────────────────────────────── */
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>
);
}
/* `SetupPanelProps`, `SetupPanel` and the `Hint` line they used stood here — a
second panel with its own password fields. Setting a first password moved to
`SetPasswordPage`, reached only from a signed invitation email. This screen
is public, so a password form on it meant that knowing a merchant's address
was enough to claim their account. */
/* ────────────────────────────────────────────────────────────────────────────
Shared form furniture

View File

@@ -0,0 +1,356 @@
import { useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2, Eye, EyeOff, KeyRound, Lock } from 'lucide-react';
import { MIN_PASSWORD_LENGTH, setInitialPassword } from '@/auth/session';
/**
* Choosing a first password, from the link in an invitation email.
*
* ── Why this is its own page ────────────────────────────────────────────────
*
* It used to be a step inside the sign-in screen: type an email, and if the
* account had no password the form offered to set one. That was an account
* takeover — `applogin` answers an email with NO password, so anybody could
* POST a merchant's primary address, receive their userid, and claim the
* business. The address is usually printed on the shopfront.
*
* So setting a first password is no longer something a visitor can ask for. It
* happens here, reached only from a signed invitation, and the sign-in screen
* now says "check your email" instead of offering a form.
*
* ── The token never leaves the URL bar ──────────────────────────────────────
*
* It is read from the query string and sent in the request body. It is not put
* into storage, not logged, and not shown on screen: it is a credential with a
* seven-day life, and every extra place it rests is another place it leaks
* from.
*/
export function SetPasswordPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get('t') ?? '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [isVisible, setIsVisible] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDone, setIsDone] = useState(false);
const isTooShort = password !== '' && password.length < MIN_PASSWORD_LENGTH;
const isMismatched = confirm !== '' && password !== confirm;
const canSubmit =
password.length >= MIN_PASSWORD_LENGTH && password === confirm && !isBusy && token !== '';
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setIsBusy(true);
try {
await setInitialPassword(token, password);
setIsDone(true);
} catch (cause) {
// The server's words, not ours. It distinguishes an expired invitation
// from a forged one from an account that is already set up, and each
// needs a different thing from the person reading it.
setError(cause instanceof Error ? cause.message : 'Could not set the password');
} finally {
setIsBusy(false);
}
}
if (isDone) {
return (
<Shell>
<div style={{ display: 'grid', gap: 16, justifyItems: 'start' }}>
<span style={{ color: 'var(--color-success, #1f9d55)' }}>
<CheckCircle2 size={28} />
</span>
<Heading>Your password is set</Heading>
<Body>
Sign in with your email address and the password you just chose.
</Body>
{/* Straight to sign-in rather than signing them in here. Setting a
password and holding a session are two different acts, and this
page deliberately never holds one. */}
<PrimaryButton label="Go to sign in" onClick={() => navigate('/login', { replace: true })} />
</div>
</Shell>
);
}
if (token === '') {
// A truncated link, or one copied without its query string. Said plainly,
// because the person's next move is to go back to the email rather than to
// try again here.
return (
<Shell>
<div style={{ display: 'grid', gap: 12, justifyItems: 'start' }}>
<Heading>This link is incomplete</Heading>
<Body>
Open the full link from your invitation email. If you no longer have it, ask whoever
set up your account to send another.
</Body>
</div>
</Shell>
);
}
return (
<Shell>
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: 20 }}>
<div style={{ display: 'grid', gap: 8 }}>
<span style={{ color: 'var(--color-brand)' }}>
<KeyRound size={26} />
</span>
<Heading>Choose your password</Heading>
<Body>
This is the password you will sign in with. It is the last step — your account is
already set up and waiting.
</Body>
</div>
<Field label="New password">
<PasswordInput
value={password}
onChange={setPassword}
isVisible={isVisible}
onToggle={() => setIsVisible((v) => !v)}
autoFocus
/>
<Hint tone={isTooShort ? 'warn' : 'muted'}>
{isTooShort
? `Use at least ${MIN_PASSWORD_LENGTH} characters.`
: `At least ${MIN_PASSWORD_LENGTH} characters.`}
</Hint>
</Field>
<Field label="Confirm password">
<PasswordInput
value={confirm}
onChange={setConfirm}
isVisible={isVisible}
onToggle={() => setIsVisible((v) => !v)}
/>
{isMismatched ? <Hint tone="warn">These do not match.</Hint> : null}
</Field>
{error ? (
<div
role="alert"
style={{
display: 'flex',
gap: 9,
padding: '11px 13px',
borderRadius: 12,
background: 'var(--color-error-muted, #FCEEEE)',
border: '1px solid rgba(214,69,69,.22)',
color: '#B03434',
fontSize: 13,
lineHeight: 1.55,
}}
>
{error}
</div>
) : null}
<PrimaryButton
label={isBusy ? 'Setting it…' : 'Set password'}
isDisabled={!canSubmit}
type="submit"
/>
{/* Passwords are stored as typed on this backend. Said here rather than
buried in a policy page, because this is the moment somebody decides
whether to reuse one from elsewhere. */}
<Body isSmall>
Passwords on this system are stored as typed. Do not reuse one from another service.
</Body>
</form>
</Shell>
);
}
/* ── Local furniture ──────────────────────────────────────────────────────
Deliberately not imported from LoginPage. This page is reached by people who
have never seen the product, from an email, possibly on a phone — and it must
keep working if the sign-in screen is redesigned. */
function Shell({ children }: { children: React.ReactNode }) {
return (
<div
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: 24,
background: 'var(--color-surface-sunken, #F4F5F7)',
}}
>
<div
style={{
width: '100%',
maxWidth: 420,
padding: 32,
borderRadius: 16,
background: 'var(--color-surface, #fff)',
border: '1px solid var(--color-line, #E6E8EB)',
boxShadow: '0 12px 32px -12px rgb(16 24 32 / .12)',
}}
>
{children}
</div>
</div>
);
}
function Heading({ children }: { children: React.ReactNode }) {
return (
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 22,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
{children}
</h1>
);
}
function Body({ children, isSmall }: { children: React.ReactNode; isSmall?: boolean }) {
return (
<p
style={{
margin: 0,
fontSize: isSmall ? 11.5 : 13.5,
lineHeight: 1.6,
color: isSmall ? 'var(--color-ink-4)' : 'var(--color-ink-2)',
}}
>
{children}
</p>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-ink-2)' }}>{label}</span>
{children}
</label>
);
}
function Hint({ children, tone }: { children: React.ReactNode; tone: 'muted' | 'warn' }) {
return (
<span
style={{
fontSize: 11.5,
color: tone === 'warn' ? '#B03434' : 'var(--color-ink-4)',
}}
>
{children}
</span>
);
}
function PasswordInput({
value,
onChange,
isVisible,
onToggle,
autoFocus,
}: {
value: string;
onChange: (next: string) => void;
isVisible: boolean;
onToggle: () => void;
autoFocus?: boolean;
}) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
border: '1px solid var(--color-line, #E6E8EB)',
borderRadius: 10,
padding: '10px 12px',
background: '#fff',
}}
>
<Lock size={15} style={{ flex: 'none', color: 'var(--color-ink-4)' }} />
<input
type={isVisible ? 'text' : 'password'}
value={value}
onChange={(event) => onChange(event.target.value)}
// New password, not a saved one — tells a password manager to offer a
// generated one rather than autofilling something from another site.
autoComplete="new-password"
autoFocus={autoFocus}
style={{
flex: 1,
border: 0,
outline: 'none',
fontSize: 14,
fontFamily: 'inherit',
color: 'var(--color-ink-1)',
background: 'transparent',
}}
/>
<button
type="button"
onClick={onToggle}
aria-label={isVisible ? 'Hide password' : 'Show password'}
style={{
border: 0,
background: 'transparent',
cursor: 'pointer',
color: 'var(--color-ink-4)',
display: 'grid',
placeItems: 'center',
}}
>
{isVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
);
}
function PrimaryButton({
label,
onClick,
isDisabled,
type = 'button',
}: {
label: string;
onClick?: () => void;
isDisabled?: boolean;
type?: 'button' | 'submit';
}) {
return (
<button
type={type === 'submit' ? 'submit' : 'button'}
disabled={isDisabled}
{...(onClick ? { onClick } : {})}
style={{
border: 0,
borderRadius: 10,
padding: '12px 16px',
fontSize: 14,
fontWeight: 600,
fontFamily: 'inherit',
color: '#fff',
background: isDisabled ? 'var(--color-ink-4)' : 'var(--color-brand)',
cursor: isDisabled ? 'default' : 'pointer',
}}
>
{label}
</button>
);
}

View File

@@ -140,6 +140,21 @@ export function PersonDrawer({
const [locationid, setLocationid] = useState(String(row?.locationid ?? 0));
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
const [problem, setProblem] = useState<string | null>(null);
/**
* A person who was hired and not emailed.
*
* The account is created with no password, and the emailed link is the only way
* to set one — the sign-in screen does not offer a form, because a public form
* for it meant knowing somebody's email address was enough to claim their
* account. So an invitation that did not send leaves a person who is in the
* directory, in every branch picker, and cannot sign in.
*
* Holding the drawer open on it rather than closing: this is the one moment
* somebody is looking, and the list behind it has no idea the mail failed.
*/
const [stranded, setStranded] = useState<{ userid: number; email: string; reason?: string } | null>(
null,
);
const save = useMutation({
mutationFn: async () => {
@@ -169,13 +184,31 @@ export function PersonDrawer({
status,
});
},
onSuccess: async () => {
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
// `result` is a `CreateStaffResult` on the create path and a `StaffInfo` on
// the edit path; only the first carries an invitation, and `in` is what
// tells them apart without a flag that could disagree with the call made.
if (result && 'invite' in result && !result.invite.sent) {
setStranded({
userid: result.person.userid,
email: email.trim(),
...(result.invite.reason ? { reason: result.invite.reason } : {}),
});
return;
}
onClose();
},
onError: (error) => setProblem(errorMessage(error)),
});
/* Sends the link again, from the drawer that just failed to send it. By
userid: the tenant's own invitation reaches the owner, and this is not them. */
const resend = useMutation({
mutationFn: (userid: number) => staffApi.resendInvite(userid),
});
function submit(event?: FormEvent) {
event?.preventDefault();
setProblem(null);
@@ -184,6 +217,54 @@ export function PersonDrawer({
save.mutate();
}
if (stranded) {
// Hired, and with no way in. Said plainly and with the one action that fixes
// it, because the person reading this is the only one who will ever see it.
return (
<Sheet
title="Added, but not invited"
subtitle={[firstname.trim(), lastname.trim()].filter(Boolean).join(' ')}
onClose={onClose}
footer={
<>
<DrawerButton label="Done" variant="ghost" onClick={onClose} />
<DrawerButton
label={
resend.isSuccess
? 'Invitation sent'
: resend.isPending
? 'Sending…'
: 'Send invitation'
}
variant="primary"
isDisabled={resend.isPending || resend.isSuccess}
onClick={() => resend.mutate(stranded.userid)}
/>
</>
}
>
<Alert>
{stranded.reason ?? 'The server did not say why.'}
</Alert>
<Note>
{[firstname.trim(), lastname.trim()].filter(Boolean).join(' ') || 'This person'} is on the
team and cannot sign in yet. Setting a first password happens only from the link in the
invitation email, so until one reaches {stranded.email || 'their address'} there is no way
in.
</Note>
{resend.isError ? <Alert>{errorMessage(resend.error)}</Alert> : null}
{resend.isSuccess ? (
<Note tone="brand">
Sent. The link is good for seven days; after that, send another from this person&apos;s
row.
</Note>
) : null}
</Sheet>
);
}
// An existing role the picker does not know about is still shown, so editing
// someone's phone number cannot silently reassign their role.
const roleOptions = [

View File

@@ -11,8 +11,9 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertCircle, ArrowLeft, CheckCircle2, Clock, MapPin, Store } from 'lucide-react';
import { AlertCircle, ArrowLeft, CheckCircle2, Clock, Mail, MapPin, Store } from 'lucide-react';
import { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
import { staffApi, type InviteOutcome } from '@/api/people';
import { errorMessage } from '@/api/client';
import { PageBody } from '@/components/PageBody';
import { queryKeys } from '@/queries/keys';
@@ -262,11 +263,24 @@ export function OnboardBranchPage() {
: 'A login was created for the outlet itself, using the email above. The branch has no catalogue yet — products are published to it per store.'}
</Text>
{mutation.data?.locationid ? (
{/* Whether that login can actually be used.
Only for a spawned one: a branch handed to an existing person
has no invitation to report, and `invite.sent` is false there
because nothing was created rather than because anything
failed. */}
{Number(form.operatorid) > 0 ? null : (
<BranchInviteStatus
invite={mutation.data.invite}
email={form.email.trim()}
userid={mutation.data.operatorUserid}
/>
)}
{mutation.data.branch.locationid ? (
<Card padding={3} variant="transparent">
<StoreQrPanel
tenantid={Number(form.tenantid || defaultTenantId)}
locationid={mutation.data.locationid}
locationid={mutation.data.branch.locationid}
locationname={form.locationname}
where={[form.suburb, form.city].filter(Boolean).join(', ')}
/>
@@ -552,3 +566,96 @@ function useBranchScopeOptional() {
return null;
}
}
/**
* Can the new outlet's login actually be used?
*
* A branch commissioned without a named operator spawns its own login on the
* email above, with NO password. Setting a first password happens only from the
* link in an invitation email — the sign-in screen does not offer a form, because
* a public one meant that knowing an address was enough to claim an account — so
* an invitation that did not send leaves an outlet nobody can open.
*
* Said on the success screen rather than in a toast, for the same reason the QR
* code is: this is the last moment anybody is looking at this branch.
*/
function BranchInviteStatus({
invite,
email,
userid,
}: {
invite: InviteOutcome;
email: string;
userid: number;
}) {
const resend = useMutation({
mutationFn: (id: number) => staffApi.resendInvite(id),
});
if (invite.sent || resend.isSuccess) {
return (
<HStack
align="center"
gap={1}
padding={1.5}
style={{
background: 'var(--color-success-muted, #eef8f3)',
borderRadius: 8,
border: '1px solid var(--color-success-border, #cceadd)',
color: 'var(--color-success, #10b981)',
}}
>
<Mail size={15} style={{ flex: 'none' }} />
<Text type="body" size="sm" style={{ color: 'inherit' }}>
An invitation is on its way to {email || 'the outlet'}. Whoever runs the shop chooses the
password from that 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' }}>
Nobody can open this shop yet
</Text>
</HStack>
<Text type="body" size="sm" color="secondary">
{/* The server's own words: it tells an unconfigured mail host apart from
a relay that refused the address, and those need different people. */}
{invite.reason ?? 'The invitation did not send, and the server did not say why.'} The
outlet&apos;s login has no password, and the only way to set one is the link in that email.
</Text>
{resend.isError ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{errorMessage(resend.error)}
</Text>
) : null}
{userid > 0 ? (
<HStack>
<Button
type="button"
variant="secondary"
label={resend.isPending ? 'Sending…' : 'Send invitation'}
isLoading={resend.isPending}
onClick={() => resend.mutate(userid)}
/>
</HStack>
) : (
<Text type="body" size="sm" color="secondary">
Send it from Users &amp; access, on this outlet&apos;s row.
</Text>
)}
</VStack>
);
}

View File

@@ -1,11 +1,13 @@
import { useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Clock, Monitor, Plus, UserCog, Users } from 'lucide-react';
import { Bike, Clock, Mail, Monitor, Plus, UserCog, Users } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { SearchInput } from '@/components/SearchInput';
import type { RiderRosterRow } from '@/api/deliveries';
import type { PosUser, StaffInfo, StaffShift } from '@/api/types';
@@ -22,7 +24,7 @@ import {
import { useBranchScope } from '../BranchScope';
import { branchLabel } from '../format';
import { PersonDrawer, ShiftDrawer, TillDrawer } from '../PeopleDrawers';
import { weekdayLabel } from '@/api/people';
import { staffApi, weekdayLabel } from '@/api/people';
import { RiderDrawer } from '../RiderDrawer';
import { isUnplaced } from '../staffPlacement';
import { Panel } from '@/components/Panel';
@@ -537,10 +539,23 @@ function StaffTable({
</Td>
) : null}
<Td>
<StatusChip status={person.status} />
{/* Two different facts, and the second is the one that gets
missed. `status` says whether the login is switched on;
`issetup` says whether it has a password at all. An Active
account with no password is refused at the sign-in screen
exactly like a disabled one, and nothing else in the
product would ever say which it was. */}
{person.issetup === false ? (
<Chip label="Not set up" tone="warning" />
) : (
<StatusChip status={person.status} />
)}
</Td>
<Td>
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
<span style={{ display: 'inline-flex', gap: 6, justifyContent: 'flex-end' }}>
{person.issetup === false ? <ResendInvite userid={person.userid} /> : null}
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
</span>
</Td>
</tr>
);
@@ -886,3 +901,50 @@ function ShiftTable({ rows, isLoading }: { rows: StaffShift[]; isLoading: boolea
</Panel>
);
}
/**
* Sends somebody's first-password link again.
*
* Shown only on a row with no password. Their invitation is the only way in —
* the sign-in screen does not offer a form to set a first password, because a
* public one meant knowing an email address was enough to claim the account — so
* a mail lost to a spam folder, a typo'd address or a seven-day expiry over a
* holiday leaves somebody locked out with no other repair.
*
* The backend refuses anybody who already HAS a password and says to send them
* to the sign-in page instead. That refusal is deliberate and it is why this
* button is not on every row: an endpoint re-issuing a working password link on
* request is a password reset, and nothing here verifies identity well enough to
* have one.
*/
function ResendInvite({ userid }: { userid: number }) {
const resend = useMutation({ mutationFn: () => staffApi.resendInvite(userid) });
if (resend.isSuccess) {
return (
<Text type="body" size="sm" color="secondary">
Invitation sent
</Text>
);
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{resend.isError ? (
/* The server's words. It tells an unconfigured mail host apart from an
address the relay refused, and those need different people to fix. */
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{errorMessage(resend.error)}
</Text>
) : null}
<Button
label={resend.isPending ? 'Sending…' : 'Resend invite'}
variant="ghost"
size="sm"
icon={<Mail size={13} />}
isLoading={resend.isPending}
onClick={() => resend.mutate()}
/>
</span>
);
}

View File

@@ -126,7 +126,7 @@ export function setupSteps(input: SetupInput): SetupStep[] {
"Leave the shop as “Not at a shop yet” if their branch does not exist",
],
gotcha:
"You never issue a password. They set their own the first time they sign in.",
"You never issue a password. Each person is emailed a link and chooses their own — so the email address has to be one they actually read.",
title: 'Add your people',
todo: 'Add whoever will run your shops. You can add them before a branch exists.',
cta: 'Add a person',