255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
/**
|
|
* Sign-in and session persistence.
|
|
*
|
|
* The session is the user record plus, now, a signed token. Until Fiesta grew
|
|
* `middleware.WebAuth` there was no token to hold: login returned the record and
|
|
* nothing else, the console asserted its own `tenantid` on every request, and
|
|
* the server believed it. The record is still what the app renders from; the
|
|
* token is the only part the server will not take our word for.
|
|
*
|
|
* Kept in sessionStorage rather than localStorage: a shared back-office machine
|
|
* should not stay signed in after the browser closes. That also means the tab
|
|
* closing is what normally ends a session — the token's own expiry is a
|
|
* backstop for a tab left open, not the mechanism.
|
|
*
|
|
* The storage key lives in `./token`, which the HTTP client also reads. It has
|
|
* to sit under both: this file calls the API to sign in, and the client needs
|
|
* the token to make that call authorised, so neither can import the other.
|
|
*/
|
|
|
|
import { api, WEB } from '@/api/client';
|
|
import type { FiestaUser } from '@/api/types';
|
|
import { toSessionUser, type SessionUser } from './roles';
|
|
import { SESSION_STORAGE_KEY } from './token';
|
|
|
|
/** Thrown when the account exists but has never had a password set. */
|
|
export class PasswordSetupRequiredError extends Error {
|
|
readonly userid: number;
|
|
constructor(userid: number) {
|
|
super('This account needs a password before it can sign in.');
|
|
this.name = 'PasswordSetupRequiredError';
|
|
this.userid = userid;
|
|
}
|
|
}
|
|
|
|
interface LoginBody {
|
|
authname: string;
|
|
password: string;
|
|
/**
|
|
* Not optional in practice.
|
|
*
|
|
* The lookup behind every login is `WHERE authname = ? AND configid = ?`
|
|
* (`userRepository.go:218`). Omitted, Go receives 0, the query matches no
|
|
* row, and every account on the platform answers "Invalid Email". The console
|
|
* surface is configid 1 — `createtenantuser` hard-codes it into the account it
|
|
* spawns for exactly this reason.
|
|
*/
|
|
configid: number;
|
|
}
|
|
|
|
/** The console's surface. Every account this app can sign in carries it. */
|
|
const CONFIG_ID = 1;
|
|
|
|
/**
|
|
* Signs in.
|
|
*
|
|
* `applogin`, not `tenant/weblogin`. The latter carries a check the former does
|
|
* not — `request.roleid == app_users.roleid` (`userService.go:224`) — and since
|
|
* Go's zero value is 0, a request without a roleid means "roleid must be 0".
|
|
* That is the branch-user role, so weblogin silently locked out every Store
|
|
* Admin and every platform operator with a 403 reading "Unauthorized email".
|
|
*
|
|
* The handler answers HTTP 200 with `status: false` for most failures, so the
|
|
* envelope is inspected rather than the HTTP status.
|
|
*/
|
|
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 },
|
|
);
|
|
|
|
// 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.
|
|
if (envelope.code === 409 && envelope.details?.setup === true) {
|
|
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
|
|
}
|
|
|
|
if (envelope.status !== true || !envelope.details) {
|
|
throw new Error(loginMessage(envelope.code, envelope.message));
|
|
}
|
|
|
|
// The token rides on the envelope, not on `details` — it is not a fact about
|
|
// the user, it is what proves a later request is theirs.
|
|
//
|
|
// Refused when absent, rather than stored and hoped for. `attachWebSession`
|
|
// on the server logs a minting failure and returns the user record anyway,
|
|
// which was correct while WEB_AUTH_REQUIRED was off and is a broken console
|
|
// now that it defaults on: the sign-in succeeds, the shell renders, and every
|
|
// request after it goes out with no `authorization` header and comes back
|
|
// 401 with nothing on screen to say why.
|
|
//
|
|
// Failing here names the problem at the moment it happens, to the person best
|
|
// placed to report it, instead of scattering 401s across every page.
|
|
if (!envelope.token) {
|
|
throw new Error(
|
|
'Signed in, but the server did not issue a session. Nothing would load — ' +
|
|
'tell your administrator the API could not mint a session token.',
|
|
);
|
|
}
|
|
|
|
const session = { ...toSessionUser(envelope.details), token: envelope.token };
|
|
persist(session);
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* What the next step is for this email — before anyone types a password.
|
|
*
|
|
* `'setup'` means the account exists and has never had a password; `'password'`
|
|
* means it has one. Anything else throws with a message worth showing.
|
|
*
|
|
* ── Why probe at all ─────────────────────────────────────────────────────────
|
|
*
|
|
* A tenant created by `createtenantuser`, and every branch created by
|
|
* `createtenantlocation`, is spawned with an EMPTY password. Their owner's
|
|
* first sign-in therefore cannot succeed, and asking them for a password first
|
|
* asks for something that does not exist — they type a guess, watch it fail,
|
|
* and only then are told to invent one. The old console avoids that by checking
|
|
* the email before the password field is ever shown, and it is right to.
|
|
*
|
|
* ── How one endpoint answers two questions ───────────────────────────────────
|
|
*
|
|
* There is no lookup endpoint. This posts to `applogin` with no password at
|
|
* all, which `userService.go:64-123` answers in four distinguishable ways:
|
|
*
|
|
* 409 + status false → no such account ("Invalid Email")
|
|
* 403 → account deactivated
|
|
* 409 + status true → exists, no password set (carries the userid)
|
|
* 401 + status true → exists, has a password ("Password is required")
|
|
*
|
|
* The last one is the whole trick: a password-less attempt against a real
|
|
* account is refused with a DIFFERENT code than a wrong password, so existence
|
|
* can be established without guessing at one.
|
|
*
|
|
* This does tell an anonymous caller whether an email has an account here. That
|
|
* is a real disclosure and worth naming — but the login already answers
|
|
* "Invalid Email" versus "Incorrect password" to any caller who sends a wrong
|
|
* 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 };
|
|
|
|
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 } },
|
|
);
|
|
|
|
if (envelope.code === 409 && envelope.details?.setup === true) {
|
|
return { state: 'setup', userid: envelope.details.userid ?? 0 };
|
|
}
|
|
// "Password is required" — the account is real and has one. Exactly what we
|
|
// wanted to learn, arriving as a refusal.
|
|
if (envelope.code === 401) {
|
|
return { state: 'password' };
|
|
}
|
|
throw new Error(loginMessage(envelope.code, envelope.message));
|
|
}
|
|
|
|
/** The backend's floor, enforced here too so the refusal is instant. */
|
|
export const MIN_PASSWORD_LENGTH = 6;
|
|
|
|
/**
|
|
* Sets the password on an account that has never had one.
|
|
*
|
|
* `POST /users/setpassword`, which is public — it has to be. This runs when
|
|
* nobody is signed in and cannot be: the account has no password yet, so there
|
|
* is no way to obtain a session first.
|
|
*
|
|
* It used to call `PUT /users/update`, which doubles as a password write but
|
|
* sits behind the session guard. Once `WEB_AUTH_REQUIRED` began defaulting on,
|
|
* that returned "a session token is required; sign in again" to somebody who
|
|
* could not sign in — sign-in needs a password, and setting the password needed
|
|
* a sign-in. Every branch login created with an empty password was unusable.
|
|
*
|
|
* The server refuses this on any account that already HAS a password, which is
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
export async function setInitialPassword(userid: number, 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 });
|
|
}
|
|
|
|
/**
|
|
* The backend's own words, where they are usable, and ours where they are not.
|
|
*
|
|
* "Invalid Email" is technically true and unhelpful — the same answer covers a
|
|
* typo and a till account, because roleids 7 and 8 are excluded from every web
|
|
* login lookup. A cashier is not refused here, they are not found, so the copy
|
|
* must not say "wrong password".
|
|
*/
|
|
function loginMessage(code: number | undefined, message: string | undefined): string {
|
|
if (code === 409) {
|
|
return 'We do not recognise that email. Till accounts (supervisor or cashier) sign in at the terminal, not here.';
|
|
}
|
|
if (code === 401) {
|
|
return message?.toLowerCase().includes('required')
|
|
? 'Enter your password.'
|
|
: 'That password is not right.';
|
|
}
|
|
// 403 covers both an inactive account and an inactive store, and the two
|
|
// messages differ — pass the backend's through rather than flattening them.
|
|
if (code === 403) return message ?? 'This account cannot sign in. Contact your administrator.';
|
|
return message ?? 'Sign-in failed';
|
|
}
|
|
|
|
export function persist(session: SessionUser): void {
|
|
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
|
}
|
|
|
|
export function restore(): SessionUser | null {
|
|
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw) as SessionUser;
|
|
// A stored blob is only as trustworthy as the tab it came from; a shape
|
|
// check keeps a corrupted value from crashing the shell on boot.
|
|
if (typeof parsed?.userid !== 'number' || typeof parsed?.role !== 'string') return null;
|
|
// A session without a token is not a session.
|
|
//
|
|
// This used to be restored happily, and the result was the worst state the
|
|
// console can be in: signed in by every visible measure — name in the
|
|
// corner, nav rendered, pages mounted — and unable to make a single
|
|
// authenticated request, because `authHeader()` had nothing to send. Every
|
|
// call came back 401 and nothing on screen explained why. A PUT to
|
|
// `users/update` on 2026-09-25 went out with no `authorization` header at
|
|
// all, which is what sent us looking.
|
|
//
|
|
// It happens whenever login could not mint one: `attachWebSession` logs the
|
|
// failure and returns the user record anyway, which was right while
|
|
// WEB_AUTH_REQUIRED was off and is a broken console now that it defaults on.
|
|
// A stored session predating the token also lands here.
|
|
//
|
|
// Returning null sends them to the sign-in screen, which is a state people
|
|
// know what to do with.
|
|
if (typeof parsed.token !== 'string' || parsed.token.trim() === '') return null;
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function clear(): void {
|
|
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
}
|