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