Files
daily_console_web/src/auth/session.ts
2026-08-27 13:58:19 +05:30

207 lines
8.6 KiB
TypeScript

/**
* 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<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));
}
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<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.
*
* `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<void> {
if (password.length < MIN_PASSWORD_LENGTH) {
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`);
}
await api.put<unknown>(`${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);
}