auto mail generation
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
356
src/features/auth/SetPasswordPage.tsx
Normal file
356
src/features/auth/SetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 = [
|
||||
|
||||
@@ -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'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 & access, on this outlet's row.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user