@gusnips/react 0.9.24 → 0.9.26
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/README.md +173 -36
- package/dist/api-client.d.ts +51 -51
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +3 -3
- package/dist/api-error.d.ts +11 -11
- package/dist/api-error.js +11 -11
- package/dist/auth-email.d.ts +40 -0
- package/dist/auth-email.d.ts.map +1 -0
- package/dist/auth-email.js +106 -0
- package/dist/auth-email.js.map +1 -0
- package/dist/auth-store.d.ts +23 -23
- package/dist/auth-store.js +13 -13
- package/dist/commands.d.ts +80 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +192 -0
- package/dist/commands.js.map +1 -0
- package/dist/copy.d.ts +3 -3
- package/dist/copy.js +3 -3
- package/dist/deploy-recovery.d.ts +40 -40
- package/dist/deploy-recovery.js +40 -40
- package/dist/describe-error.d.ts +68 -68
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +5 -5
- package/dist/error-boundary.d.ts +12 -12
- package/dist/guards.d.ts +15 -15
- package/dist/guards.js +3 -3
- package/dist/hydrate.d.ts +15 -16
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +9 -9
- package/dist/hydrate.js.map +1 -1
- package/dist/i18n.d.ts +15 -16
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +5 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal-path.d.ts +8 -7
- package/dist/internal-path.d.ts.map +1 -1
- package/dist/internal-path.js +8 -7
- package/dist/internal-path.js.map +1 -1
- package/dist/prerender-contract.d.ts +7 -7
- package/dist/prerender-contract.js +7 -7
- package/dist/query.d.ts +16 -17
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +2 -2
- package/dist/query.js.map +1 -1
- package/dist/sse.d.ts +19 -20
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +18 -19
- package/dist/sse.js.map +1 -1
- package/dist/states.d.ts +12 -12
- package/dist/states.d.ts.map +1 -1
- package/dist/supabase-session.d.ts +47 -48
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +35 -35
- package/dist/supabase-session.js.map +1 -1
- package/dist/theme.d.ts +4 -4
- package/dist/theme.js +4 -4
- package/dist/ui/class-name.d.ts +3 -4
- package/dist/ui/class-name.d.ts.map +1 -1
- package/dist/ui/class-name.js +3 -4
- package/dist/ui/class-name.js.map +1 -1
- package/dist/ui/combobox.d.ts +11 -11
- package/dist/ui/combobox.d.ts.map +1 -1
- package/dist/ui/dialog.d.ts +5 -5
- package/dist/ui/dialog.js +2 -2
- package/dist/ui/drawer.d.ts +10 -10
- package/dist/ui/drawer.d.ts.map +1 -1
- package/dist/ui/drawer.js +2 -2
- package/dist/ui/input.d.ts +2 -2
- package/dist/ui/input.js +2 -2
- package/dist/ui/menu.d.ts +9 -9
- package/dist/ui/menu.d.ts.map +1 -1
- package/dist/ui/menu.js +4 -4
- package/dist/ui/menu.js.map +1 -1
- package/dist/ui/select.d.ts +12 -12
- package/dist/ui/select.d.ts.map +1 -1
- package/dist/ui/select.js +3 -3
- package/dist/ui/tabs.d.ts +14 -15
- package/dist/ui/tabs.d.ts.map +1 -1
- package/dist/ui/tabs.js +13 -14
- package/dist/ui/tabs.js.map +1 -1
- package/package.json +5 -1
- package/src/api-client.ts +51 -51
- package/src/api-error.ts +11 -11
- package/src/auth-email.test.ts +145 -0
- package/src/auth-email.ts +112 -0
- package/src/auth-store.ts +23 -23
- package/src/commands-hook.test.ts +444 -0
- package/src/commands.test.ts +245 -0
- package/src/commands.ts +268 -0
- package/src/copy.ts +3 -3
- package/src/deploy-recovery.ts +40 -40
- package/src/describe-error.ts +68 -68
- package/src/error-boundary.tsx +12 -12
- package/src/guards.tsx +15 -15
- package/src/hydrate.ts +15 -16
- package/src/i18n.ts +15 -16
- package/src/index.ts +2 -0
- package/src/internal-path.ts +8 -7
- package/src/prerender-contract.ts +7 -7
- package/src/query.ts +16 -17
- package/src/sse.ts +19 -20
- package/src/states.ts +12 -12
- package/src/supabase-session.ts +46 -47
- package/src/theme.ts +4 -4
- package/src/ui/class-name.ts +3 -4
- package/src/ui/combobox.tsx +11 -11
- package/src/ui/dialog.tsx +5 -5
- package/src/ui/drawer.tsx +10 -10
- package/src/ui/input.tsx +2 -2
- package/src/ui/menu.tsx +8 -8
- package/src/ui/select.tsx +12 -12
- package/src/ui/tabs.tsx +14 -15
package/dist/ui/tabs.d.ts
CHANGED
|
@@ -9,19 +9,18 @@ type TabListLabel = {
|
|
|
9
9
|
"aria-labelledby": string;
|
|
10
10
|
};
|
|
11
11
|
export type TabListProps = Omit<Primitive.List.Props, "aria-labelledby"> & TabListLabel & {
|
|
12
|
-
/** Classes for the
|
|
12
|
+
/** Classes for the wrapper that owns the sideways scroll. */
|
|
13
13
|
wrapperClassName?: string;
|
|
14
14
|
};
|
|
15
15
|
/**
|
|
16
16
|
* The rail, and the reason this file exists.
|
|
17
17
|
*
|
|
18
|
-
* It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* first tab and the right of the last.
|
|
18
|
+
* It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel below the
|
|
19
|
+
* fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto` makes the
|
|
20
|
+
* element a scroll container, a scroll container clips everything its children paint outside the
|
|
21
|
+
* padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from `visible` to `auto`
|
|
22
|
+
* as well. A self-scrolling rail therefore crops the focus ring on all FOUR sides of whichever tab
|
|
23
|
+
* the keyboard is on: top and bottom always, the left of the first tab and the right of the last.
|
|
25
24
|
*
|
|
26
25
|
* So the scroll goes on a wrapper, and the wrapper's padding is the room the ring needs. The
|
|
27
26
|
* number is derived, not inherited: `@gusnips/tokens` draws `outline: 2px solid` at
|
|
@@ -35,17 +34,17 @@ export type TabListProps = Omit<Primitive.List.Props, "aria-labelledby"> & TabLi
|
|
|
35
34
|
export declare function TabList({ label, className, wrapperClassName, children, ...rest }: TabListProps): import("react").JSX.Element;
|
|
36
35
|
export declare function Tab({ className, ...rest }: Primitive.Tab.Props): import("react").JSX.Element;
|
|
37
36
|
/**
|
|
38
|
-
* The travelling marker under the active tab
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* The travelling marker under the active tab. A SEPARATE part, because the two donors disagreed
|
|
38
|
+
* about whether to have one at all and both were right about their own app. Render it or do not;
|
|
39
|
+
* the rail works either way.
|
|
41
40
|
*
|
|
42
41
|
* It must be a child of `TabList`: Base UI measures the active tab against the enclosing
|
|
43
42
|
* `[role="tablist"]` and writes `--active-tab-left` / `--active-tab-width` onto this element.
|
|
44
43
|
*
|
|
45
|
-
* `renderBeforeHydration` is on by default because this package's apps are prerendered
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
44
|
+
* `renderBeforeHydration` is on by default because this package's apps are prerendered. With it
|
|
45
|
+
* off, every prerendered page shows the rail with no marker until React arrives. It writes one
|
|
46
|
+
* inline script to do that; under a strict CSP without Base UI's `CspProvider` the script is
|
|
47
|
+
* blocked and the behaviour falls back to exactly what `false` would have done.
|
|
49
48
|
*/
|
|
50
49
|
export declare function TabIndicator({ className, ...rest }: Primitive.Indicator.Props): import("react").JSX.Element;
|
|
51
50
|
export {};
|
package/dist/ui/tabs.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAOxD,eAAO,MAAM,IAAI,gJAAiB,CAAC;AACnC,eAAO,MAAM,QAAQ,iJAAkB,CAAC;AAExC,KAAK,YAAY,GACb;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,SAAS,CAAA;CAAE,GAChD;IAAE,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,EAAE,iBAAiB,CAAC,GACtE,YAAY,GAAG;IACb,
|
|
1
|
+
{"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAOxD,eAAO,MAAM,IAAI,gJAAiB,CAAC;AACnC,eAAO,MAAM,QAAQ,iJAAkB,CAAC;AAExC,KAAK,YAAY,GACb;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,SAAS,CAAA;CAAE,GAChD;IAAE,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,EAAE,iBAAiB,CAAC,GACtE,YAAY,GAAG;IACb,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEJ;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,YAAY,+BAa9F;AAYD,wBAAgB,GAAG,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,GAAG,CAAC,KAAK,+BAE9D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,SAAS,CAAC,KAAK,+BAY7E"}
|
package/dist/ui/tabs.js
CHANGED
|
@@ -10,13 +10,12 @@ export const TabPanel = Primitive.Panel;
|
|
|
10
10
|
/**
|
|
11
11
|
* The rail, and the reason this file exists.
|
|
12
12
|
*
|
|
13
|
-
* It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* first tab and the right of the last.
|
|
13
|
+
* It scrolls sideways rather than wrapping, because a second row of tabs pushes the panel below the
|
|
14
|
+
* fold on a phone. But the scroll cannot live on the rail itself: `overflow-x: auto` makes the
|
|
15
|
+
* element a scroll container, a scroll container clips everything its children paint outside the
|
|
16
|
+
* padding box, and a computed `overflow-x` of `auto` forces `overflow-y` from `visible` to `auto`
|
|
17
|
+
* as well. A self-scrolling rail therefore crops the focus ring on all FOUR sides of whichever tab
|
|
18
|
+
* the keyboard is on: top and bottom always, the left of the first tab and the right of the last.
|
|
20
19
|
*
|
|
21
20
|
* So the scroll goes on a wrapper, and the wrapper's padding is the room the ring needs. The
|
|
22
21
|
* number is derived, not inherited: `@gusnips/tokens` draws `outline: 2px solid` at
|
|
@@ -44,17 +43,17 @@ export function Tab({ className, ...rest }) {
|
|
|
44
43
|
return _jsx(Primitive.Tab, { ...rest, className: mergeClassName(tabClasses, className) });
|
|
45
44
|
}
|
|
46
45
|
/**
|
|
47
|
-
* The travelling marker under the active tab
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* The travelling marker under the active tab. A SEPARATE part, because the two donors disagreed
|
|
47
|
+
* about whether to have one at all and both were right about their own app. Render it or do not;
|
|
48
|
+
* the rail works either way.
|
|
50
49
|
*
|
|
51
50
|
* It must be a child of `TabList`: Base UI measures the active tab against the enclosing
|
|
52
51
|
* `[role="tablist"]` and writes `--active-tab-left` / `--active-tab-width` onto this element.
|
|
53
52
|
*
|
|
54
|
-
* `renderBeforeHydration` is on by default because this package's apps are prerendered
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* `renderBeforeHydration` is on by default because this package's apps are prerendered. With it
|
|
54
|
+
* off, every prerendered page shows the rail with no marker until React arrives. It writes one
|
|
55
|
+
* inline script to do that; under a strict CSP without Base UI's `CspProvider` the script is
|
|
56
|
+
* blocked and the behaviour falls back to exactly what `false` would have done.
|
|
58
57
|
*/
|
|
59
58
|
export function TabIndicator({ className, ...rest }) {
|
|
60
59
|
return (_jsx(Primitive.Indicator, { "aria-hidden": "true", renderBeforeHydration: true, ...rest, className: mergeClassName("absolute bottom-0 left-0 h-0.5 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] bg-foreground transition-[translate,width]", className) }));
|
package/dist/ui/tabs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tabs.js","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAC9B,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,6FAA6F;AAC7F,uFAAuF;AACvF,oCAAoC;AACpC,MAAM,CAAC,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC;AACnC,MAAM,CAAC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC;AAYxC
|
|
1
|
+
{"version":3,"file":"tabs.js","sourceRoot":"","sources":["../../src/ui/tabs.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAC9B,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,6FAA6F;AAC7F,uFAAuF;AACvF,oCAAoC;AACpC,MAAM,CAAC,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC;AACnC,MAAM,CAAC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC;AAYxC;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAgB;IAC7F,OAAO,CACL,cAAK,SAAS,EAAE,EAAE,CAAC,sCAAsC,EAAE,gBAAgB,CAAC,YAC1E,KAAC,SAAS,CAAC,IAAI,kBACD,KAAK,KACb,IAAI;YACR,sEAAsE;YACtE,SAAS,EAAE,cAAc,CAAC,qBAAqB,EAAE,SAAS,CAAC,YAE1D,QAAQ,GACM,GACb,CACP,CAAC;AACJ,CAAC;AAED,6FAA6F;AAC7F,uFAAuF;AACvF,2DAA2D;AAC3D,EAAE;AACF,4FAA4F;AAC5F,6FAA6F;AAC7F,uBAAuB;AACvB,MAAM,UAAU,GACd,yJAAyJ,CAAC;AAE5J,MAAM,UAAU,GAAG,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAuB;IAC7D,OAAO,KAAC,SAAS,CAAC,GAAG,OAAK,IAAI,EAAE,SAAS,EAAE,cAAc,CAAC,UAAU,EAAE,SAAS,CAAC,GAAI,CAAC;AACvF,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAA6B;IAC5E,OAAO,CACL,KAAC,SAAS,CAAC,SAAS,mBACN,MAAM,EAClB,qBAAqB,WACjB,IAAI,EACR,SAAS,EAAE,cAAc,CACvB,4IAA4I,EAC5I,SAAS,CACV,GACD,CACH,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.26",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
"./theme": {
|
|
42
42
|
"types": "./dist/theme.d.ts",
|
|
43
43
|
"default": "./dist/theme.js"
|
|
44
|
+
},
|
|
45
|
+
"./commands": {
|
|
46
|
+
"types": "./dist/commands.d.ts",
|
|
47
|
+
"default": "./dist/commands.js"
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"types": "./dist/index.d.ts",
|
package/src/api-client.ts
CHANGED
|
@@ -5,10 +5,10 @@ import { ApiError } from "./api-error.ts";
|
|
|
5
5
|
/**
|
|
6
6
|
* The app's one door to the API. Nothing else should call `fetch`.
|
|
7
7
|
*
|
|
8
|
-
* Every route answers the same envelope, so unwrapping and error-shaping belong here once
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Every route answers the same envelope, so unwrapping and error-shaping belong here once rather
|
|
9
|
+
* than in every hook. Three donors built this independently and all three arrived at single-flight
|
|
10
|
+
* token refresh. See {@link SessionAdapter.refresh} for the reason, which is the sharpest thing in
|
|
11
|
+
* this file.
|
|
12
12
|
*
|
|
13
13
|
* Four things deliberately did NOT come across from the donors, because they belong to a
|
|
14
14
|
* product and not to a client:
|
|
@@ -16,7 +16,7 @@ import { ApiError } from "./api-error.ts";
|
|
|
16
16
|
* - **Toasts.** Whether a failure is spoken aloud is a design decision that differs per surface.
|
|
17
17
|
* Throw, and let the caller decide.
|
|
18
18
|
* - **In-flight GET deduplication.** react-query already dedups by key, so the client's copy is
|
|
19
|
-
* redundant
|
|
19
|
+
* redundant. One donor's version also returned a `clone()` of a body already being consumed,
|
|
20
20
|
* which is a race with no symptom until it has one.
|
|
21
21
|
* - **Service-status tracking and impersonation.** One donor's, and firmly its own.
|
|
22
22
|
* - **Analytics correlation headers.** Injected through {@link ApiClientOptions.headers}.
|
|
@@ -26,8 +26,8 @@ import { ApiError } from "./api-error.ts";
|
|
|
26
26
|
export interface RefreshResult {
|
|
27
27
|
token: string | null;
|
|
28
28
|
/**
|
|
29
|
-
* False when the refresh never got an ANSWER out of the auth server
|
|
30
|
-
* a 5xx
|
|
29
|
+
* False when the refresh never got an ANSWER out of the auth server because of a dropped
|
|
30
|
+
* connection, a 5xx or a captive portal.
|
|
31
31
|
*
|
|
32
32
|
* This flag is invariant 3, and only one of three donors had it. Losing a packet says nothing
|
|
33
33
|
* about whether a session is still good, so a client that collapses "auth said no" into
|
|
@@ -39,8 +39,8 @@ export interface RefreshResult {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* The seam onto whatever holds the session. Supabase in every donor so far, but nothing here
|
|
43
|
-
*
|
|
42
|
+
* The seam onto whatever holds the session. Supabase in every donor so far, but nothing here knows
|
|
43
|
+
* that. An adapter is four lines and keeps the auth vendor out of this package.
|
|
44
44
|
*/
|
|
45
45
|
export interface SessionAdapter {
|
|
46
46
|
getToken(): Promise<string | null>;
|
|
@@ -49,9 +49,9 @@ export interface SessionAdapter {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* How long anything here waits on a sign-out: the default for
|
|
53
|
-
*
|
|
54
|
-
* `@gusnips/react/supabase`. Shared so the two give up together
|
|
52
|
+
* How long anything here waits on a sign-out: the default for {@link
|
|
53
|
+
* ApiClientOptions.signOutTimeoutMs}, and the deadline of `signOutEvenOffline` in
|
|
54
|
+
* `@gusnips/react/supabase`. Shared so the two give up together. A redirect that gave up sooner
|
|
55
55
|
* would navigate away while the session was still in storage.
|
|
56
56
|
*/
|
|
57
57
|
export const SIGN_OUT_TIMEOUT_MS = 3_000;
|
|
@@ -70,54 +70,54 @@ export interface ApiClientOptions {
|
|
|
70
70
|
*/
|
|
71
71
|
headers?: () => Record<string, string>;
|
|
72
72
|
/**
|
|
73
|
-
* The session is gone and cannot be renewed
|
|
73
|
+
* The session is gone and cannot be renewed. Send them to sign in.
|
|
74
74
|
*
|
|
75
75
|
* The client does not know the route, and it deliberately still THROWS after calling this:
|
|
76
76
|
* `location.replace()` does not stop the current frame, so a caller's own `onError` must
|
|
77
77
|
* still run or a half-finished screen keeps rendering against data that will never arrive.
|
|
78
78
|
* The error it throws carries `expected: true`.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* Routing to the sign-in screen without a reload is equally supported. It is the case that keeps
|
|
81
|
+
* this client alive across the next sign-in. The sign-out latch is keyed on the refused token for
|
|
82
|
+
* exactly that reason; see `refusedToken`.
|
|
83
83
|
*/
|
|
84
84
|
onSessionDead: () => void;
|
|
85
85
|
/**
|
|
86
86
|
* Every failed response, seen once, just before it is thrown.
|
|
87
87
|
*
|
|
88
|
-
* For a reaction that belongs to the whole app rather than to one call site. The donor's case
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
88
|
+
* For a reaction that belongs to the whole app rather than to one call site. The donor's case is
|
|
89
|
+
* the sharp one: a mid-session account suspension 403s every authed route except `GET /auth/me`,
|
|
90
|
+
* so the moment one arrives the app has to refresh `me` and route to the screen that explains it.
|
|
91
|
+
* Otherwise every query on the page fails at once and the shell half-renders behind an error
|
|
92
|
+
* storm until `me` goes stale on its own.
|
|
93
93
|
*
|
|
94
|
-
* It cannot live at a call site, because the point is that it fires from whichever call
|
|
95
|
-
*
|
|
96
|
-
*
|
|
94
|
+
* It cannot live at a call site, because the point is that it fires from whichever call happened
|
|
95
|
+
* to be first. It went in the donor's client directly, which made the client import its query
|
|
96
|
+
* cache and its query keys, creating a cycle that this hook removes.
|
|
97
97
|
*
|
|
98
98
|
* Observation only: the error is thrown either way, and throwing from here would replace a
|
|
99
99
|
* real API failure with whatever the listener hit.
|
|
100
100
|
*
|
|
101
101
|
* **The response comes with it, because a refusal's headers are otherwise unreachable.**
|
|
102
|
-
* `request` throws before the `Response` escapes, so an adopter whose API states something on
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
102
|
+
* `request` throws before the `Response` escapes, so an adopter whose API states something on the
|
|
103
|
+
* error path had no way to read it at all. One reports what every call cost, refusals included,
|
|
104
|
+
* which is how "a blocked page costs nothing" becomes a thing a customer can check rather than a
|
|
105
|
+
* thing we assert. That is the distinction worth keeping: field-error flattening and a per-call
|
|
106
|
+
* meter callback both stayed in their products, because a product can write those itself. This
|
|
107
|
+
* one it cannot, at any price, and a capability the kit makes unreachable is a regression the kit
|
|
108
|
+
* has to undo.
|
|
109
109
|
*
|
|
110
|
-
* The BODY has already been read by the time this runs
|
|
111
|
-
*
|
|
110
|
+
* The BODY has already been read by the time this runs to build the error from the envelope, so
|
|
111
|
+
* `response.json()` here throws. The headers are what is left, and what this is for.
|
|
112
112
|
*/
|
|
113
113
|
onError?: (error: ApiError, response: Response) => void;
|
|
114
114
|
/**
|
|
115
115
|
* Abort a request that has not answered. Default 30s.
|
|
116
116
|
*
|
|
117
|
-
* Neither donor bounded its authenticated requests
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
117
|
+
* Neither donor bounded its authenticated requests. Both bounded only their keyless clients, so a
|
|
118
|
+
* stalled connection was a spinner with no end. providerkit learned the same lesson about
|
|
119
|
+
* streams: a request with no deadline is a bug that only shows up on a bad network, which is
|
|
120
|
+
* exactly when nobody can reproduce it.
|
|
121
121
|
*/
|
|
122
122
|
timeoutMs?: number;
|
|
123
123
|
/** How many times to refresh-and-retry a 401. Default 2. */
|
|
@@ -127,9 +127,9 @@ export interface ApiClientOptions {
|
|
|
127
127
|
/**
|
|
128
128
|
* How long to wait for `signOut()` before redirecting anyway. Default 3s.
|
|
129
129
|
*
|
|
130
|
-
* A fail-safe, and one donor added it after the failure it prevents: awaiting `signOut()`
|
|
131
|
-
*
|
|
132
|
-
*
|
|
130
|
+
* A fail-safe, and one donor added it after the failure it prevents: awaiting `signOut()` covers
|
|
131
|
+
* a rejection but not a HANG, and a hang leaves the tab signed out in name only: still on the
|
|
132
|
+
* page, every request 401ing, nothing left that could redirect it.
|
|
133
133
|
*/
|
|
134
134
|
signOutTimeoutMs?: number;
|
|
135
135
|
/** Header carrying the server's request id, echoed onto {@link ApiError.requestId}. */
|
|
@@ -151,9 +151,9 @@ export interface ApiClient {
|
|
|
151
151
|
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
|
|
152
152
|
patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
|
|
153
153
|
/**
|
|
154
|
-
* DELETE, discarding whatever comes back. Returns `void` because 204 is the usual answer and
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
* DELETE, discarding whatever comes back. Returns `void` because 204 is the usual answer and a
|
|
155
|
+
* 204 has NO BODY. `res.json()` on one throws `Unexpected end of JSON input`, which reads like a
|
|
156
|
+
* malformed response and is really just a success nobody was allowed to parse.
|
|
157
157
|
*
|
|
158
158
|
* Split from {@link ApiClient.delJson} rather than guessing per response, because the guess is
|
|
159
159
|
* the part that hides a bug: a route that was supposed to answer with data and returned
|
|
@@ -161,21 +161,21 @@ export interface ApiClient {
|
|
|
161
161
|
* layers away. The donor that hit this had written exactly these two functions.
|
|
162
162
|
*/
|
|
163
163
|
del(path: string, options?: RequestOptions): Promise<void>;
|
|
164
|
-
/** The DELETE that answers with something worth reading
|
|
164
|
+
/** The DELETE that answers with something worth reading, such as a queue entry it handed back. */
|
|
165
165
|
delJson<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
166
166
|
/**
|
|
167
|
-
* The whole envelope rather than just `data`, for a route whose `meta` is half the answer
|
|
168
|
-
* a list's `total
|
|
167
|
+
* The whole envelope rather than just `data`, for a route whose `meta` is half the answer, such
|
|
168
|
+
* as a list's `total` or a metered call's receipt.
|
|
169
169
|
*/
|
|
170
170
|
page<T, M>(path: string, options?: RequestOptions): Promise<ApiSuccess<T, M>>;
|
|
171
171
|
/**
|
|
172
172
|
* The same, off a POST.
|
|
173
173
|
*
|
|
174
|
-
* Two adopters needed this and both wrote the same workaround: `client.request(…)` followed by
|
|
175
|
-
*
|
|
176
|
-
* assertion about the success shape. One of them left the reason sitting in a comment
|
|
177
|
-
*
|
|
178
|
-
*
|
|
174
|
+
* Two adopters needed this and both wrote the same workaround: `client.request(…)` followed by a
|
|
175
|
+
* hand-rolled `res.json()`, stepping around the client's own envelope reader and its one
|
|
176
|
+
* assertion about the success shape. One of them left the reason sitting in a comment: "the whole
|
|
177
|
+
* envelope is reachable off a GET and not off a POST, so this one reads the body itself". This
|
|
178
|
+
* package's gap was described in somebody else's file.
|
|
179
179
|
*
|
|
180
180
|
* A metered route takes a body whenever its input is too long or too punctuated to be a path
|
|
181
181
|
* segment (a URL, a search query), and then the receipt has to survive the POST too. When the
|
package/src/api-error.ts
CHANGED
|
@@ -3,9 +3,9 @@ import type { ApiErrorBody } from "@gusnips/http";
|
|
|
3
3
|
/**
|
|
4
4
|
* A refusal, with the whole envelope intact.
|
|
5
5
|
*
|
|
6
|
-
* `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries
|
|
7
|
-
*
|
|
8
|
-
* Keeping all of it means a caller never has to re-parse a response body that has already been
|
|
6
|
+
* `code` is what the UI switches on, `messageKey` is what gets localized, and `details` carries the
|
|
7
|
+
* parts that make a refusal actionable, such as the `resetAt` on a 429 or the plan that lifts a
|
|
8
|
+
* 402. Keeping all of it means a caller never has to re-parse a response body that has already been
|
|
9
9
|
* read once.
|
|
10
10
|
*
|
|
11
11
|
* A real `class` and not an interface with a factory: one donor used the interface shape, which
|
|
@@ -23,17 +23,17 @@ export class ApiError extends Error {
|
|
|
23
23
|
*
|
|
24
24
|
* Read from the `Retry-After` HEADER, which is where HTTP puts it and where a rate limiter
|
|
25
25
|
* written by anyone puts it. The second migration is why this exists: that API sends
|
|
26
|
-
* `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader looked in
|
|
27
|
+
* `details.retryAfterSecs`, the first donor's convention, and found nothing, and both the copy
|
|
28
|
+
* and the retry rule were flying blind on the one refusal that states its own expiry. Two
|
|
29
|
+
* conventions, same fact; `retryAfterSecs` from `@gusnips/http/retry` reads either.
|
|
30
30
|
*/
|
|
31
31
|
readonly retryAfterSecs: number | undefined;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* The client raised this itself to stop a caller while it was already handling the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* The client raised this itself to stop a caller while it was already handling the situation.
|
|
35
|
+
* Today this is the 401 thrown after a dead session, where the person is already on their way to
|
|
36
|
+
* the sign-in page.
|
|
37
37
|
*
|
|
38
38
|
* It is control flow, not a fault. Exception reporting should skip it, and an error surface
|
|
39
39
|
* should not ask somebody to retry something that is not broken. One donor had this and it is
|
|
@@ -65,7 +65,7 @@ export class ApiError extends Error {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
68
|
+
* A request aborted by the caller's own `AbortController` or a navigation that unmounted the
|
|
69
69
|
* component waiting on it.
|
|
70
70
|
*
|
|
71
71
|
* Worth its own predicate because it is the one failure that must NOT be reported, retried or
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.ts";
|
|
3
|
+
|
|
4
|
+
const KEY = "frontkit.auth-email";
|
|
5
|
+
const MINUTE = 60 * 1000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `localStorage` stubbed rather than run under a DOM, the same call as `theme.test.ts`. What
|
|
9
|
+
* matters is what a browser does at the edges: site data blocked (every method throws), a quota
|
|
10
|
+
* that refuses new writes while old ones stay readable, and a page with no storage at all.
|
|
11
|
+
*/
|
|
12
|
+
function storage({ blocked = false }: { blocked?: boolean } = {}) {
|
|
13
|
+
const store = new Map<string, string>();
|
|
14
|
+
const state = { blocked, full: false };
|
|
15
|
+
vi.stubGlobal("localStorage", {
|
|
16
|
+
getItem: (key: string) => {
|
|
17
|
+
if (state.blocked) throw new Error("site data blocked");
|
|
18
|
+
return store.get(key) ?? null;
|
|
19
|
+
},
|
|
20
|
+
setItem: (key: string, value: string) => {
|
|
21
|
+
if (state.blocked || state.full) throw new Error("site data blocked");
|
|
22
|
+
store.set(key, value);
|
|
23
|
+
},
|
|
24
|
+
removeItem: (key: string) => {
|
|
25
|
+
if (state.blocked) throw new Error("site data blocked");
|
|
26
|
+
store.delete(key);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return { store, state };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.useFakeTimers();
|
|
34
|
+
vi.setSystemTime(new Date("2026-10-08T10:00:00Z"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
forgetAuthEmail();
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("remember and recall", () => {
|
|
44
|
+
it("starts empty", () => {
|
|
45
|
+
storage();
|
|
46
|
+
expect(recallAuthEmail()).toBe("");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("hands back what was typed, as typed", () => {
|
|
50
|
+
storage();
|
|
51
|
+
rememberAuthEmail(" Ana@Example.com ");
|
|
52
|
+
expect(recallAuthEmail()).toBe(" Ana@Example.com ");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("writes to localStorage, so a new tab opened from a mail finds it", () => {
|
|
56
|
+
const { store } = storage();
|
|
57
|
+
rememberAuthEmail("ana@example.com");
|
|
58
|
+
expect(JSON.parse(store.get(KEY) ?? "null")).toMatchObject({ email: "ana@example.com" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("empties when the field is cleared", () => {
|
|
62
|
+
const { store } = storage();
|
|
63
|
+
rememberAuthEmail("ana@example.com");
|
|
64
|
+
rememberAuthEmail("");
|
|
65
|
+
expect(recallAuthEmail()).toBe("");
|
|
66
|
+
expect(store.has(KEY)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps the last good value when handed something longer than an address can be", () => {
|
|
70
|
+
storage();
|
|
71
|
+
rememberAuthEmail("ana@example.com");
|
|
72
|
+
rememberAuthEmail("a".repeat(255));
|
|
73
|
+
expect(recallAuthEmail()).toBe("ana@example.com");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("forgets on request", () => {
|
|
77
|
+
const { store } = storage();
|
|
78
|
+
rememberAuthEmail("ana@example.com");
|
|
79
|
+
forgetAuthEmail();
|
|
80
|
+
expect(recallAuthEmail()).toBe("");
|
|
81
|
+
expect(store.has(KEY)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("the thirty minutes", () => {
|
|
86
|
+
it("lets go after thirty minutes of nothing, and deletes what it found", () => {
|
|
87
|
+
const { store } = storage();
|
|
88
|
+
rememberAuthEmail("ana@example.com");
|
|
89
|
+
vi.advanceTimersByTime(30 * MINUTE);
|
|
90
|
+
expect(recallAuthEmail()).toBe("ana@example.com");
|
|
91
|
+
vi.advanceTimersByTime(1);
|
|
92
|
+
expect(recallAuthEmail()).toBe("");
|
|
93
|
+
expect(store.has(KEY)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renews on every keystroke", () => {
|
|
97
|
+
storage();
|
|
98
|
+
rememberAuthEmail("ana@example.com");
|
|
99
|
+
vi.advanceTimersByTime(25 * MINUTE);
|
|
100
|
+
rememberAuthEmail("ana@example.com.br");
|
|
101
|
+
vi.advanceTimersByTime(25 * MINUTE);
|
|
102
|
+
expect(recallAuthEmail()).toBe("ana@example.com.br");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("when the browser will not store it", () => {
|
|
107
|
+
it("falls back to memory when site data is blocked", () => {
|
|
108
|
+
storage({ blocked: true });
|
|
109
|
+
rememberAuthEmail("ana@example.com");
|
|
110
|
+
expect(recallAuthEmail()).toBe("ana@example.com");
|
|
111
|
+
forgetAuthEmail();
|
|
112
|
+
expect(recallAuthEmail()).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("falls back to memory when there is no localStorage at all", () => {
|
|
116
|
+
vi.stubGlobal("localStorage", undefined);
|
|
117
|
+
rememberAuthEmail("ana@example.com");
|
|
118
|
+
expect(recallAuthEmail()).toBe("ana@example.com");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("prefers the newer address when a full quota left an older one in storage", () => {
|
|
122
|
+
const { state } = storage();
|
|
123
|
+
rememberAuthEmail("old@example.com");
|
|
124
|
+
state.full = true;
|
|
125
|
+
vi.advanceTimersByTime(MINUTE);
|
|
126
|
+
rememberAuthEmail("new@example.com");
|
|
127
|
+
expect(recallAuthEmail()).toBe("new@example.com");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("prefers what another tab wrote after this tab's memory went stale", () => {
|
|
131
|
+
const { store } = storage();
|
|
132
|
+
rememberAuthEmail("old@example.com");
|
|
133
|
+
vi.advanceTimersByTime(MINUTE);
|
|
134
|
+
store.set(KEY, JSON.stringify({ email: "new@example.com", at: Date.now() }));
|
|
135
|
+
expect(recallAuthEmail()).toBe("new@example.com");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("ignores a stored value that is not ours", () => {
|
|
139
|
+
const { store } = storage();
|
|
140
|
+
store.set(KEY, "{not json");
|
|
141
|
+
expect(recallAuthEmail()).toBe("");
|
|
142
|
+
store.set(KEY, JSON.stringify({ email: 42, at: "now" }));
|
|
143
|
+
expect(recallAuthEmail()).toBe("");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email a person has typed during sign-in, kept so the next auth screen opens with it filled.
|
|
3
|
+
*
|
|
4
|
+
* Sign-in, "use a code instead", "forgot password" and "create account" are separate screens, and
|
|
5
|
+
* each one used to start with an empty email field. Someone who typed their address, then chose
|
|
6
|
+
* another way in, typed it again. The fix is the same everywhere, so it lives here once.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <input name="email" type="email" defaultValue={recallAuthEmail()}
|
|
10
|
+
* onChange={(event) => rememberAuthEmail(event.currentTarget.value)} />
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* - **Not in the URL.** A query string rides in `Referer`, in access logs, in history and in every
|
|
14
|
+
* analytics tool that records page addresses. An email is personal data and has no business there.
|
|
15
|
+
* - **`localStorage`, not `sessionStorage`.** An emailed link opens in a new tab, and a new tab
|
|
16
|
+
* does not inherit `sessionStorage`. The person who confirms their account from the mail lands
|
|
17
|
+
* on sign-in in that new tab, which is the screen that most needs the address.
|
|
18
|
+
* - **Thirty minutes, sliding.** Long enough to read a code in a mail client and come back; short
|
|
19
|
+
* enough that a shared computer forgets. Every keystroke renews it, and a value found expired is
|
|
20
|
+
* deleted when it is read, so it does not sit on disk afterwards.
|
|
21
|
+
* - **Memory behind it.** A browser that blocks site data throws on `localStorage` itself, and
|
|
22
|
+
* React Native has none. The value then lasts until the page (or the app) is left, which still
|
|
23
|
+
* covers moving between screens.
|
|
24
|
+
*
|
|
25
|
+
* Read it once when a screen opens, as a default value. It is not a live store: nothing re-renders
|
|
26
|
+
* when it changes, and a prerendered page must read it after hydration, never while rendering.
|
|
27
|
+
* Never put a password, a code or a token here. Call `forgetAuthEmail()` once a session exists, so
|
|
28
|
+
* the next person at this browser starts empty.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const KEY = "frontkit.auth-email";
|
|
32
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
33
|
+
/** The longest an address can be (RFC 5321). A longer value is not one, and is not kept. */
|
|
34
|
+
const MAX_LENGTH = 254;
|
|
35
|
+
|
|
36
|
+
interface Saved {
|
|
37
|
+
email: string;
|
|
38
|
+
at: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let memory: Saved | null = null;
|
|
42
|
+
|
|
43
|
+
function isSaved(value: unknown): value is Saved {
|
|
44
|
+
return (
|
|
45
|
+
typeof value === "object" &&
|
|
46
|
+
value !== null &&
|
|
47
|
+
"email" in value &&
|
|
48
|
+
typeof value.email === "string" &&
|
|
49
|
+
"at" in value &&
|
|
50
|
+
typeof value.at === "number"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readStored(): Saved | null {
|
|
55
|
+
try {
|
|
56
|
+
const raw = globalThis.localStorage?.getItem(KEY);
|
|
57
|
+
if (!raw) return null;
|
|
58
|
+
const parsed: unknown = JSON.parse(raw);
|
|
59
|
+
return isSaved(parsed) ? parsed : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Storage and memory are written together, so they only differ when one of them missed a write:
|
|
67
|
+
* storage that is full keeps an older address while memory holds the new one, and a second tab
|
|
68
|
+
* writes storage while this tab's memory goes stale. The newer of the two is the one the person
|
|
69
|
+
* typed last.
|
|
70
|
+
*/
|
|
71
|
+
function newest(): Saved | null {
|
|
72
|
+
const stored = readStored();
|
|
73
|
+
if (!stored || !memory) return stored ?? memory;
|
|
74
|
+
return stored.at >= memory.at ? stored : memory;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The address typed earlier in this sign-in, or an empty string when there is none. */
|
|
78
|
+
export function recallAuthEmail(): string {
|
|
79
|
+
const saved = newest();
|
|
80
|
+
if (!saved) return "";
|
|
81
|
+
if (Date.now() - saved.at > TTL_MS) {
|
|
82
|
+
forgetAuthEmail();
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
return saved.email;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Keep what the person has typed. Call it from the email field's change handler, so every screen
|
|
90
|
+
* they reach next can open with it. The value is stored as typed: trim it where you submit it.
|
|
91
|
+
* An empty value forgets.
|
|
92
|
+
*/
|
|
93
|
+
export function rememberAuthEmail(value: string): void {
|
|
94
|
+
if (value === "") return forgetAuthEmail();
|
|
95
|
+
if (value.length > MAX_LENGTH) return;
|
|
96
|
+
memory = { email: value, at: Date.now() };
|
|
97
|
+
try {
|
|
98
|
+
globalThis.localStorage?.setItem(KEY, JSON.stringify(memory));
|
|
99
|
+
} catch {
|
|
100
|
+
// Blocked or full: the memory copy above carries it until the page is left.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Drop it. Call this once a session exists, so the next person at this browser starts empty. */
|
|
105
|
+
export function forgetAuthEmail(): void {
|
|
106
|
+
memory = null;
|
|
107
|
+
try {
|
|
108
|
+
globalThis.localStorage?.removeItem(KEY);
|
|
109
|
+
} catch {
|
|
110
|
+
// Blocked: nothing was stored, so there is nothing to drop.
|
|
111
|
+
}
|
|
112
|
+
}
|