feat(panel): local-password login and forced password change

Add the op.console login and forced first-login password-change flow to
the panel. RequireAuth bounces an unauthenticated visitor to /login;
both /login and /change-password render outside the app shell with their
own centered chrome.

- TierProvider now derives auth state (deriveAuth) and exposes refresh()
  so a successful login re-fetches identity without a full reload; only a
  genuine 401 marks the session unauthenticated, so a transient /me
  failure keeps a healthy Zero-Trust principal in the app.
- api.login/logout/changePassword send Content-Type: application/json on
  bodied requests to satisfy the backend guard; humanizeError maps the
  auth error codes to stable copy.

Covered by vitest unit tests for deriveAuth branch coverage and the
login/change-password wire-shape contracts.
This commit is contained in:
flyemoji committed 2026-06-27 04:23:32 +09:00
1 parent e108a3709a
commit 885c4a9bd8
12 files changed
+688 -58

No files matched your search

+88
View File
@@ -62,4 +62,92 @@ describe("api.me wire shape", () => {
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("surfaces must_change_password from GET /me verbatim", async () => {
// handleMe always emits must_change_password; the forced-change gate routes on
// it, so the snake_case key must survive the untyped boundary unchanged.
const body = {
user_id: "u4",
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: true,
};
vi.stubGlobal("fetch", fakeFetch(body));
const id = await api.me();
expect(id.must_change_password).toBe(true);
});
});
describe("local-password auth wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("login POSTs {username, password} and returns must_change_password", async () => {
// EXACTLY handlers_auth.go handleLogin's request body and response.
const fetchSpy = fakeFetch({
user_id: "u1",
role: "admin",
must_change_password: true,
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.login("owner", "s3cret");
expect(res.must_change_password).toBe(true);
expect(res.user_id).toBe("u1");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/login");
expect((opts as RequestInit).method).toBe("POST");
expect((opts as RequestInit).credentials).toBe("include");
// The Go login route now REQUIRES Content-Type: application/json (it 415s any
// other type to kill the cross-site form-POST forgery vector). This pins the
// panel half of that contract: a refactor that drops the header silently breaks
// login, and only this assertion would catch it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
username: "owner",
password: "s3cret",
});
});
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
const fetchSpy = fakeFetch({ ok: true });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.logout();
expect(res.ok).toBe(true);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/logout");
expect((opts as RequestInit).method).toBe("POST");
});
it("changePassword POSTs {current_password, new_password}", async () => {
const fetchSpy = fakeFetch({ ok: true });
vi.stubGlobal("fetch", fetchSpy);
await api.changePassword("old-pw", "brand-new-pw");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/change-password");
expect((opts as RequestInit).method).toBe("POST");
// Same JSON content-type contract as login — the change-password route guards on
// it too (defense-in-depth), so the panel must keep sending it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
current_password: "old-pw",
new_password: "brand-new-pw",
});
});
it("maps the auth error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
});
});