auto mail generation

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

View File

@@ -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 });
}
/**