Skip to content
Merged
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
9 changes: 9 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,15 @@ unrecognised one is refused with its own message. (Written earlier, before
#532 shipped: it was recorded and discoverable but not yet used at sign-in, and
there was no SPA surface for it.)

On the SPA sign-in page the field is prefilled in two ways. A `?farm=<slug>`
link prefills the field with a validated slug (an invalid value is ignored, not
truncated, and no error is shown). And the page remembers farm codes that were
used to sign in successfully on this device and offers them as a picker: a
single remembered code prefills the field, several render one button per code.
Accepted disclosure: on a shared device the cached list is a durable roster of
which farms that browser profile uses — revocable only by clearing the cache,
tracked in #587.

**Farm provisioning (#533)** — the offline `provision-account` operator command
creates a new account, its ten canonical egg grades, its six packed-unit
conversions, and its first Owner as one transaction. The Owner receives a
Expand Down
97 changes: 97 additions & 0 deletions web/src/auth/AuthContext.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,100 @@ describe("AuthProvider lifecycle", () => {
expect(mockSetOnUnauthenticated).toHaveBeenCalledWith(null);
});
});

// #535 — a SUCCESSFULLY signed-in farm code is remembered on this device so the
// login screen can offer it later. Remembered only after apiLogin resolves.
function MixedProbe() {
const { login } = useAuth();
return <button onClick={() => void login("Sunny-Acres", "a@b.co", "pw")}>mixed login</button>;
}

// A probe that catches the login rejection the way an application boundary would,
// so the rejected promise isn't left unhandled in the test run.
function GuardedLoginProbe() {
const { login } = useAuth();
return (
<button
onClick={() => {
void login("default-farm", "a@b.co", "pw").catch(() => {});
}}
>
guarded login
</button>
);
}

describe("AuthProvider remembers farm codes on successful login", () => {
beforeEach(() => {
localStorage.clear();
});

it("appends the code when apiLogin resolves", async () => {
setStoredToken({ sub: "u1", role: "Admin" });
mockApiLogin.mockResolvedValue(undefined);
renderAuth();

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "login" }));
});

const stored = JSON.parse(localStorage.getItem("cluckwork.farmCodes") ?? "[]");
expect(stored).toEqual(["default-farm"]);
});

it("writes nothing when apiLogin rejects", async () => {
setStoredToken({ sub: "u1", role: "Admin" });
mockApiLogin.mockRejectedValue(new Error("invalid credentials"));
render(<AuthProvider><GuardedLoginProbe /></AuthProvider>);

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "guarded login" }));
});

// rememberFarmCode sits AFTER the await, so a rejecting apiLogin never stores.
expect(localStorage.getItem("cluckwork.farmCodes")).toBeNull();
});

it("stores a mixed-case input normalised", async () => {
setStoredToken({ sub: "u1", role: "Admin" });
mockApiLogin.mockResolvedValue(undefined);
render(<AuthProvider><MixedProbe /></AuthProvider>);

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "mixed login" }));
});

const stored = JSON.parse(localStorage.getItem("cluckwork.farmCodes") ?? "[]");
expect(stored).toEqual(["sunny-acres"]);
});
});

// #535 review round 1 — the purge FUNCTION was tested in accountStorage.test.ts,
// but nothing asserted AuthProvider ever calls it: deleting the call site left the
// whole suite green. This pins the CALL, which is the part that ships the
// behaviour. It covers purgeLegacyTokens()'s call site too — the same untested
// shape, one line above it.
describe("AuthProvider purges pre-namespacing browser state on mount", () => {
beforeEach(() => {
localStorage.clear();
});

it("drops the bare cluckwork.lastFlockId and the legacy token blob, and leaves namespaced state alone", async () => {
const NS = "cluckwork.lastFlockId:11111111-1111-1111-1111-111111111111";
localStorage.setItem("cluckwork.lastFlockId", "f-from-an-unknown-farm");
localStorage.setItem("cluckwork.tokens", "{}");
localStorage.setItem(NS, "f-keep");

render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId("auth")).toHaveTextContent("false"));

expect(localStorage.getItem("cluckwork.lastFlockId")).toBeNull();
expect(localStorage.getItem("cluckwork.tokens")).toBeNull();
// A namespaced value belongs to a known farm and must survive the purge.
expect(localStorage.getItem(NS)).toBe("f-keep");
});
});
26 changes: 22 additions & 4 deletions web/src/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { currentUserId, currentUserIsAdmin, currentUserMustChangePassword, curre
import type { Role } from "./claims";
import { clearAccessToken, getAccessToken, purgeLegacyTokens } from "./tokenStore";
import { clearSplashSeenMarker } from "../session/SessionContext";
import { purgeUnscopedAccountState } from "../lib/accountStorage";
import { rememberFarmCode } from "./farmCodeCache";

interface AuthState {
isAuthenticated: boolean;
Expand Down Expand Up @@ -81,6 +83,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// session in place; failure lands the user cleanly on /login, no error flash.
useEffect(() => {
purgeLegacyTokens();
// #535 — the pre-namespacing per-account keys. Dropped rather than migrated:
// the app cannot attribute them to a farm, so migrating would be a guess.
purgeUnscopedAccountState();
if (getAccessToken() !== null) {
setIsLoading(false);
return;
Expand All @@ -106,6 +111,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {

const login = useCallback(async (farmCode: string, email: string, password: string) => {
await apiLogin({ farmCode, email, password });
// #535 — remembered only AFTER apiLogin resolves, so a typo is never stored:
// a failed sign-in throws out of apiLogin (client.ts:144 `if (!res.ok) throw`)
// and never reaches this line, and neither does the superseded-session path
// (client.ts:242 StaleSessionError). The value is normalised inside
// rememberFarmCode, mirroring the server's own Trim().ToLowerInvariant()
// lookup, so a capitalised code is still remembered.
// Awaited rather than fire-and-forget so the roster write is ordered before
// login's other side effects, and safe to await because rememberFarmCode
// never rejects.
await rememberFarmCode(farmCode);
// #179/codex: a fresh explicit login is a new "per login" for the splash,
// even in a tab that already dismissed it (or belonged to another user).
// A silent token refresh never reaches this line, so it's exempt by design.
Expand All @@ -116,10 +131,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [refreshClaims]);

const logout = useCallback(async () => {
// The farm palette (cluckwork.brand) is deliberately NOT cleared here: this
// is a single-farm deployment, so the login screen should keep showing the
// farm's palette rather than reverting to the default (user choice, #149).
// The palette is now device-persistent, the same way cluckwork.theme is.
// The farm palette (cluckwork.brand) is deliberately NOT cleared here: it
// stays device-persistent, the same way cluckwork.theme is (user choice,
// #149), so the login screen keeps showing the last palette rather than
// reverting to the default. That persistence means a multi-farm device
// shows the previous farm's palette on the next login — the "single-farm
// deployment assumption this once leaned on is gone now that accounts
// coexist (#530) — the per-farm fix for that is tracked in #586.
await apiLogout();
setIsAuthenticated(false);
setIsAdmin(false);
Expand Down
Loading
Loading