@remit/web-client 0.0.280 → 0.0.282

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.280",
3
+ "version": "0.0.282",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -10,6 +10,7 @@ import {
10
10
  import { type FormEvent, type ReactNode, useState } from "react";
11
11
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
12
12
  import { FatalErrorOverlay } from "@/components/ui/FatalErrorOverlay";
13
+ import { openBugReport } from "@/lib/bug-report";
13
14
  import { authFooterNote } from "./account-menu-mode";
14
15
  import {
15
16
  type AuthRequest,
@@ -22,6 +23,8 @@ import {
22
23
  isBetterAuthEnabled,
23
24
  isSignUpDisabledError,
24
25
  } from "./better-auth-config";
26
+ import { SessionRateLimited } from "./SessionRateLimited";
27
+ import { classifySessionQuery, type SessionQuery } from "./session-state";
25
28
 
26
29
  type Mode = "signIn" | "signUp";
27
30
 
@@ -189,10 +192,42 @@ export const SignInForm = () => {
189
192
  );
190
193
  };
191
194
 
192
- export const BetterAuthShell = ({ children }: BetterAuthShellProps) => {
193
- const { data: session, isPending } = authClient.useSession();
195
+ interface SessionGateProps {
196
+ session: SessionQuery;
197
+ onRetry: () => void;
198
+ children: ReactNode;
199
+ }
194
200
 
195
- if (isPending) return <AppShellSkeleton />;
196
- if (session) return <>{children}</>;
201
+ /**
202
+ * What the app shows for each state the session lookup can be in, separated
203
+ * from the hook that feeds it so every state can be driven directly.
204
+ */
205
+ export const SessionGate = ({
206
+ session,
207
+ onRetry,
208
+ children,
209
+ }: SessionGateProps) => {
210
+ const state = classifySessionQuery(session);
211
+
212
+ if (state.kind === "active") return <>{children}</>;
213
+ if (state.kind === "pending") return <AppShellSkeleton />;
214
+ if (state.kind === "rateLimited") {
215
+ return <SessionRateLimited onRetry={onRetry} onReport={openBugReport} />;
216
+ }
197
217
  return <SignInForm />;
198
218
  };
219
+
220
+ export const BetterAuthShell = ({ children }: BetterAuthShellProps) => {
221
+ const { data, isPending, error, refetch } = authClient.useSession();
222
+
223
+ return (
224
+ <SessionGate
225
+ session={{ data, isPending, error }}
226
+ onRetry={() => {
227
+ void refetch();
228
+ }}
229
+ >
230
+ {children}
231
+ </SessionGate>
232
+ );
233
+ };
@@ -0,0 +1,26 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { SessionRateLimited } from "./SessionRateLimited";
3
+
4
+ /**
5
+ * What a 429 on the session lookup looks like (#441). Before this state
6
+ * existed the gate fell through to the sign-in screen, so a throttled instance
7
+ * told everyone behind the address that they had been logged out.
8
+ */
9
+ const meta: Meta<typeof SessionRateLimited> = {
10
+ title: "Screens/SessionRateLimited",
11
+ component: SessionRateLimited,
12
+ parameters: { layout: "fullscreen" },
13
+ args: {
14
+ onRetry: () => undefined,
15
+ onReport: () => undefined,
16
+ },
17
+ };
18
+ export default meta;
19
+
20
+ type Story = StoryObj<typeof SessionRateLimited>;
21
+
22
+ export const Default: Story = {};
23
+
24
+ export const Dark: Story = {
25
+ parameters: { theme: "dark" },
26
+ };
@@ -0,0 +1,39 @@
1
+ import { AuthCard, AuthHero, Banner, Button } from "@remit/ui";
2
+
3
+ export interface SessionRateLimitedProps {
4
+ onRetry: () => void;
5
+ onReport: () => void;
6
+ }
7
+
8
+ /**
9
+ * The session could not be checked because the server is throttling this
10
+ * address. Shown instead of the sign-in screen, which would claim a sign-out
11
+ * that never happened (#441). The limits are keyed by IP, so a household, an
12
+ * office, a VPN exit or a handful of open tabs all spend one budget — the copy
13
+ * says so, because otherwise a single user cannot explain what they did wrong.
14
+ */
15
+ export const SessionRateLimited = ({
16
+ onRetry,
17
+ onReport,
18
+ }: SessionRateLimitedProps) => (
19
+ <AuthCard>
20
+ <AuthHero />
21
+ <div className="flex flex-col gap-4 rounded-lg border border-line bg-surface p-7 shadow-lg">
22
+ <Banner tone="warning">
23
+ <p className="font-medium">Too many requests from this address</p>
24
+ <p className="mt-1">
25
+ The server is limiting requests from your network address, so it could
26
+ not check whether you are signed in. You have not been signed out.
27
+ </p>
28
+ </Banner>
29
+ <p className="text-sm text-fg-muted">
30
+ Wait a minute and try again. Everyone behind the same address — other
31
+ people on this network, and every tab you have open — shares one limit.
32
+ </p>
33
+ <Button onClick={onRetry}>Try again</Button>
34
+ <Button variant="ghost" size="sm" onClick={onReport}>
35
+ Report an issue
36
+ </Button>
37
+ </div>
38
+ </AuthCard>
39
+ );
@@ -52,6 +52,17 @@ describe("classifyAuthError", () => {
52
52
  );
53
53
  });
54
54
 
55
+ it("treats a 429 as rate limited, not as a rejected credential (#441)", () => {
56
+ assert.equal(
57
+ classifyAuthError({
58
+ status: 429,
59
+ statusText: "Too Many Requests",
60
+ message: "Too many requests. Please try again later.",
61
+ }),
62
+ "rateLimited",
63
+ );
64
+ });
65
+
55
66
  it("treats a statusless failure as network", () => {
56
67
  assert.equal(classifyAuthError(new Error("Failed to fetch")), "network");
57
68
  });
@@ -82,6 +93,15 @@ describe("authInlineMessage", () => {
82
93
  it("gives a connection hint for a network error", () => {
83
94
  assert.match(authInlineMessage(new Error("boom"), "network"), /offline/i);
84
95
  });
96
+
97
+ it("says the limit is shared by address, over the server's bare wording", () => {
98
+ const message = authInlineMessage(
99
+ { status: 429, message: "Too many requests. Please try again later." },
100
+ "rateLimited",
101
+ );
102
+ assert.match(message, /this address/i);
103
+ assert.match(message, /wait a minute/i);
104
+ });
85
105
  });
86
106
 
87
107
  describe("buildFatalAuthMessage", () => {
@@ -9,12 +9,16 @@ import { reportFatalError } from "@/lib/fatal-error";
9
9
  * - "validation" — an expected, user-correctable rejection the server
10
10
  * described (email already registered, weak password, wrong
11
11
  * credentials). Inline, surfacing the server's own message.
12
+ * - "rateLimited" — a 429. Nothing is wrong with the credentials or the
13
+ * deployment; the address has spent its budget and the same
14
+ * attempt works after a wait. Inline, and named as its own
15
+ * thing so it is never read as a rejection (#441).
12
16
  * - "fatal" — a 404, or any other unexpected 4xx/5xx from our own API. The
13
17
  * deployment or the client/server contract is broken and there
14
18
  * is nothing the user can retry their way out of. Escalates to
15
19
  * the full-screen fatal page with a bug-report link.
16
20
  */
17
- export type AuthErrorClass = "network" | "validation" | "fatal";
21
+ export type AuthErrorClass = "network" | "validation" | "rateLimited" | "fatal";
18
22
 
19
23
  /** The request being attempted, for a fatal report that names method and path. */
20
24
  export interface AuthRequest {
@@ -25,6 +29,8 @@ export interface AuthRequest {
25
29
  /** better-auth's client sentinel for a fetch that never got an HTTP response. */
26
30
  const FETCH_ERROR_STATUS_TEXT = "Fetch Error";
27
31
 
32
+ const TOO_MANY_REQUESTS = 429;
33
+
28
34
  const readString = (error: unknown, key: string): string | undefined => {
29
35
  if (error && typeof error === "object" && key in error) {
30
36
  const value = (error as Record<string, unknown>)[key];
@@ -53,6 +59,7 @@ export const classifyAuthError = (error: unknown): AuthErrorClass => {
53
59
  if (isNetworkFailure(error)) return "network";
54
60
  const status = getErrorStatus(error);
55
61
  if (status === undefined) return "fatal";
62
+ if (status === TOO_MANY_REQUESTS) return "rateLimited";
56
63
  if (status === 404 || status >= 500) return "fatal";
57
64
  if (authServerMessage(error)) return "validation";
58
65
  return "fatal";
@@ -61,12 +68,18 @@ export const classifyAuthError = (error: unknown): AuthErrorClass => {
61
68
  const NETWORK_MESSAGE =
62
69
  "Can't reach the server. You may be offline — check your connection and try again.";
63
70
 
64
- /** The inline banner text for a network or validation failure. */
71
+ // The server's own 429 body says only "Too many requests"; it cannot say what
72
+ // the budget is shared with, and that is the part a single user needs to hear.
73
+ const RATE_LIMITED_MESSAGE =
74
+ "Too many requests from this address. Everyone behind the same address — other people on this network, and every tab you have open — shares one limit. Wait a minute and try again.";
75
+
76
+ /** The inline banner text for a network, rate-limited or validation failure. */
65
77
  export const authInlineMessage = (
66
78
  error: unknown,
67
79
  kind: AuthErrorClass,
68
80
  ): string => {
69
81
  if (kind === "network") return NETWORK_MESSAGE;
82
+ if (kind === "rateLimited") return RATE_LIMITED_MESSAGE;
70
83
  return authServerMessage(error) ?? "Please check your details and try again.";
71
84
  };
72
85
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * #441: a 429 on the session lookup put the sign-in screen up, so a throttled
3
+ * instance told everyone behind the address they had been logged out. This
4
+ * renders the gate the app actually mounts, one state per case.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { SessionGate } from "./BetterAuthShell";
12
+ import type { SessionQuery } from "./session-state";
13
+
14
+ const APP = "the-signed-in-app";
15
+
16
+ const render = (session: SessionQuery): string =>
17
+ renderToString(
18
+ createElement(SessionGate, {
19
+ session,
20
+ onRetry: () => undefined,
21
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
22
+ children: APP,
23
+ }) as never,
24
+ );
25
+
26
+ const tooManyRequests = {
27
+ status: 429,
28
+ statusText: "Too Many Requests",
29
+ error: { message: "Too many requests. Please try again later." },
30
+ };
31
+
32
+ describe("the session gate", () => {
33
+ it("shows the app when a session is held", () => {
34
+ const html = render({
35
+ data: { user: { id: "u1" } },
36
+ isPending: false,
37
+ error: null,
38
+ });
39
+ assert.match(html, new RegExp(APP));
40
+ });
41
+
42
+ it("shows the sign-in screen when the lookup answered without a session", () => {
43
+ const html = render({ data: null, isPending: false, error: null });
44
+ assert.match(html, /Sign in/);
45
+ assert.doesNotMatch(html, /Too many requests/i);
46
+ });
47
+
48
+ it("names the rate limit instead of claiming a sign-out", () => {
49
+ const html = render({
50
+ data: null,
51
+ isPending: false,
52
+ error: tooManyRequests,
53
+ });
54
+ assert.match(html, /Too many requests from this address/i);
55
+ assert.match(html, /have not been signed out/i);
56
+ assert.doesNotMatch(html, /Create account/);
57
+ });
58
+
59
+ it("offers a way to retry and a way to report", () => {
60
+ const html = render({
61
+ data: null,
62
+ isPending: false,
63
+ error: tooManyRequests,
64
+ });
65
+ assert.match(html, /Try again/);
66
+ assert.match(html, /Report an issue/);
67
+ });
68
+
69
+ it("announces the refusal rather than leaving it to colour", () => {
70
+ const html = render({
71
+ data: null,
72
+ isPending: false,
73
+ error: tooManyRequests,
74
+ });
75
+ assert.match(html, /role="alert"/);
76
+ });
77
+ });
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { classifySessionQuery } from "./session-state";
4
+
5
+ const rateLimited = {
6
+ status: 429,
7
+ statusText: "Too Many Requests",
8
+ error: { message: "Too many requests. Please try again later." },
9
+ };
10
+
11
+ describe("classifySessionQuery", () => {
12
+ it("is pending only while nothing has come back", () => {
13
+ assert.deepEqual(
14
+ classifySessionQuery({ data: null, isPending: true, error: null }),
15
+ { kind: "pending" },
16
+ );
17
+ });
18
+
19
+ it("is active whenever a session is held", () => {
20
+ assert.deepEqual(
21
+ classifySessionQuery({
22
+ data: { user: { id: "u1" } },
23
+ isPending: false,
24
+ error: null,
25
+ }),
26
+ { kind: "active" },
27
+ );
28
+ });
29
+
30
+ it("keeps a held session through a throttled refetch", () => {
31
+ assert.deepEqual(
32
+ classifySessionQuery({
33
+ data: { user: { id: "u1" } },
34
+ isPending: false,
35
+ error: rateLimited,
36
+ }),
37
+ { kind: "active" },
38
+ );
39
+ });
40
+
41
+ it("reads a 429 as rate limited, never as signed out (#441)", () => {
42
+ assert.deepEqual(
43
+ classifySessionQuery({
44
+ data: null,
45
+ isPending: false,
46
+ error: rateLimited,
47
+ }),
48
+ { kind: "rateLimited" },
49
+ );
50
+ });
51
+
52
+ it("is signed out only when the lookup answered without a session", () => {
53
+ assert.deepEqual(
54
+ classifySessionQuery({ data: null, isPending: false, error: null }),
55
+ { kind: "signedOut" },
56
+ );
57
+ assert.deepEqual(
58
+ classifySessionQuery({
59
+ data: null,
60
+ isPending: false,
61
+ error: { status: 401, statusText: "Unauthorized" },
62
+ }),
63
+ { kind: "signedOut" },
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,41 @@
1
+ import { getErrorStatus } from "@/lib/error-classifier";
2
+
3
+ /**
4
+ * What the session query is actually telling the gate.
5
+ *
6
+ * "signedOut" is the only state that may put the sign-in screen up. A session
7
+ * lookup that failed for any other reason has not established that the user is
8
+ * signed out, and rendering the sign-in screen for it tells them they were
9
+ * logged out when they were not (#441).
10
+ */
11
+ export type SessionState =
12
+ | { kind: "pending" }
13
+ | { kind: "active" }
14
+ | { kind: "rateLimited" }
15
+ | { kind: "signedOut" };
16
+
17
+ /** The shape better-auth's `useSession` hands back, read structurally. */
18
+ export interface SessionQuery {
19
+ data: unknown;
20
+ isPending: boolean;
21
+ error: unknown;
22
+ }
23
+
24
+ const TOO_MANY_REQUESTS = 429;
25
+
26
+ /**
27
+ * A held session outranks everything: better-auth keeps `data` across a failed
28
+ * refetch unless the failure was a 401, so a throttled refresh must not evict a
29
+ * signed-in user.
30
+ */
31
+ export const classifySessionQuery = ({
32
+ data,
33
+ isPending,
34
+ error,
35
+ }: SessionQuery): SessionState => {
36
+ if (data) return { kind: "active" };
37
+ if (isPending) return { kind: "pending" };
38
+ if (getErrorStatus(error) === TOO_MANY_REQUESTS)
39
+ return { kind: "rateLimited" };
40
+ return { kind: "signedOut" };
41
+ };
@@ -27,6 +27,7 @@ export const makeMailbox = (
27
27
  lastSyncUid: 0,
28
28
  highWaterMarkUid: 0,
29
29
  lastMessageSyncAt: 0,
30
+ syncStatus: "synced",
30
31
  createdAt: 0,
31
32
  updatedAt: 0,
32
33
  ...overrides,