Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/components/auth/LoginGate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LoginGate } from "@/components/auth/LoginGate";
import { useAuthStore } from "@/store/auth-store";

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

vi.mock("@/components/shared/LanguageSwitcher", () => ({
LanguageSwitcher: () => null,
}));

const INITIAL = useAuthStore.getState();

describe("LoginGate", () => {
beforeEach(() => {
act(() => {
useAuthStore.setState({
...INITIAL,
gatewayUrl: "",
token: "",
password: "",
authStatus: "unauthenticated",
authError: null,
defaults: { gatewayUrl: "", token: "" },
});
});
});

// <App> seeds the store inside an effect, which React runs only after this
// component has mounted. Rendering first and hydrating second is not an
// artificial ordering — it is exactly what happens in the browser.
it("backfills the form when defaults arrive after mount", () => {
render(<LoginGate />);

expect(screen.getByLabelText<HTMLInputElement>("fields.gatewayUrl").value).toBe("");

act(() => {
useAuthStore
.getState()
.hydrate({ gatewayUrl: "ws://gateway.test/gateway-ws", token: "tok-123" });
});

expect(screen.getByLabelText<HTMLInputElement>("fields.gatewayUrl").value).toBe(
"ws://gateway.test/gateway-ws",
);
expect(screen.getByLabelText<HTMLInputElement>("fields.token").value).toBe("tok-123");
});

it("keeps values the user already typed", () => {
act(() => {
useAuthStore.getState().hydrate({ gatewayUrl: "ws://first.test", token: "first" });
});

render(<LoginGate />);

const url = screen.getByLabelText<HTMLInputElement>("fields.gatewayUrl");
act(() => {
url.focus();
});
act(() => {
// Simulate a correction typed by the user before the store settles.
useAuthStore.setState({ gatewayUrl: "ws://second.test", token: "second" });
});

// A later default must never clobber what is already in the field.
expect(screen.getByLabelText<HTMLInputElement>("fields.gatewayUrl").value).toBe(
"ws://first.test",
);
});
});
19 changes: 18 additions & 1 deletion src/components/auth/LoginGate.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher";
import { useAuthStore } from "@/store/auth-store";
Expand All @@ -21,6 +21,23 @@ export function LoginGate() {
const [showToken, setShowToken] = useState(false);
const [showPassword, setShowPassword] = useState(false);

// The store is seeded from the injected config by an effect in <App>, which
// React runs *after* this component has mounted. The useState() calls above
// therefore capture the pre-hydration values (empty strings), and a plain
// re-render never revisits them — so the form stays blank even though the
// deployment provided a gateway URL and token.
//
// Backfill once the defaults arrive, using functional updates so anything the
// user has already typed always wins. This does not restore credentials from
// localStorage; that remains deliberately opt-in via submit (see auth-store).
useEffect(() => {
setGatewayUrl((current) => current || gatewayUrlInit);
}, [gatewayUrlInit]);

useEffect(() => {
setToken((current) => current || tokenInit);
}, [tokenInit]);

const isAuthenticating = authStatus === "authenticating";

const handleSubmit = (e: React.FormEvent) => {
Expand Down