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 { HOME_ROUTE } from '@/auth/roles';
|
||||||
import { withStaleChunkRecovery } from '@/lib/staleChunk';
|
import { withStaleChunkRecovery } from '@/lib/staleChunk';
|
||||||
import { LoginPage } from '@/features/auth/LoginPage';
|
import { LoginPage } from '@/features/auth/LoginPage';
|
||||||
|
import { SetPasswordPage } from '@/features/auth/SetPasswordPage';
|
||||||
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
||||||
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
||||||
|
|
||||||
@@ -79,6 +80,10 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<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.
|
{/* Nearle staff have their own application.
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,17 @@ interface RequestOptions {
|
|||||||
signal?: AbortSignal;
|
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;
|
const { method = 'GET', params, body, signal } = options;
|
||||||
|
|
||||||
// There is exactly one path out of this function and it goes to `fetch`.
|
// 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
|
return envelope;
|
||||||
// `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.
|
/**
|
||||||
|
* 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;
|
return (envelope.details ?? envelope.data) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +307,16 @@ export const api = {
|
|||||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||||
request<T>(path, { method: 'POST', body, params }),
|
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>) =>
|
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||||
request<T>(path, { method: 'PUT', body, params }),
|
request<T>(path, { method: 'PUT', body, params }),
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,24 @@ export interface CreateStaffRequest {
|
|||||||
status?: string;
|
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 {
|
export interface UpdateStaffRequest {
|
||||||
userid: number;
|
userid: number;
|
||||||
firstname?: string;
|
firstname?: string;
|
||||||
@@ -85,7 +103,42 @@ export const staffApi = {
|
|||||||
unassign: (body: { tenantid: number; userid: number }) =>
|
unassign: (body: { tenantid: number; userid: number }) =>
|
||||||
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
|
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.
|
* Update a person.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { api, WEB } from './client';
|
import { api, WEB } from './client';
|
||||||
import type { AppLocation } from './deliveries';
|
import type { AppLocation } from './deliveries';
|
||||||
|
import type { InviteOutcome } from './people';
|
||||||
import type { TenantInfo, TenantLocation } from './types';
|
import type { TenantInfo, TenantLocation } from './types';
|
||||||
|
|
||||||
/** Everything the tenant-onboarding form collects. */
|
/** Everything the tenant-onboarding form collects. */
|
||||||
@@ -101,6 +102,20 @@ export interface CreateBranchRequest {
|
|||||||
operatorid?: number;
|
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 {
|
export interface TenantListQuery {
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
@@ -176,9 +191,24 @@ export const tenantsApi = {
|
|||||||
* created row, and the new `locationid` is what a QR code and every
|
* 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
|
* follow-up write need. `createlocation` answers 201 with a message and no
|
||||||
* `details` at all.
|
* `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) =>
|
createBranch: (body: CreateBranchRequest): Promise<CreateBranchResult> =>
|
||||||
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
|
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 }) =>
|
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
|
||||||
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
|
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
|
||||||
|
|||||||
@@ -54,6 +54,26 @@ export interface FiestaEnvelope<T> {
|
|||||||
* a number Fiesta has already worked out.
|
* a number Fiesta has already worked out.
|
||||||
*/
|
*/
|
||||||
pricedetails?: { orderamount?: number; totaltaxamount?: number };
|
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;
|
locationid?: number;
|
||||||
locationname?: string;
|
locationname?: string;
|
||||||
status?: 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 {
|
export class PasswordSetupRequiredError extends Error {
|
||||||
readonly userid: number;
|
constructor() {
|
||||||
constructor(userid: number) {
|
|
||||||
super('This account needs a password before it can sign in.');
|
super('This account needs a password before it can sign in.');
|
||||||
this.name = 'PasswordSetupRequiredError';
|
this.name = 'PasswordSetupRequiredError';
|
||||||
this.userid = userid;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,16 +87,16 @@ const CONFIG_ID = 1;
|
|||||||
export async function login(email: string, password: string): Promise<SessionUser> {
|
export async function login(email: string, password: string): Promise<SessionUser> {
|
||||||
const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
|
const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
|
||||||
|
|
||||||
const envelope = await api.envelope<FiestaUser & { setup?: boolean; userid?: number }>(
|
const envelope = await api.envelope<FiestaUser & { setup?: boolean }>(`${WEB}/users/applogin`, {
|
||||||
`${WEB}/users/applogin`,
|
method: 'POST',
|
||||||
{ method: 'POST', body },
|
body,
|
||||||
);
|
});
|
||||||
|
|
||||||
// A brand-new account — `createtenantlocation` spawns branch logins with an
|
// A brand-new account — `createtenantlocation` spawns branch logins with an
|
||||||
// empty password — answers `status: true` with a 409 and the userid to set
|
// empty password — answers `status: true` with a 409. Not a failure: the
|
||||||
// one against. It is not a failure, it is the first step.
|
// account is real and is waiting for its invitation to be used.
|
||||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
if (envelope.code === 409 && envelope.details?.setup === true) {
|
||||||
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
|
throw new PasswordSetupRequiredError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.status !== true || !envelope.details) {
|
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")
|
* 409 + status false → no such account ("Invalid Email")
|
||||||
* 403 → account deactivated
|
* 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")
|
* 401 + status true → exists, has a password ("Password is required")
|
||||||
*
|
*
|
||||||
* The last one is the whole trick: a password-less attempt against a real
|
* 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
|
* password, so the probe reveals nothing that was not already available with
|
||||||
* one more field filled in.
|
* 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> {
|
export async function checkAccount(email: string): Promise<AccountCheck> {
|
||||||
const envelope = await api.envelope<{ setup?: boolean; userid?: number }>(
|
const envelope = await api.envelope<{ setup?: boolean }>(`${WEB}/users/applogin`, {
|
||||||
`${WEB}/users/applogin`,
|
method: 'POST',
|
||||||
{ method: 'POST', body: { authname: email.trim(), configid: CONFIG_ID } },
|
body: { authname: email.trim(), configid: CONFIG_ID },
|
||||||
);
|
});
|
||||||
|
|
||||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
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
|
// "Password is required" — the account is real and has one. Exactly what we
|
||||||
// wanted to learn, arriving as a refusal.
|
// 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
|
* 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.
|
* 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
|
* 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
|
* console can fix.
|
||||||
* emailed setup link.
|
|
||||||
*/
|
*/
|
||||||
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) {
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`);
|
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,
|
Lock,
|
||||||
Mail,
|
Mail,
|
||||||
Building2,
|
Building2,
|
||||||
ShieldCheck,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { HOME_ROUTE } from '@/auth/roles';
|
import { HOME_ROUTE } from '@/auth/roles';
|
||||||
import {
|
import { checkAccount, PasswordSetupRequiredError, WrongConsoleError } from '@/auth/session';
|
||||||
checkAccount,
|
|
||||||
MIN_PASSWORD_LENGTH,
|
|
||||||
PasswordSetupRequiredError,
|
|
||||||
WrongConsoleError,
|
|
||||||
setInitialPassword,
|
|
||||||
} from '@/auth/session';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign-in — KROW's full-bleed auth archetype.
|
* Sign-in — KROW's full-bleed auth archetype.
|
||||||
@@ -43,35 +36,27 @@ export function LoginPage() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [notice, setNotice] = useState<string | null>(null);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
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
|
* Email first, always. A tenant made by `createtenantuser` and every branch
|
||||||
* made by `createtenantlocation` is spawned with an EMPTY password, so their
|
* 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
|
* 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
|
* 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
|
* So the email is checked before a password field is ever shown. An account
|
||||||
* goes straight to whichever step that account actually needs. This is what
|
* that has no password never reaches the second step: it is told to use its
|
||||||
* the old console does, and it is the right shape.
|
* invitation, because setting a first password happens on `SetPasswordPage`
|
||||||
|
* behind a signed link and nowhere else.
|
||||||
*/
|
*/
|
||||||
const [step, setStep] = useState<'email' | 'password' | 'setup'>('email');
|
const [step, setStep] = useState<'email' | 'password'>('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('');
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Each step brings its own panel into view.
|
Each step brings its own panel into view.
|
||||||
@@ -113,14 +98,24 @@ export function LoginPage() {
|
|||||||
async function handleEmail(event: FormEvent) {
|
async function handleEmail(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
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);
|
setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
const check = await checkAccount(email);
|
const check = await checkAccount(email);
|
||||||
if (check.state === 'setup') {
|
if (check.state === 'setup') {
|
||||||
setSetupUserid(check.userid);
|
// An account that exists and has never been used. It is NOT offered a
|
||||||
setNewPassword('');
|
// password form any more: this branch is reached by anybody who types
|
||||||
setConfirmPassword('');
|
// an email, so a form here meant that knowing a merchant's address —
|
||||||
setStep('setup');
|
// 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 {
|
} else {
|
||||||
setStep('password');
|
setStep('password');
|
||||||
}
|
}
|
||||||
@@ -134,6 +129,7 @@ export function LoginPage() {
|
|||||||
async function handleSubmit(event: FormEvent) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
const session = await signIn(email, password);
|
const session = await signIn(email, password);
|
||||||
@@ -143,10 +139,15 @@ export function LoginPage() {
|
|||||||
// check and the submit an administrator could have cleared the password,
|
// check and the submit an administrator could have cleared the password,
|
||||||
// and the account would otherwise dead-end on "Invalid Email".
|
// and the account would otherwise dead-end on "Invalid Email".
|
||||||
if (cause instanceof PasswordSetupRequiredError) {
|
if (cause instanceof PasswordSetupRequiredError) {
|
||||||
setSetupUserid(cause.userid);
|
// Between the probe and the submit, an administrator could have cleared
|
||||||
setNewPassword('');
|
// the password. Same answer as the probe gives: the invitation is the
|
||||||
setConfirmPassword('');
|
// only way to set one, so there is nothing to offer here.
|
||||||
setStep('setup');
|
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) {
|
} else if (cause instanceof WrongConsoleError) {
|
||||||
// Not a failure. The password was right and the account is fine — it
|
// 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
|
// 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() {
|
function restart() {
|
||||||
setStep('email');
|
setStep('email');
|
||||||
setSetupUserid(null);
|
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setNewPassword('');
|
|
||||||
setConfirmPassword('');
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* `handleSetup` stood here, with its own password form. Setting a first
|
||||||
* Set the password, then sign in with it.
|
password moved to `SetPasswordPage`, reached only from a signed invitation
|
||||||
*
|
— this screen is public, so a form here meant knowing a merchant's email
|
||||||
* Signing in afterwards rather than sending the person back to the form: they
|
was enough to claim their account. */
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
|
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
|
||||||
const canSetup =
|
|
||||||
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -240,8 +212,7 @@ export function LoginPage() {
|
|||||||
form vertically beside the brand. A plain block here would collapse
|
form vertically beside the brand. A plain block here would collapse
|
||||||
to its content's height and un-centre it. */}
|
to its content's height and un-centre it. */}
|
||||||
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
|
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
|
||||||
{step !== 'setup' ? (
|
<FormPanel
|
||||||
<FormPanel
|
|
||||||
step={step}
|
step={step}
|
||||||
email={email}
|
email={email}
|
||||||
password={password}
|
password={password}
|
||||||
@@ -255,23 +226,7 @@ export function LoginPage() {
|
|||||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||||
onSubmit={step === 'email' ? handleEmail : handleSubmit}
|
onSubmit={step === 'email' ? handleEmail : handleSubmit}
|
||||||
onBack={restart}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,177 +514,11 @@ function FormPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* `SetupPanelProps`, `SetupPanel` and the `Hint` line they used stood here — a
|
||||||
Right — first sign-in, setting the password
|
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
|
||||||
interface SetupPanelProps {
|
was enough to claim their account. */
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────────────────
|
||||||
Shared form furniture
|
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 [locationid, setLocationid] = useState(String(row?.locationid ?? 0));
|
||||||
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
|
||||||
const [problem, setProblem] = useState<string | null>(null);
|
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({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -169,13 +184,31 @@ export function PersonDrawer({
|
|||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.people.all });
|
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();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => setProblem(errorMessage(error)),
|
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) {
|
function submit(event?: FormEvent) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
setProblem(null);
|
setProblem(null);
|
||||||
@@ -184,6 +217,54 @@ export function PersonDrawer({
|
|||||||
save.mutate();
|
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
|
// An existing role the picker does not know about is still shown, so editing
|
||||||
// someone's phone number cannot silently reassign their role.
|
// someone's phone number cannot silently reassign their role.
|
||||||
const roleOptions = [
|
const roleOptions = [
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { Selector } from '@astryxdesign/core/Selector';
|
|||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
|
||||||
|
import { staffApi, type InviteOutcome } from '@/api/people';
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { PageBody } from '@/components/PageBody';
|
import { PageBody } from '@/components/PageBody';
|
||||||
import { queryKeys } from '@/queries/keys';
|
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.'}
|
: '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>
|
</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">
|
<Card padding={3} variant="transparent">
|
||||||
<StoreQrPanel
|
<StoreQrPanel
|
||||||
tenantid={Number(form.tenantid || defaultTenantId)}
|
tenantid={Number(form.tenantid || defaultTenantId)}
|
||||||
locationid={mutation.data.locationid}
|
locationid={mutation.data.branch.locationid}
|
||||||
locationname={form.locationname}
|
locationname={form.locationname}
|
||||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||||
/>
|
/>
|
||||||
@@ -552,3 +566,96 @@ function useBranchScopeOptional() {
|
|||||||
return null;
|
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 { useMemo, useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { SearchInput } from '@/components/SearchInput';
|
||||||
import type { RiderRosterRow } from '@/api/deliveries';
|
import type { RiderRosterRow } from '@/api/deliveries';
|
||||||
import type { PosUser, StaffInfo, StaffShift } from '@/api/types';
|
import type { PosUser, StaffInfo, StaffShift } from '@/api/types';
|
||||||
@@ -22,7 +24,7 @@ import {
|
|||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { branchLabel } from '../format';
|
import { branchLabel } from '../format';
|
||||||
import { PersonDrawer, ShiftDrawer, TillDrawer } from '../PeopleDrawers';
|
import { PersonDrawer, ShiftDrawer, TillDrawer } from '../PeopleDrawers';
|
||||||
import { weekdayLabel } from '@/api/people';
|
import { staffApi, weekdayLabel } from '@/api/people';
|
||||||
import { RiderDrawer } from '../RiderDrawer';
|
import { RiderDrawer } from '../RiderDrawer';
|
||||||
import { isUnplaced } from '../staffPlacement';
|
import { isUnplaced } from '../staffPlacement';
|
||||||
import { Panel } from '@/components/Panel';
|
import { Panel } from '@/components/Panel';
|
||||||
@@ -537,10 +539,23 @@ function StaffTable({
|
|||||||
</Td>
|
</Td>
|
||||||
) : null}
|
) : null}
|
||||||
<Td>
|
<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>
|
||||||
<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>
|
</Td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -886,3 +901,50 @@ function ShiftTable({ rows, isLoading }: { rows: StaffShift[]; isLoading: boolea
|
|||||||
</Panel>
|
</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",
|
"Leave the shop as “Not at a shop yet” if their branch does not exist",
|
||||||
],
|
],
|
||||||
gotcha:
|
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',
|
title: 'Add your people',
|
||||||
todo: 'Add whoever will run your shops. You can add them before a branch exists.',
|
todo: 'Add whoever will run your shops. You can add them before a branch exists.',
|
||||||
cta: 'Add a person',
|
cta: 'Add a person',
|
||||||
|
|||||||
Reference in New Issue
Block a user