diff --git a/src/lib/api.ts b/src/lib/api.ts index 44590b0..601c79f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -191,16 +191,15 @@ export const api = { return authedRequest(this.config, "GET", "/api/v1/auth/me", undefined, signal); }, - async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> { - const payload = password ? { email, password } : { email }; - const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login"; + async passwordLogin(email: string, password: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> { + if (!password) throw new Error("Password required"); const init: RequestInit = { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify({ email, password }), signal, }; - const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload); + 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)}`); @@ -210,6 +209,23 @@ export const api = { return data; }, + async devLogin(email: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> { + const init: RequestInit = { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ email }), + signal, + }; + 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)}`); + } + const data = await r.json() as { access_token: string; refresh_token?: string }; + this.setBearer(data.access_token); + return data; + }, + async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean | null }> { try { const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal }; diff --git a/src/scenes/Login.test.tsx b/src/scenes/Login.test.tsx index 2b86fe5..681f4be 100644 --- a/src/scenes/Login.test.tsx +++ b/src/scenes/Login.test.tsx @@ -22,58 +22,43 @@ describe("Login Scene", () => { beforeEach(() => { vi.clearAllMocks(); - - // Default store mock (useApp as any).mockImplementation((selector: any) => { - const state = { - setScene: mockSetScene, - loginAs: mockLoginAs, - }; + const state = { setScene: mockSetScene, loginAs: mockLoginAs }; return selector(state); }); - - // Default api mock (api.devLoginConfig as any).mockResolvedValue({ enabled: false }); + globalThis.fetch = vi.fn(async () => new Response("", { status: 404 })) as any; }); it("renders standard login form elements", async () => { render(); - expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined(); expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined(); expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined(); expect(screen.getAllByText("SIGN IN")[0]).toBeDefined(); - expect(screen.getAllByText("CONTINUE WITH MICROSOFT")[0]).toBeDefined(); + expect(screen.getAllByText(/MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i)[0]).toBeDefined(); }); - it("calls loginAs and setScene on valid form submission", async () => { + it("calls loginAs with password method on valid form submission", async () => { mockLoginAs.mockResolvedValueOnce(undefined); - render(); - fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } }); fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } }); - fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]); - await waitFor(() => { - expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123'); + expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123', { method: 'password' }); expect(mockSetScene).toHaveBeenCalledWith('landing'); }); }); - it("displays error on login failure", async () => { - mockLoginAs.mockRejectedValueOnce(new Error("Invalid credentials")); - + it("blocks empty-password SIGN IN — must NOT silently call dev-login", async () => { render(); - fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } }); fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]); - await waitFor(() => { - expect(screen.getByText("Invalid credentials")).toBeDefined(); - expect(mockSetScene).not.toHaveBeenCalled(); + expect(screen.getByText(/Password required/i)).toBeDefined(); }); + expect(mockLoginAs).not.toHaveBeenCalled(); }); it("shows dev-login button when feature flag is enabled", async () => { @@ -86,16 +71,11 @@ describe("Login Scene", () => { }); }); - it("redirects to SSO endpoint on microsoft click", async () => { - // We can't actually assert on window.location.href in this test environment - // without mocking window.location, but we can verify the button renders - // and click it to ensure no errors + it("SSO button is disabled until probe resolves to ready", async () => { render(); - - const ssoBtn = screen.getAllByRole("button", { name: /CONTINUE WITH MICROSOFT/i })[0]; + const ssoBtn = screen.getAllByRole("button", { name: /MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i })[0] as HTMLButtonElement; + expect(ssoBtn.disabled).toBe(true); fireEvent.click(ssoBtn); - - // We just verify it doesn't try to call the regular login expect(mockLoginAs).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/src/scenes/Login.tsx b/src/scenes/Login.tsx index abc2ff6..d944183 100644 --- a/src/scenes/Login.tsx +++ b/src/scenes/Login.tsx @@ -35,10 +35,14 @@ export default function Login() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!email) return; + if (!password) { + setError("Password required. Use the developer button below if dev-login is enabled."); + return; + } setLoading(true); setError(null); try { - await loginAs(email, password || undefined); + await loginAs(email, password, { method: "password" }); setScene("landing"); } catch (err) { setError((err as Error).message); @@ -48,6 +52,10 @@ export default function Login() { }; const handleDevLogin = async () => { + if (!devLoginEnabled) { + setError("Developer sign-in is disabled in this build."); + return; + } if (!email) { setError("Email required for dev-login"); return; @@ -55,7 +63,7 @@ export default function Login() { setLoading(true); setError(null); try { - await loginAs(email, undefined); + await loginAs(email, undefined, { method: "dev" }); setScene("landing"); } catch (err) { setError((err as Error).message); @@ -158,8 +166,13 @@ export default function Login() { type="button" className="sso-btn" onClick={handleSSO} - disabled={loading || ssoState === "not-configured" || ssoState === "not-wired"} - title={ssoState === "not-configured" ? "Microsoft sign-in is not yet configured for this tenant" : ssoState === "not-wired" ? "Microsoft sign-in is not wired to this environment yet" : "Sign in with Microsoft"} + disabled={loading || ssoState !== "ready"} + title={ + ssoState === "ready" ? "Sign in with Microsoft" + : ssoState === "not-configured" ? "Microsoft sign-in is not yet configured for this tenant" + : ssoState === "not-wired" ? "Microsoft sign-in is not wired to this environment yet" + : "Checking Microsoft sign-in availability…" + } > @@ -167,7 +180,10 @@ export default function Login() { - {ssoState === "not-configured" ? "MICROSOFT SIGN-IN · NOT CONFIGURED" : ssoState === "not-wired" ? "MICROSOFT SIGN-IN · NOT WIRED" : "CONTINUE WITH MICROSOFT"} + {ssoState === "unknown" ? "MICROSOFT SIGN-IN · CHECKING…" + : ssoState === "not-configured" ? "MICROSOFT SIGN-IN · NOT CONFIGURED" + : ssoState === "not-wired" ? "MICROSOFT SIGN-IN · NOT WIRED" + : "CONTINUE WITH MICROSOFT"} {devLoginEnabled && ( diff --git a/src/state/store.ts b/src/state/store.ts index 329de94..13c1b0b 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -96,7 +96,7 @@ interface AppState { userDisplayName: string | null; isAuthed: boolean; setUserEmail: (e: string) => void; - loginAs: (email: string, password?: string) => Promise; + loginAs: (email: string, password?: string, options?: { method?: "password" | "dev" }) => Promise; /** Live polling — lightweight tick that only refreshes the active * scenario's work-items + headline runtime. Full refresh is manual. */ @@ -258,13 +258,18 @@ export const useApp = create((set, get) => { userDisplayName: null, isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"), setUserEmail: (e) => { set({ userEmail: e }); persist(); }, - loginAs: async (email, password) => { + loginAs: async (email, password, options) => { api.clearToken(); api.config = { ...api.config, email }; set({ userEmail: email }); persist(); try { - await api.signIn(email, password); + if (options?.method === "dev") { + await api.devLogin(email); + } else { + if (!password) throw new Error("Password required"); + await api.passwordLogin(email, password); + } const me = await api.me(); set({ actor: { mode: "direct_user", user_id: me.user_id },