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
5 changes: 5 additions & 0 deletions app/components/editor/FooterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { Button } from "@core/shared-ui";
import type { PresenceUser, PresenceStatus } from "~/hooks/usePresence";
import { m } from "~/paraglide/messages";
import { SignInAgainLink } from "./SignInAgainLink";

interface FooterBarProps {
connected?: boolean;
Expand Down Expand Up @@ -91,12 +92,16 @@ export function FooterBar({ connected = false, status, roster = [] }: FooterBarP
<span className={`w-1.5 h-1.5 rounded-full ${
effectiveStatus === 'connected'
? 'bg-ih-ok'
: effectiveStatus === 'expired'
? 'bg-ih-bad'
: effectiveStatus === 'reconnecting'
? 'bg-ih-watch'
: 'bg-ih-fg-4 animate-pulse'
}`} />
{effectiveStatus === 'connected'
? m.editor_footer_status_connected()
: effectiveStatus === 'expired'
? <SignInAgainLink>{m.editor_session_expired()}</SignInAgainLink>
: effectiveStatus === 'reconnecting'
? m.editor_footer_status_reconnecting()
: m.editor_footer_status_connecting()}
Expand Down
13 changes: 13 additions & 0 deletions app/components/editor/MobileSyncPill.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";

import { MobileSyncPill } from "./MobileSyncPill";

Expand All @@ -31,4 +32,16 @@ describe("MobileSyncPill", () => {
rerender(<MobileSyncPill inspectionId="i1" connection="connecting" />);
expect(state()).toBe("connecting");
});

it("turns into the way back to this item when the session has expired", () => {
render(
<MemoryRouter initialEntries={["/inspections/i1/edit?section=s1&item=a1"]}>
<MobileSyncPill inspectionId="i1" connection="expired" />
</MemoryRouter>,
);
expect(state()).toBe("expired");
const href = screen.getByRole("link", { name: "Session expired" }).getAttribute("href") ?? "";
expect(href.startsWith("/login?")).toBe(true);
expect(new URLSearchParams(href.slice("/login?".length)).get("returnTo")).toBe("/inspections/i1/edit?section=s1&item=a1");
});
});
11 changes: 8 additions & 3 deletions app/components/editor/MobileSyncPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { listPendingMedia } from "~/lib/collab/media-pending-store";
import type { PresenceStatus } from "~/hooks/usePresence";
import { m } from "~/paraglide/messages";
import { SignInAgainLink } from "./SignInAgainLink";

