fix(login): persona click signs in directly + friendly error copy
build-and-publish / test (pull_request) Has been cancelled
build-and-publish / image (pull_request) Has been cancelled

- Persona chips (Mariana CEO, Aisha HR, Rohan IT) now run dev-login on
  click instead of just populating the email field. Closes the dogfood
  failure 'ceo_persona_dev_login_lands_on_landing'.
- api.ts: replace 'Login failed: 502 <html>...502 Bad Gateway...' with
  a human-readable friendlyAuthError() mapping (502/504/503/404/401)
  so a regional store manager sees 'auth service temporarily down' not
  an HTTP status dump. Aligns with the business-as-code mandate.
- api.test: ping() failure assertion widened to match the new copy.

31/31 vitest tests pass. tsc + vite build green.
This commit is contained in:
canvas-bot
2026-06-15 01:25:37 +04:00
parent d68cb5b34e
commit 1f419dc078
3 changed files with 27 additions and 8 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ describe("api client", () => {
]);
const r = await api.ping();
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/401/);
expect(r.reason).toMatch(/rejected|401|sign-in/i);
});
it("workItems() returns the items array", async () => {
+11 -5
View File
@@ -19,6 +19,14 @@ const DEFAULT_CONFIG: ApiConfig = {
const TOKEN_KEY = "fm.mc.token.v1";
function friendlyAuthError(status: number, kind: string): string {
if (status === 502 || status === 504) return `${kind} unavailable: the EA2 backend is not reachable from this environment right now. Try again shortly.`;
if (status === 503) return `${kind} unavailable: the auth service is temporarily down. Try again shortly.`;
if (status === 404) return `${kind} is not wired to this environment yet.`;
if (status === 401 || status === 403) return `${kind} rejected: check the email and password.`;
return `${kind} failed (HTTP ${status}). Try again or contact the operator on call.`;
}
let inflightLogin: Promise<string> | null = null;
async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
@@ -30,7 +38,7 @@ async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
body: JSON.stringify({ email: cfg.email }),
signal,
});
if (!r.ok) throw new Error(`dev-login ${r.status}`);
if (!r.ok) throw new Error(friendlyAuthError(r.status, "Developer sign-in"));
const body = (await r.json()) as { access_token: string };
sessionStorage.setItem(TOKEN_KEY, body.access_token);
return body.access_token;
@@ -213,8 +221,7 @@ export const api = {
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}/api/v1/auth/login`, init, { email, password });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
throw new Error(friendlyAuthError(r.status, "Sign-in"));
}
const data = await r.json() as { access_token: string; refresh_token?: string };
this.setBearer(data.access_token);
@@ -230,8 +237,7 @@ export const api = {
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}/api/v1/auth/dev-login`, init, { email });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Dev-login failed: ${r.status} ${text.slice(0, 100)}`);
throw new Error(friendlyAuthError(r.status, "Developer sign-in"));
}
const data = await r.json() as { access_token: string; refresh_token?: string };
this.setBearer(data.access_token);
+15 -2
View File
@@ -206,9 +206,22 @@ export default function Login() {
key={p.key}
type="button"
className="persona-chip"
onClick={() => setEmail(p.email)}
onClick={async () => {
setEmail(p.email);
if (!devLoginEnabled) return;
setLoading(true);
setError(null);
try {
await loginAs(p.email, undefined, { method: "dev" });
setScene("landing");
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}}
disabled={loading}
title={p.email}
title={`Sign in as ${p.title}`}
>
{p.title}
</button>