From e3fc144d05715829ca353f8f5d0e5c8e8250be2b Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 29 Sep 2026 16:48:10 +0530 Subject: [PATCH] auto mail generation --- src/App.tsx | 5 + src/api/client.ts | 39 +- src/api/people.ts | 55 ++- src/api/tenants.ts | 34 +- src/api/types.ts | 33 ++ src/auth/session.ts | 81 ++-- src/features/auth/LoginPage.tsx | 307 +++------------ src/features/auth/SetPasswordPage.tsx | 356 ++++++++++++++++++ src/features/store-admin/PeopleDrawers.tsx | 83 +++- .../store-admin/pages/OnboardBranchPage.tsx | 113 +++++- src/features/store-admin/pages/UsersPage.tsx | 70 +++- src/features/store-admin/setupSteps.ts | 2 +- 12 files changed, 880 insertions(+), 298 deletions(-) create mode 100644 src/features/auth/SetPasswordPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 60d3f50..59e547c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( } /> + {/* 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. */} + } /> {/* Nearle staff have their own application. diff --git a/src/api/client.ts b/src/api/client.ts index e944cbd..9e9a0bb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -163,7 +163,17 @@ interface RequestOptions { signal?: AbortSignal; } -async function request(path: string, options: RequestOptions = {}): Promise { +/** + * One request, answered with the WHOLE envelope. + * + * `request` below is this plus "take the payload out", which is what nearly + * every caller wants. A handful need a field that sits BESIDE the payload — + * `invited` on onboarding is the one this was extracted for — and the only way + * to read one used to be `requestEnvelope`, which does none of the checking: no + * 401 handling, no `status: false`, no thrown `FiestaError`. So a caller that + * wanted one extra field had to give up all the error handling to get it. + */ +async function send(path: string, options: RequestOptions = {}): Promise> { const { method = 'GET', params, body, signal } = options; // There is exactly one path out of this function and it goes to `fetch`. @@ -223,10 +233,19 @@ async function request(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise { + const envelope = await send(path, options); return (envelope.details ?? envelope.data) as T; } @@ -288,6 +307,16 @@ export const api = { post: (path: string, body?: unknown, params?: Record) => request(path, { method: 'POST', body, params }), + /** + * A POST whose answer carries something beside the payload. + * + * Same checking as `post` — a failure still throws a `FiestaError` — so a + * caller reading one extra envelope field does not give up the error handling + * to get at it. `tenants/createtenantuser` needs `invited`. + */ + postEnvelope: (path: string, body?: unknown, params?: Record) => + send(path, { method: 'POST', body, params }), + put: (path: string, body?: unknown, params?: Record) => request(path, { method: 'PUT', body, params }), diff --git a/src/api/people.ts b/src/api/people.ts index 0a3fc3e..03c10ab 100644 --- a/src/api/people.ts +++ b/src/api/people.ts @@ -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(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }), - create: (body: CreateStaffRequest) => api.post(`${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 => + api.postEnvelope(`${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(`${WEB}/tenants/resendinvite`, { userid }), /** * Update a person. diff --git a/src/api/tenants.ts b/src/api/tenants.ts index a360ebc..16702ea 100644 --- a/src/api/tenants.ts +++ b/src/api/tenants.ts @@ -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(`${WEB}/tenants/createtenantlocation`, body), + createBranch: (body: CreateBranchRequest): Promise => + api + .postEnvelope(`${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 & { locationid: number }) => api.put(`${WEB}/tenants/updatelocation`, body), diff --git a/src/api/types.ts b/src/api/types.ts index 6934486..3de916a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -54,6 +54,26 @@ export interface FiestaEnvelope { * 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; } /** diff --git a/src/auth/session.ts b/src/auth/session.ts index 4ba29a2..9138f47 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -39,13 +39,18 @@ export class WrongConsoleError extends Error { } } -/** Thrown when the account exists but has never had a password set. */ +/** + * Thrown when the account exists but has never had a password set. + * + * It carried the userid, for the setup form that used to be on the login screen. + * Both are gone: a userid was all it took to set any account's password, and the + * probe below handed one to anybody who typed an email. The signed invitation + * replaced it, so there is nothing left for this to carry. + */ export class PasswordSetupRequiredError extends Error { - readonly userid: number; - constructor(userid: number) { + constructor() { super('This account needs a password before it can sign in.'); this.name = 'PasswordSetupRequiredError'; - this.userid = userid; } } @@ -82,16 +87,16 @@ const CONFIG_ID = 1; export async function login(email: string, password: string): Promise { const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID }; - const envelope = await api.envelope( - `${WEB}/users/applogin`, - { method: 'POST', body }, - ); + const envelope = await api.envelope(`${WEB}/users/applogin`, { + method: 'POST', + body, + }); // A brand-new account — `createtenantlocation` spawns branch logins with an - // empty password — answers `status: true` with a 409 and the userid to set - // one against. It is not a failure, it is the first step. + // empty password — answers `status: true` with a 409. Not a failure: the + // account is real and 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 { - 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 { +export async function setInitialPassword(token: string, password: string): Promise { if (password.length < MIN_PASSWORD_LENGTH) { throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`); } - await api.post(`${WEB}/users/setpassword`, { userid, password }); + 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(`${WEB}/users/setpassword`, { token, password }); } /** diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx index cd836e2..fa77fa3 100644 --- a/src/features/auth/LoginPage.tsx +++ b/src/features/auth/LoginPage.tsx @@ -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(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(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(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 (
- {step !== 'setup' ? ( - setIsPasswordVisible((visible) => !visible)} onSubmit={step === 'email' ? handleEmail : handleSubmit} onBack={restart} - /> - ) : ( - setIsPasswordVisible((visible) => !visible)} - onSubmit={handleSetup} - onBack={restart} - /> - )} + />
@@ -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 ( -
-
-
-
- - First sign-in -
-

- Choose a password -

-

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

-
- -
- } - action={ - - } - > - onNewPassword(event.target.value)} - placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`} - autoComplete="new-password" - autoFocus - required - aria-invalid={isTooShort} - style={{ ...inputStyle, paddingRight: 40 }} - /> - - - }> - onConfirmPassword(event.target.value)} - placeholder="Type it again" - autoComplete="new-password" - required - aria-invalid={isMismatched} - style={{ - ...inputStyle, - borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)', - }} - /> - - - - {isTooShort - ? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.` - : isMismatched - ? 'Those two do not match yet.' - : 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'} - - - {/* No ConsoleNote here. The setup step is reached only by an account - that has never had a password — a branch login this console just - spawned — so it is the right console by construction. */} - - - - - - -
-
- ); -} - -/** A quiet line under the fields — advisory, never an error. */ -function Hint({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); -} +/* `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 diff --git a/src/features/auth/SetPasswordPage.tsx b/src/features/auth/SetPasswordPage.tsx new file mode 100644 index 0000000..3fe4fe5 --- /dev/null +++ b/src/features/auth/SetPasswordPage.tsx @@ -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(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 ( + +
+ + + + Your password is set + + Sign in with your email address and the password you just chose. + + {/* 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. */} + navigate('/login', { replace: true })} /> +
+
+ ); + } + + 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 ( + +
+ This link is incomplete + + Open the full link from your invitation email. If you no longer have it, ask whoever + set up your account to send another. + +
+
+ ); + } + + return ( + +
+
+ + + + Choose your password + + This is the password you will sign in with. It is the last step — your account is + already set up and waiting. + +
+ + + setIsVisible((v) => !v)} + autoFocus + /> + + {isTooShort + ? `Use at least ${MIN_PASSWORD_LENGTH} characters.` + : `At least ${MIN_PASSWORD_LENGTH} characters.`} + + + + + setIsVisible((v) => !v)} + /> + {isMismatched ? These do not match. : null} + + + {error ? ( +
+ {error} +
+ ) : null} + + + + {/* 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. */} + + Passwords on this system are stored as typed. Do not reuse one from another service. + + +
+ ); +} + +/* ── 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 ( +
+
+ {children} +
+
+ ); +} + +function Heading({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function Body({ children, isSmall }: { children: React.ReactNode; isSmall?: boolean }) { + return ( +

+ {children} +

+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} + +function Hint({ children, tone }: { children: React.ReactNode; tone: 'muted' | 'warn' }) { + return ( + + {children} + + ); +} + +function PasswordInput({ + value, + onChange, + isVisible, + onToggle, + autoFocus, +}: { + value: string; + onChange: (next: string) => void; + isVisible: boolean; + onToggle: () => void; + autoFocus?: boolean; +}) { + return ( +
+ + 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', + }} + /> + +
+ ); +} + +function PrimaryButton({ + label, + onClick, + isDisabled, + type = 'button', +}: { + label: string; + onClick?: () => void; + isDisabled?: boolean; + type?: 'button' | 'submit'; +}) { + return ( + + ); +} diff --git a/src/features/store-admin/PeopleDrawers.tsx b/src/features/store-admin/PeopleDrawers.tsx index e446d43..f2378df 100644 --- a/src/features/store-admin/PeopleDrawers.tsx +++ b/src/features/store-admin/PeopleDrawers.tsx @@ -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(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 ( + + + resend.mutate(stranded.userid)} + /> + + } + > + + {stranded.reason ?? 'The server did not say why.'} + + + + {[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. + + + {resend.isError ? {errorMessage(resend.error)} : null} + {resend.isSuccess ? ( + + Sent. The link is good for seven days; after that, send another from this person's + row. + + ) : null} + + ); + } + // 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 = [ diff --git a/src/features/store-admin/pages/OnboardBranchPage.tsx b/src/features/store-admin/pages/OnboardBranchPage.tsx index aeb5a96..33a1682 100644 --- a/src/features/store-admin/pages/OnboardBranchPage.tsx +++ b/src/features/store-admin/pages/OnboardBranchPage.tsx @@ -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.'} - {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 : ( + + )} + + {mutation.data.branch.locationid ? ( @@ -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 ( + + + + 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. + + + ); + } + + return ( + + + + + Nobody can open this shop yet + + + + {/* 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's login has no password, and the only way to set one is the link in that email. + + {resend.isError ? ( + + {errorMessage(resend.error)} + + ) : null} + {userid > 0 ? ( + +