/** Poll interval while photos are queued. Slow on purpose: the pill is a
* reassurance readout, not a progress bar, and this runs on a phone. */
Expand All @@ -24,7 +25,9 @@ const POLL_MS = 3000;
* "Synced". `navigator.onLine` only reports that the phone has a network, not
* that this server answers on it (one bar in a crawlspace, a captive portal, a
* deploy in progress), and the edit made in that window is on the phone only.
* `connection` is the same status the desktop `FooterBar` shows.
* `connection` is the same status the desktop `FooterBar` shows. Its "expired"
* is the one state the inspector can end themselves, so there the label is the
* link that does it.
*/
export function MobileSyncPill({ inspectionId, connection = "connected" }: { inspectionId: string; connection?: PresenceStatus }) {
const [online, setOnline] = useState(true);
Expand Down Expand Up @@ -63,6 +66,8 @@ export function MobileSyncPill({ inspectionId, connection = "connected" }: { ins
const label =
state === "offline"
? m.editor_mobile_sync_offline({ count: pending })
: state === "expired"
? <SignInAgainLink>{m.editor_session_expired_short()}</SignInAgainLink>
: state === "reconnecting"
? m.editor_footer_status_reconnecting()
: state === "connecting"
Expand All @@ -73,14 +78,14 @@ export function MobileSyncPill({ inspectionId, connection = "connected" }: { ins
// A fresh page is "connecting" for a moment and that is not a warning, so
// it stays neutral — the same reading the desktop footer gives it.
const tone =
state === "offline"
state === "offline" || state === "expired"
? "bg-ih-bad-bg text-ih-bad-fg"
: state === "connecting"
? "bg-ih-bg-muted text-ih-fg-2"
: state === "synced"
? "bg-ih-ok-bg text-ih-ok-fg"
: "bg-ih-watch-bg text-ih-watch-fg";
const dot = state === "offline" ? "bg-ih-bad" : state === "connecting" ? "bg-ih-fg-3" : state === "synced" ? "bg-ih-ok" : "bg-ih-watch";
const dot = state === "offline" || state === "expired" ? "bg-ih-bad" : state === "connecting" ? "bg-ih-fg-3" : state === "synced" ? "bg-ih-ok" : "bg-ih-watch";

return (
<span
Expand Down
27 changes: 27 additions & 0 deletions app/components/editor/SignInAgainLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { useLocation } from "react-router";
import { m } from "~/paraglide/messages";

/**
* The way back in when the session ended under an open editor.
*
* `returnTo` is this page with its `?section=`/`?item=`, so logging in lands on
* the finding being written rather than on the inspection list. It is a plain
* anchor, not a `<Link>`: the cookies change underneath the page, and a full
* document load is the one navigation that cannot carry stale state across.
*
* The padding with its equal negative margin makes the tap target at least
* 44px tall (an 18px line plus 2 x 14px) without moving the 24px pill it sits in.
*/
export function SignInAgainLink({ children }: { children: ReactNode }) {
const { pathname, search } = useLocation();
return (
<a
href={`/login?returnTo=${encodeURIComponent(pathname + search)}`}
title={m.editor_session_expired()}
className="underline py-3.5 -my-3.5"
>
{children}
</a>
);
}
105 changes: 105 additions & 0 deletions app/hooks/usePresence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
/**
* A refused WebSocket handshake and a dead network look the same to a browser,
* so "expired" has to come from somewhere other than the socket. These pin where:
* a socket that closed without opening, plus a 401 from the API on the same
* cookie. Each "is not expired" case is the control for the first test: a hook
* that said "expired" on every failed handshake would pass it alone.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { usePresence } from "./usePresence";

/** A socket the test settles by hand: `open()` accepts the handshake, `close()` before it refuses. */
class StubSocket extends EventTarget {
static readonly OPEN = 1;
static last: StubSocket;
readyState = 0;
constructor() {
super();
StubSocket.last = this;
}
send(): void { /* presence frames are not under test */ }
open(): void {
this.readyState = 1;
this.dispatchEvent(new Event("open"));
}
close(): void {
this.readyState = 3;
this.dispatchEvent(new Event("close"));
}
}

const fetchMock = vi.fn();
const mount = () => renderHook(() => usePresence({ inspectionId: "i1", userId: "u1", userName: "Inspector" }));
/** Refuse the newest socket's handshake, then let the session check answer. */
const refuse = () => act(async () => {
StubSocket.last.close();
await vi.advanceTimersByTimeAsync(0);
});

beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("WebSocket", StubSocket);
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

describe("usePresence session expiry", () => {
it("reads expired when the handshake is refused and the API answers 401, until a retry opens", async () => {
fetchMock.mockResolvedValue({ status: 401 });
const { result } = mount();
const first = StubSocket.last;

await refuse();
expect(fetchMock).toHaveBeenCalledWith("/api/auth/me");
expect(result.current.status).toBe("expired");

// The retry is refused too. The reading holds from the moment it closes,
// before its own check has answered: no flash of "Reconnecting…".
await act(() => vi.advanceTimersByTimeAsync(400));
expect(StubSocket.last).not.toBe(first);
act(() => StubSocket.last.close());
expect(result.current.status).toBe("expired");

// Signed in from another tab: the next retry is let in.
await act(() => vi.advanceTimersByTimeAsync(800));
act(() => StubSocket.last.open());
expect(result.current.status).toBe("connected");
});

it("is not expired when the handshake is refused but the session is live", async () => {
fetchMock.mockResolvedValue({ status: 200 });
const { result } = mount();
await refuse();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.current.status).toBe("connecting");
});

it("is not expired when the API cannot be reached, and does not ask while offline", async () => {
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
const { result } = mount();
await refuse();
expect(result.current.status).toBe("connecting");

fetchMock.mockClear();
vi.spyOn(navigator, "onLine", "get").mockReturnValue(false);
await act(() => vi.advanceTimersByTimeAsync(400));
await refuse();
expect(fetchMock).not.toHaveBeenCalled();
expect(result.current.status).toBe("connecting");
});

it("does not ask when an open socket drops: that one was let in", async () => {
const { result } = mount();
act(() => StubSocket.last.open());
await refuse();
expect(fetchMock).not.toHaveBeenCalled();
expect(result.current.status).toBe("reconnecting");
});
});
34 changes: 31 additions & 3 deletions app/hooks/usePresence.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { isOffline } from "~/lib/network-failure";

/* ------------------------------------------------------------------ */
/* Types */
Expand All @@ -11,8 +12,12 @@ export interface PresenceUser {
focusItemId?: string | null;
}

/** FE-5 — tri-state so the UI can distinguish "still connecting" from "lost". */
export type PresenceStatus = "connecting" | "connected" | "reconnecting";
/**
* FE-5 — tri-state so the UI can distinguish "still connecting" from "lost".
* "expired" is a lost connection with a known cause: the server refuses the
* socket because there is no session, so no amount of retrying will reopen it.
*/
export type PresenceStatus = "connecting" | "connected" | "reconnecting" | "expired";

/* ------------------------------------------------------------------ */
/* Constants */
Expand Down Expand Up @@ -44,6 +49,9 @@ export function usePresence(options: {
// read as data loss by field users. Track connecting/connected/reconnecting.
const [status, setStatus] = useState<PresenceStatus>("connecting");
const everConnectedRef = useRef(false);
// Held in a ref so each failed retry keeps the reading instead of flashing
// "Reconnecting…" until its own session check answers.
const expiredRef = useRef(false);
const wsRef = useRef<WebSocket | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const backoffRef = useRef(400);
Expand All @@ -65,8 +73,13 @@ export function usePresence(options: {
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
let opened = false;
const downStatus = (): PresenceStatus =>
expiredRef.current ? "expired" : everConnectedRef.current ? "reconnecting" : "connecting";

ws.addEventListener("open", () => {
opened = true;
expiredRef.current = false;
backoffRef.current = 400;
everConnectedRef.current = true;
setStatus("connected");
Expand Down Expand Up @@ -97,7 +110,22 @@ export function usePresence(options: {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
setConnected(false);
setStatus(everConnectedRef.current ? "reconnecting" : "connecting");
setStatus(downStatus());
// A browser is never told why a WebSocket handshake was refused: a 401
// and a dead network both arrive as a bare `close`. So a socket that
// closed without opening asks the API, with the cookie the socket
// itself sends, whether there is still a session. Only a 401 reads as
// "expired"; the retries below carry on either way, so signing in from
// another tab reconnects this one.
if (!opened && !closedRef.current && !isOffline()) {
void fetch("/api/auth/me").then(
(res) => {
expiredRef.current = res.status === 401;
if (!closedRef.current && wsRef.current?.readyState !== WebSocket.OPEN) setStatus(downStatus());
},
() => { /* unreachable: a network problem, not a session one */ },
);
}
// Schedule reconnect
if (!closedRef.current) {
setTimeout(() => connect(), backoffRef.current);
Expand Down
2 changes: 1 addition & 1 deletion app/lib/session.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export async function getToken(context: LoadContext, request: Request): Promise<
* cheaper question: is it even worth sending? A token whose payload cannot be
* read counts as expired, so an unreadable value can never be passed on.
*/
function isTokenExpired(token: string, nowMs: number): boolean {
export function isTokenExpired(token: string, nowMs: number): boolean {
const payload = token.split(".")[1];
if (!payload) return true;
try {
Expand Down
18 changes: 15 additions & 3 deletions app/routes/login-saas-bounce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ beforeEach(() => {

type LoaderArgs = Parameters<typeof loader>[0];

// The loader reads `exp` from the token's payload and nothing else; the API is
// what verifies a signature. So a session is a payload with a future `exp`.
const cookieExpiring = (exp: number) => `__Host-inspector_token=h.${btoa(JSON.stringify({ exp }))}.s`;
const LIVE_COOKIE = cookieExpiring(4102444800); // 2100-01-01

// happy-dom enforces the Fetch spec's forbidden-header list and silently drops a
// `Cookie` header set on `new Request(...)`, so we hand-roll a minimal request
// object whose `headers.get('Cookie')` we fully control (the loader only reads
Expand Down Expand Up @@ -89,15 +94,22 @@ describe('login loader — returnTo (B3)', () => {

it('redirects an already-authenticated user to the same-origin returnTo', async () => {
const res = await loader(
args({}, { cookie: '__Host-inspector_token=fake.jwt.value', returnTo: '/oauth/authorize?x=1' }),
args({}, { cookie: LIVE_COOKIE, returnTo: '/oauth/authorize?x=1' }),
);
expect(res).toBeInstanceOf(Response);
expect((res as Response).headers.get('Location')).toBe('/oauth/authorize?x=1');
});

it('shows the form, returnTo kept, when the token is past its exp (NET-06)', async () => {
// Bounced to returnTo, that page's loader would end the session and send
// the visitor back here with the destination gone.
const res = await loader(args({}, { cookie: cookieExpiring(1), returnTo: '/inspections/i1/edit?item=a' }));
expect(res).toEqual({ returnTo: '/inspections/i1/edit?item=a' });
});

it('ignores an off-origin returnTo for an authenticated user (no open redirect)', async () => {
const res = await loader(
args({}, { cookie: '__Host-inspector_token=fake.jwt.value', returnTo: '//evil.test/phish' }),
args({}, { cookie: LIVE_COOKIE, returnTo: '//evil.test/phish' }),
);
expect(res).toBeInstanceOf(Response);
expect((res as Response).headers.get('Location')).toBe('/inspections');
Expand Down Expand Up @@ -129,7 +141,7 @@ describe('login loader — first run on a standalone instance (ONB-01)', () => {

it('a signed-in visitor is not asked, and goes to the app', async () => {
setupStatus.mockResolvedValue(status(false));
const res = await loader(args({}, { cookie: '__Host-inspector_token=fake.jwt.value' }));
const res = await loader(args({}, { cookie: LIVE_COOKIE }));
expect(setupStatus).not.toHaveBeenCalled();
expect((res as Response).headers.get('Location')).toBe('/inspections');
});
Expand Down
6 changes: 4 additions & 2 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Form, useActionData, useLoaderData, useNavigation, redirect } from "rea
import { useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod/v4";
import type { Route } from "./+types/login";
import { getToken, createSessionWithToken } from "~/lib/session.server";
import { getToken, isTokenExpired, createSessionWithToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
import { makeLoginSchema } from "~/lib/forms/auth.schema";
import { AuthShell } from "~/components/AuthShell";
Expand Down Expand Up @@ -37,8 +37,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// it to same-origin paths, so an attacker can't turn this into an open
// redirect. Absent/invalid → /inspections (unchanged behavior).
const returnTo = new URL(request.url).searchParams.get("returnTo");
// A token past its `exp` is not a session: bouncing it to `returnTo` only has
// that page's loader send it straight back here with the destination dropped.
const token = await getToken(context, request);
if (token) return redirect(safeReturnTo(returnTo, "/inspections"));
if (token && !isTokenExpired(token, Date.now())) return redirect(safeReturnTo(returnTo, "/inspections"));

// ONB-01 — a standalone instance with no users has nobody who could sign in,
// and `/setup` is only findable by already knowing the URL. Ask the question
Expand Down
Loading
Loading