/** * Sign-in and session persistence. * * There is no token to hold. `TenantWebLogin` returns the user record and * nothing else, so the session IS that record. It is kept in sessionStorage * rather than localStorage: a shared back-office machine should not stay signed * in after the browser closes, and there is no server-side session to revoke. */ import { api, WEB } from '@/api/client'; import type { FiestaUser } from '@/api/types'; import { toSessionUser, type SessionUser } from './roles'; const STORAGE_KEY = 'nearle.session.v1'; /** 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 { const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID }; const envelope = await api.envelope( `${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)); } const session = toSessionUser(envelope.details); 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 { 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. * * `PUT /users/update` doubles as the password call. There is no dedicated * endpoint and no reset flow — the controller says so in as many words * (`userController.go:145`): "this endpoint also doubles as the * password-setup/reset call (userid + password only, everything else left zero * so GORM's `Updates` skips it)". Sending only those two fields is therefore * load-bearing: a struct with any other field populated would write it. * * This is reachable only with the `userid` that `applogin` just handed back for * an account it confirmed has an empty password. It is not a "change my * password" call and must not be wired up as one — nothing here verifies the * 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 { if (password.length < MIN_PASSWORD_LENGTH) { throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`); } await api.put(`${WEB}/users/update`, { 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(STORAGE_KEY, JSON.stringify(session)); } export function restore(): SessionUser | null { const raw = sessionStorage.getItem(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; return parsed; } catch { return null; } } export function clear(): void { sessionStorage.removeItem(STORAGE_KEY); }