@alxia/react-router 0.16.0 → 0.18.0

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 CHANGED
@@ -937,8 +937,9 @@ has the rest.
937
937
  An app that renders its pages and calls a GraphQL API that signs users in
938
938
  with `@nxgt/janus` keeps the API's token in its own HttpOnly cookie.
939
939
  `@alxia/react-router/session` holds that session's server side, built on
940
- `/errors`; `/session/model` imports nothing and is safe in a component.
941
- Neither adds a peer.
940
+ `/errors`; `/session/react` holds the page's hooks around it (`useSignOut`, `useLinkParam`,
941
+ `useCodeField`); `/session/model` imports nothing and is safe in a component.
942
+ `/session` and `/session/model` add no peer; `/session/react` needs `react`, an optional peer.
942
943
 
943
944
  ```ts
944
945
  // app/session.server.ts
@@ -1137,6 +1138,24 @@ From `@alxia/react-router/session` (`react-router` and `/errors`' files are its
1137
1138
  | `Spent`, `SpendOptions`, `LinkStayed`, `RequestLinkAnswer` | their types |
1138
1139
  | `TOKEN_PAGE_HEADERS` | `Referrer-Policy: no-referrer` and `X-Robots-Tag: noindex, nofollow`, for a page whose URL carries a token |
1139
1140
 
1141
+ From `@alxia/react-router/session/react` (`react` and `react-router` are its only imports; every hook renders on the server):
1142
+
1143
+ | export | |
1144
+ | --- | --- |
1145
+ | `useSignOut({ action?, before? })` | `{ signOut, busy }`: posts `{}` as JSON to `action` (default `/sign-out`) after `before()`, whose failure never keeps the user signed in; `busy` until the navigation ends |
1146
+ | `SIGN_OUT_PATH`, `SignOutOptions`, `signOutAfter(before, submit)` | `/sign-out`, the options, and the step `useSignOut` is built on |
1147
+ | `useLinkParam(name, { drop?, live? }?)`, `LinkParamOptions` | a mail link's query parameter, read once (`null` when absent or empty); `drop: true` removes it from the address bar with `history.replaceState`; `live: true` follows the URL, for a parameter a redirect adds to the same mounted route |
1148
+ | `useCodeField(schema, refusal)`, `CodeField`, `CodeSchema` | a one-code field with no form library: `{ code, setCode, ref, error, check() }` over a synchronous Standard Schema; each new `refusal` object clears the refused code and focuses the input |
1149
+ | `useOnRefusal(refusal, handler)` | the core under it, for any form library: `handler` runs once per new refusal, not for one present at mount |
1150
+
1151
+ ```tsx
1152
+ import { useLinkParam, useSignOut } from '@alxia/react-router/session/react';
1153
+
1154
+ const token = useLinkParam('token', { drop: true });
1155
+ const linked = useLinkParam('state', { live: true }); // follows a redirect to this same route
1156
+ const { signOut, busy } = useSignOut();
1157
+ ```
1158
+
1140
1159
  From `@alxia/react-router/session/model` (no import at all):
1141
1160
 
1142
1161
  | export | |
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@alxia/react-router/session/react`: the hooks of a page around the
3
+ * session flows `/session` serves: signing out, a mail link's parameter and
4
+ * a one-code field. `react` and `react-router` are its only imports; it
5
+ * shares nothing with the server side of `/session`.
6
+ */
7
+ export { type CodeField, type CodeSchema, useCodeField, useOnRefusal, } from './use-code-field';
8
+ export { type LinkParamOptions, useLinkParam } from './use-link-param';
9
+ export { SIGN_OUT_PATH, type SignOutOptions, signOutAfter, useSignOut, } from './use-sign-out';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/session/react/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACN,KAAK,SAAS,EACd,KAAK,UAAU,EACf,YAAY,EACZ,YAAY,GACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,KAAK,gBAAgB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACvE,OAAO,EACN,aAAa,EACb,KAAK,cAAc,EACnB,YAAY,EACZ,UAAU,GACV,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,105 @@
1
+ // src/session/react/use-code-field.ts
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState
7
+ } from "react";
8
+ function useOnRefusal(refusal, handler) {
9
+ const handled = useRef(refusal);
10
+ const latest = useRef(handler);
11
+ latest.current = handler;
12
+ useEffect(() => {
13
+ if (!refusal || handled.current === refusal)
14
+ return;
15
+ handled.current = refusal;
16
+ latest.current();
17
+ }, [refusal]);
18
+ }
19
+ function useCodeField(schema, refusal) {
20
+ const [code, setCodeState] = useState("");
21
+ const [error, setError] = useState();
22
+ const ref = useRef(null);
23
+ const setCode = useCallback((next) => {
24
+ setCodeState(next);
25
+ setError(undefined);
26
+ }, []);
27
+ useOnRefusal(refusal, () => {
28
+ setCode("");
29
+ ref.current?.focus();
30
+ });
31
+ const check = () => {
32
+ const result = schema["~standard"].validate(code);
33
+ if (result instanceof Promise) {
34
+ throw new TypeError("useCodeField takes a synchronous schema");
35
+ }
36
+ const { issues, value } = result;
37
+ if (issues?.length) {
38
+ setError(issues[0]?.message);
39
+ return;
40
+ }
41
+ setError(undefined);
42
+ return value;
43
+ };
44
+ return { code, setCode, ref, error, check };
45
+ }
46
+ // src/session/react/use-link-param.ts
47
+ import { useEffect as useEffect2, useState as useState2 } from "react";
48
+ import { useLocation, useSearchParams } from "react-router";
49
+ function useLinkParam(name, { drop = false, live = false } = {}) {
50
+ const [params] = useSearchParams();
51
+ const { key } = useLocation();
52
+ const followed = live ? key : null;
53
+ const current = params.get(name) || null;
54
+ const [kept, setKept] = useState2(current);
55
+ const [seen, setSeen] = useState2(current);
56
+ if (live && current !== seen) {
57
+ setSeen(current);
58
+ if (!drop || current !== null)
59
+ setKept(current);
60
+ }
61
+ useEffect2(() => {
62
+ if (!drop)
63
+ return;
64
+ const url = new URL(window.location.href);
65
+ if (!url.searchParams.has(name))
66
+ return;
67
+ url.searchParams.delete(name);
68
+ window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
69
+ }, [drop, name, followed]);
70
+ if (live && !drop)
71
+ return current;
72
+ return kept;
73
+ }
74
+ // src/session/react/use-sign-out.ts
75
+ import { useCallback as useCallback2, useState as useState3 } from "react";
76
+ import { useNavigation, useSubmit } from "react-router";
77
+ var SIGN_OUT_PATH = "/sign-out";
78
+ async function signOutAfter(before, submit) {
79
+ try {
80
+ await before?.();
81
+ } catch {}
82
+ await submit();
83
+ }
84
+ function useSignOut(options = {}) {
85
+ const { action = SIGN_OUT_PATH, before } = options;
86
+ const submit = useSubmit();
87
+ const [preparing, setPreparing] = useState3(false);
88
+ const submitting = useNavigation().formAction === action;
89
+ const signOut = useCallback2(() => {
90
+ setPreparing(true);
91
+ signOutAfter(before, () => submit({}, { method: "post", action, encType: "application/json" })).finally(() => setPreparing(false));
92
+ }, [submit, action, before]);
93
+ return { signOut, busy: preparing || submitting };
94
+ }
95
+ export {
96
+ SIGN_OUT_PATH,
97
+ signOutAfter,
98
+ useCodeField,
99
+ useLinkParam,
100
+ useOnRefusal,
101
+ useSignOut
102
+ };
103
+
104
+ //# debugId=D9CB7386EE263E4264756E2164756E21
105
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/session/react/use-code-field.ts", "../src/session/react/use-link-param.ts", "../src/session/react/use-sign-out.ts"],
4
+ "sourcesContent": [
5
+ "import {\n\ttype RefObject,\n\tuseCallback,\n\tuseEffect,\n\tuseRef,\n\tuseState,\n} from 'react';\n\n/** The part of a Standard Schema (`~standard`) this hook reads; Zod, Valibot and ArkType satisfy it. */\nexport interface CodeSchema {\n\treadonly '~standard': {\n\t\tvalidate(value: unknown): unknown;\n\t};\n}\n\n/**\n * Calls `handler` once for each new `refusal` (a new object each time,\n * `undefined` when there is none). A refusal already there when the\n * component mounted is not new: it is the page's own, seen again.\n * Library-agnostic: pair it with any form library's `reset` and `focus`.\n */\nexport function useOnRefusal(\n\trefusal: object | undefined,\n\thandler: () => void,\n): void {\n\tconst handled = useRef(refusal);\n\tconst latest = useRef(handler);\n\tlatest.current = handler;\n\tuseEffect(() => {\n\t\tif (!refusal || handled.current === refusal) return;\n\t\thandled.current = refusal;\n\t\tlatest.current();\n\t}, [refusal]);\n}\n\nexport interface CodeField {\n\t/** The code typed so far. */\n\treadonly code: string;\n\treadonly setCode: (code: string) => void;\n\t/** On the input, so a refusal can focus it. */\n\treadonly ref: RefObject<HTMLInputElement | null>;\n\t/** The first issue's message of the last `check`, cleared by typing. */\n\treadonly error: string | undefined;\n\t/**\n\t * Validates the code against the schema: the parsed value, or\n\t * `undefined` and `error` set. Synchronous schemas only.\n\t */\n\treadonly check: () => unknown;\n}\n\n/**\n * A one-code field (an authenticator app's code, a recovery code) with no\n * form library: it holds the code, checks it against `schema`, and on each\n * new `refusal` clears the refused code and focuses the input for the next\n * try. With react-hook-form, use `useOnRefusal` and its `setValue` and\n * `setFocus` instead.\n */\nexport function useCodeField(\n\tschema: CodeSchema,\n\trefusal: object | undefined,\n): CodeField {\n\tconst [code, setCodeState] = useState('');\n\tconst [error, setError] = useState<string | undefined>();\n\tconst ref = useRef<HTMLInputElement | null>(null);\n\tconst setCode = useCallback((next: string) => {\n\t\tsetCodeState(next);\n\t\tsetError(undefined);\n\t}, []);\n\tuseOnRefusal(refusal, () => {\n\t\tsetCode('');\n\t\tref.current?.focus();\n\t});\n\tconst check = () => {\n\t\tconst result = schema['~standard'].validate(code);\n\t\tif (result instanceof Promise) {\n\t\t\tthrow new TypeError('useCodeField takes a synchronous schema');\n\t\t}\n\t\tconst { issues, value } = result as {\n\t\t\tissues?: ReadonlyArray<{ message: string }>;\n\t\t\tvalue?: unknown;\n\t\t};\n\t\tif (issues?.length) {\n\t\t\tsetError(issues[0]?.message);\n\t\t\treturn undefined;\n\t\t}\n\t\tsetError(undefined);\n\t\treturn value;\n\t};\n\treturn { code, setCode, ref, error, check };\n}\n",
6
+ "import { useEffect, useState } from 'react';\nimport { useLocation, useSearchParams } from 'react-router';\n\nexport interface LinkParamOptions {\n\t/**\n\t * Remove the parameter from the address bar once read\n\t * (`history.replaceState`), so a token is not left in the history, a\n\t * bookmark or a shared screen. Default `false`.\n\t */\n\treadonly drop?: boolean;\n\t/**\n\t * Follow the URL instead of reading it once. A parameter that a redirect\n\t * or a link adds to the SAME mounted route (`?state=linked`) is seen, and\n\t * each new location is read again. Without `drop` the value is the\n\t * parameter of the current location. With `drop` the value is kept after\n\t * the address bar loses the parameter, until a location carries a new\n\t * one: a navigation that has none does not clear it. Default `false`.\n\t */\n\treadonly live?: boolean;\n}\n\n/**\n * The query parameter a mail's link carries (`token`, `code`, `state`),\n * read once: the value of the first render is kept for the page's life, so\n * dropping it from the address bar never loses it. With `live: true` it\n * follows the URL instead (see `LinkParamOptions`). `null` when absent or\n * empty. Safe to render on the server, where nothing is dropped.\n */\nexport function useLinkParam(\n\tname: string,\n\t{ drop = false, live = false }: LinkParamOptions = {},\n): string | null {\n\tconst [params] = useSearchParams();\n\tconst { key } = useLocation();\n\tconst followed = live ? key : null;\n\tconst current = params.get(name) || null;\n\tconst [kept, setKept] = useState(current);\n\tconst [seen, setSeen] = useState(current);\n\tif (live && current !== seen) {\n\t\tsetSeen(current);\n\t\tif (!drop || current !== null) setKept(current);\n\t}\n\tuseEffect(() => {\n\t\tif (!drop) return;\n\t\tconst url = new URL(window.location.href);\n\t\tif (!url.searchParams.has(name)) return;\n\t\turl.searchParams.delete(name);\n\t\twindow.history.replaceState(\n\t\t\twindow.history.state,\n\t\t\t'',\n\t\t\t`${url.pathname}${url.search}${url.hash}`,\n\t\t);\n\t}, [drop, name, followed]);\n\tif (live && !drop) return current;\n\treturn kept;\n}\n",
7
+ "import { useCallback, useState } from 'react';\nimport { useNavigation, useSubmit } from 'react-router';\n\n/** Where `signInFlow`'s sign-out action is mounted unless the app says otherwise. */\nexport const SIGN_OUT_PATH = '/sign-out';\n\nexport interface SignOutOptions {\n\t/** The sign-out route (default `/sign-out`). */\n\treadonly action?: string;\n\t/**\n\t * Runs before the post: clear the worker's caches, a local store. A\n\t * rejection never keeps the user signed in: the sign-out goes on.\n\t */\n\treadonly before?: () => Promise<unknown> | unknown;\n}\n\n/**\n * Runs `before`, then `submit`. A `before` that throws or rejects is\n * ignored, so a failed cleanup cannot keep a session alive.\n */\nexport async function signOutAfter(\n\tbefore: (() => Promise<unknown> | unknown) | undefined,\n\tsubmit: () => Promise<void> | void,\n): Promise<void> {\n\ttry {\n\t\tawait before?.();\n\t} catch {\n\t\t// Signing out matters more than the cleanup.\n\t}\n\tawait submit();\n}\n\n/**\n * The one way a page signs out, wherever the control is: posts `{}` as JSON\n * to the sign-out route (same-origin and JSON only, as `signInFlow`'s action\n * requires). `busy` from the call until the navigation it starts has ended.\n */\nexport function useSignOut(options: SignOutOptions = {}): {\n\tsignOut: () => void;\n\tbusy: boolean;\n} {\n\tconst { action = SIGN_OUT_PATH, before } = options;\n\tconst submit = useSubmit();\n\tconst [preparing, setPreparing] = useState(false);\n\tconst submitting = useNavigation().formAction === action;\n\tconst signOut = useCallback(() => {\n\t\tsetPreparing(true);\n\t\tvoid signOutAfter(before, () =>\n\t\t\tsubmit({}, { method: 'post', action, encType: 'application/json' }),\n\t\t).finally(() => setPreparing(false));\n\t}, [submit, action, before]);\n\treturn { signOut, busy: preparing || submitting };\n}\n"
8
+ ],
9
+ "mappings": ";AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqBO,SAAS,YAAY,CAC3B,SACA,SACO;AAAA,EACP,MAAM,UAAU,OAAO,OAAO;AAAA,EAC9B,MAAM,SAAS,OAAO,OAAO;AAAA,EAC7B,OAAO,UAAU;AAAA,EACjB,UAAU,MAAM;AAAA,IACf,IAAI,CAAC,WAAW,QAAQ,YAAY;AAAA,MAAS;AAAA,IAC7C,QAAQ,UAAU;AAAA,IAClB,OAAO,QAAQ;AAAA,KACb,CAAC,OAAO,CAAC;AAAA;AAyBN,SAAS,YAAY,CAC3B,QACA,SACY;AAAA,EACZ,OAAO,MAAM,gBAAgB,SAAS,EAAE;AAAA,EACxC,OAAO,OAAO,YAAY,SAA6B;AAAA,EACvD,MAAM,MAAM,OAAgC,IAAI;AAAA,EAChD,MAAM,UAAU,YAAY,CAAC,SAAiB;AAAA,IAC7C,aAAa,IAAI;AAAA,IACjB,SAAS,SAAS;AAAA,KAChB,CAAC,CAAC;AAAA,EACL,aAAa,SAAS,MAAM;AAAA,IAC3B,QAAQ,EAAE;AAAA,IACV,IAAI,SAAS,MAAM;AAAA,GACnB;AAAA,EACD,MAAM,QAAQ,MAAM;AAAA,IACnB,MAAM,SAAS,OAAO,aAAa,SAAS,IAAI;AAAA,IAChD,IAAI,kBAAkB,SAAS;AAAA,MAC9B,MAAM,IAAI,UAAU,yCAAyC;AAAA,IAC9D;AAAA,IACA,QAAQ,QAAQ,UAAU;AAAA,IAI1B,IAAI,QAAQ,QAAQ;AAAA,MACnB,SAAS,OAAO,IAAI,OAAO;AAAA,MAC3B;AAAA,IACD;AAAA,IACA,SAAS,SAAS;AAAA,IAClB,OAAO;AAAA;AAAA,EAER,OAAO,EAAE,MAAM,SAAS,KAAK,OAAO,MAAM;AAAA;;ACxF3C,sBAAS,wBAAW;AACpB;AA2BO,SAAS,YAAY,CAC3B,QACE,OAAO,OAAO,OAAO,UAA4B,CAAC,GACpC;AAAA,EAChB,OAAO,UAAU,gBAAgB;AAAA,EACjC,QAAQ,QAAQ,YAAY;AAAA,EAC5B,MAAM,WAAW,OAAO,MAAM;AAAA,EAC9B,MAAM,UAAU,OAAO,IAAI,IAAI,KAAK;AAAA,EACpC,OAAO,MAAM,WAAW,UAAS,OAAO;AAAA,EACxC,OAAO,MAAM,WAAW,UAAS,OAAO;AAAA,EACxC,IAAI,QAAQ,YAAY,MAAM;AAAA,IAC7B,QAAQ,OAAO;AAAA,IACf,IAAI,CAAC,QAAQ,YAAY;AAAA,MAAM,QAAQ,OAAO;AAAA,EAC/C;AAAA,EACA,WAAU,MAAM;AAAA,IACf,IAAI,CAAC;AAAA,MAAM;AAAA,IACX,MAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;AAAA,IACxC,IAAI,CAAC,IAAI,aAAa,IAAI,IAAI;AAAA,MAAG;AAAA,IACjC,IAAI,aAAa,OAAO,IAAI;AAAA,IAC5B,OAAO,QAAQ,aACd,OAAO,QAAQ,OACf,IACA,GAAG,IAAI,WAAW,IAAI,SAAS,IAAI,MACpC;AAAA,KACE,CAAC,MAAM,MAAM,QAAQ,CAAC;AAAA,EACzB,IAAI,QAAQ,CAAC;AAAA,IAAM,OAAO;AAAA,EAC1B,OAAO;AAAA;;ACtDR,wBAAS,0BAAa;AACtB;AAGO,IAAM,gBAAgB;AAgB7B,eAAsB,YAAY,CACjC,QACA,QACgB;AAAA,EAChB,IAAI;AAAA,IACH,MAAM,SAAS;AAAA,IACd,MAAM;AAAA,EAGR,MAAM,OAAO;AAAA;AAQP,SAAS,UAAU,CAAC,UAA0B,CAAC,GAGpD;AAAA,EACD,QAAQ,SAAS,eAAe,WAAW;AAAA,EAC3C,MAAM,SAAS,UAAU;AAAA,EACzB,OAAO,WAAW,gBAAgB,UAAS,KAAK;AAAA,EAChD,MAAM,aAAa,cAAc,EAAE,eAAe;AAAA,EAClD,MAAM,UAAU,aAAY,MAAM;AAAA,IACjC,aAAa,IAAI;AAAA,IACZ,aAAa,QAAQ,MACzB,OAAO,CAAC,GAAG,EAAE,QAAQ,QAAQ,QAAQ,SAAS,mBAAmB,CAAC,CACnE,EAAE,QAAQ,MAAM,aAAa,KAAK,CAAC;AAAA,KACjC,CAAC,QAAQ,QAAQ,MAAM,CAAC;AAAA,EAC3B,OAAO,EAAE,SAAS,MAAM,aAAa,WAAW;AAAA;",
10
+ "debugId": "D9CB7386EE263E4264756E2164756E21",
11
+ "names": []
12
+ }
@@ -0,0 +1,37 @@
1
+ import { type RefObject } from 'react';
2
+ /** The part of a Standard Schema (`~standard`) this hook reads; Zod, Valibot and ArkType satisfy it. */
3
+ export interface CodeSchema {
4
+ readonly '~standard': {
5
+ validate(value: unknown): unknown;
6
+ };
7
+ }
8
+ /**
9
+ * Calls `handler` once for each new `refusal` (a new object each time,
10
+ * `undefined` when there is none). A refusal already there when the
11
+ * component mounted is not new: it is the page's own, seen again.
12
+ * Library-agnostic: pair it with any form library's `reset` and `focus`.
13
+ */
14
+ export declare function useOnRefusal(refusal: object | undefined, handler: () => void): void;
15
+ export interface CodeField {
16
+ /** The code typed so far. */
17
+ readonly code: string;
18
+ readonly setCode: (code: string) => void;
19
+ /** On the input, so a refusal can focus it. */
20
+ readonly ref: RefObject<HTMLInputElement | null>;
21
+ /** The first issue's message of the last `check`, cleared by typing. */
22
+ readonly error: string | undefined;
23
+ /**
24
+ * Validates the code against the schema: the parsed value, or
25
+ * `undefined` and `error` set. Synchronous schemas only.
26
+ */
27
+ readonly check: () => unknown;
28
+ }
29
+ /**
30
+ * A one-code field (an authenticator app's code, a recovery code) with no
31
+ * form library: it holds the code, checks it against `schema`, and on each
32
+ * new `refusal` clears the refused code and focuses the input for the next
33
+ * try. With react-hook-form, use `useOnRefusal` and its `setValue` and
34
+ * `setFocus` instead.
35
+ */
36
+ export declare function useCodeField(schema: CodeSchema, refusal: object | undefined): CodeField;
37
+ //# sourceMappingURL=use-code-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-code-field.d.ts","sourceRoot":"","sources":["../../../src/session/react/use-code-field.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,SAAS,EAKd,MAAM,OAAO,CAAC;AAEf,wGAAwG;AACxG,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,WAAW,EAAE;QACrB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC;KAClC,CAAC;CACF;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,EAAE,MAAM,IAAI,GACjB,IAAI,CASN;AAED,MAAM,WAAW,SAAS;IACzB,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,+CAA+C;IAC/C,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACjD,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC3B,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,MAAM,GAAG,SAAS,GACzB,SAAS,CA6BX"}
@@ -0,0 +1,26 @@
1
+ export interface LinkParamOptions {
2
+ /**
3
+ * Remove the parameter from the address bar once read
4
+ * (`history.replaceState`), so a token is not left in the history, a
5
+ * bookmark or a shared screen. Default `false`.
6
+ */
7
+ readonly drop?: boolean;
8
+ /**
9
+ * Follow the URL instead of reading it once. A parameter that a redirect
10
+ * or a link adds to the SAME mounted route (`?state=linked`) is seen, and
11
+ * each new location is read again. Without `drop` the value is the
12
+ * parameter of the current location. With `drop` the value is kept after
13
+ * the address bar loses the parameter, until a location carries a new
14
+ * one: a navigation that has none does not clear it. Default `false`.
15
+ */
16
+ readonly live?: boolean;
17
+ }
18
+ /**
19
+ * The query parameter a mail's link carries (`token`, `code`, `state`),
20
+ * read once: the value of the first render is kept for the page's life, so
21
+ * dropping it from the address bar never loses it. With `live: true` it
22
+ * follows the URL instead (see `LinkParamOptions`). `null` when absent or
23
+ * empty. Safe to render on the server, where nothing is dropped.
24
+ */
25
+ export declare function useLinkParam(name: string, { drop, live }?: LinkParamOptions): string | null;
26
+ //# sourceMappingURL=use-link-param.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-link-param.d.ts","sourceRoot":"","sources":["../../../src/session/react/use-link-param.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,gBAAgB;IAChC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC3B,IAAI,EAAE,MAAM,EACZ,EAAE,IAAY,EAAE,IAAY,EAAE,GAAE,gBAAqB,GACnD,MAAM,GAAG,IAAI,CAwBf"}
@@ -0,0 +1,26 @@
1
+ /** Where `signInFlow`'s sign-out action is mounted unless the app says otherwise. */
2
+ export declare const SIGN_OUT_PATH = "/sign-out";
3
+ export interface SignOutOptions {
4
+ /** The sign-out route (default `/sign-out`). */
5
+ readonly action?: string;
6
+ /**
7
+ * Runs before the post: clear the worker's caches, a local store. A
8
+ * rejection never keeps the user signed in: the sign-out goes on.
9
+ */
10
+ readonly before?: () => Promise<unknown> | unknown;
11
+ }
12
+ /**
13
+ * Runs `before`, then `submit`. A `before` that throws or rejects is
14
+ * ignored, so a failed cleanup cannot keep a session alive.
15
+ */
16
+ export declare function signOutAfter(before: (() => Promise<unknown> | unknown) | undefined, submit: () => Promise<void> | void): Promise<void>;
17
+ /**
18
+ * The one way a page signs out, wherever the control is: posts `{}` as JSON
19
+ * to the sign-out route (same-origin and JSON only, as `signInFlow`'s action
20
+ * requires). `busy` from the call until the navigation it starts has ended.
21
+ */
22
+ export declare function useSignOut(options?: SignOutOptions): {
23
+ signOut: () => void;
24
+ busy: boolean;
25
+ };
26
+ //# sourceMappingURL=use-sign-out.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-sign-out.d.ts","sourceRoot":"","sources":["../../../src/session/react/use-sign-out.ts"],"names":[],"mappings":"AAGA,qFAAqF;AACrF,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC,MAAM,WAAW,cAAc;IAC9B,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;CACnD;AAED;;;GAGG;AACH,wBAAsB,YAAY,CACjC,MAAM,EAAE,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,GAAG,SAAS,EACtD,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAChC,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,OAAO,GAAE,cAAmB,GAAG;IACzD,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;CACd,CAYA"}
package/docs/README.md CHANGED
@@ -19,7 +19,7 @@ installable and usable offline, wording an API's refusals, and what to do with e
19
19
  | [Deploying](guide/deploying.md) | running the build with `bun build/server/index.js`, and the Docker image |
20
20
  | [Installable and offline](guide/pwa.md) | making the app installable and usable offline: the subpaths of `@alxia/react-router/pwa`, the service worker (`startWorker`, `/pwa/worker`), its build by `alxia()` or `buildWorker` (`/pwa/build`), its contract and cache names, which requests the worker caches and which responses it stores, the precache list read from a build, or clearing the caches at sign-out |
21
21
  | [An API's refusals](guide/errors.md) | wording a GraphQL API's refusals for the pages: recognising an `ApiError`, the request id, the page a request is for, the catalogue store, the form checks in the API's words, the provider, and the loaders, the error page and the actions that answer with it, the JSON actions with their CSRF check and fetcher hook, calling the API from a loader (`apiCallFailure`), and keeping the catalogue fresh (`@alxia/react-router/errors`) |
22
- | [A BFF session](guide/session.md) | redirecting after sign-in to a page of the site and nowhere else, the sign-in pages' paths, the session's cookies, the sign-in flow (where an answer lands, a refused code, the viewer, sign-out; `@alxia/react-router/session`), the pages a mail's link opens (a magic link, a password reset: `authLinks`), and the step-up's state and the props it gives the UI (`@alxia/react-router/session/model`) |
22
+ | [A BFF session](guide/session.md) | redirecting after sign-in to a page of the site and nowhere else, the sign-in pages' paths, the session's cookies, the sign-in flow (where an answer lands, a refused code, the viewer, sign-out; `@alxia/react-router/session`), the pages a mail's link opens (a magic link, a password reset: `authLinks`), the page's hooks (sign-out, a mail link's parameter, a one-code field; `@alxia/react-router/session/react`), and the step-up's state and the props it gives the UI (`@alxia/react-router/session/model`) |
23
23
  | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a page streams, hydrates or reads its context unlike you expected |
24
24
  | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
25
25
 
@@ -6,12 +6,15 @@ holds the API's session token in an HttpOnly cookie of its own. The browser
6
6
  never sees the token; the app sends it to the API as a bearer, on the user's
7
7
  behalf.
8
8
 
9
- It covers two subpaths:
9
+ It covers three subpaths:
10
10
 
11
11
  - `@alxia/react-router/session`, the server side: the cookies, the sign-in
12
12
  flow, the step-up and the pages a mail's link opens. It imports
13
13
  `react-router` and `/errors`' files, nothing else, knows no API client and
14
14
  adds no peer.
15
+ - `@alxia/react-router/session/react`, the page's hooks around those flows:
16
+ signing out, a mail link's parameter and a one-code field. They import
17
+ `react` and `react-router`, both already peers.
15
18
  - `@alxia/react-router/session/model`, the part that imports nothing and is
16
19
  safe in a component as well as in a loader: redirects that stay on the
17
20
  site, the sign-in pages' paths, and the step-up's state.
@@ -31,6 +34,7 @@ instead.
31
34
  - [Signing out](#signing-out)
32
35
  - [Step-up](#step-up)
33
36
  - [Pages a mail's link opens](#pages-a-mails-link-opens)
37
+ - [The page's hooks](#the-pages-hooks)
34
38
  - [The types and the rest of `/session/model`](#the-types-and-the-rest-of-sessionmodel)
35
39
 
36
40
  ## A complete sign-in
@@ -949,6 +953,148 @@ site, as a GET that spends nothing; the token is no further from the user than
949
953
  it was in the address bar, and the page's `TOKEN_PAGE_HEADERS` still keep it
950
954
  out of any `Referer`.
951
955
 
956
+ ## The page's hooks
957
+
958
+ `@alxia/react-router/session/react` holds the three client pieces every app
959
+ writes around the flows above. It imports `react` and `react-router` (peers
960
+ the package already has) and nothing else; every hook renders on the server.
961
+
962
+ ### `useSignOut`
963
+
964
+ Posts `{}` as JSON to the sign-out route (the one `signIn.signOut` serves; JSON
965
+ and same-origin only), wherever the control is. `busy` is true from the call
966
+ until the navigation it starts has ended, so the button can show it.
967
+
968
+ ```tsx
969
+ import { useSignOut } from '@alxia/react-router/session/react';
970
+
971
+ export function SignOutButton() {
972
+ const { signOut, busy } = useSignOut();
973
+ return (
974
+ <button type="button" onClick={signOut} disabled={busy}>
975
+ {busy ? 'Signing out…' : 'Sign out'}
976
+ </button>
977
+ );
978
+ }
979
+ ```
980
+
981
+ Options: `action` (the route, default `/sign-out`) and `before`, run before the
982
+ post, for what must be gone from a shared device first. A `before` that throws
983
+ or rejects never keeps the user signed in: the sign-out goes on.
984
+
985
+ ```tsx
986
+ const { signOut } = useSignOut({
987
+ action: '/account/sign-out',
988
+ before: () => caches.keys().then((keys) => Promise.all(keys.map((k) => caches.delete(k)))),
989
+ });
990
+ ```
991
+
992
+ ### `useLinkParam`
993
+
994
+ Reads the query parameter a mail's link carries, once: the first render's value
995
+ is kept for the page's life (unless `live: true`, below). With `drop: true` the parameter is then removed
996
+ from the address bar (`history.replaceState`, keeping the history state and the
997
+ hash), so a token is not left in the history, a bookmark or a screenshot. It is
998
+ `null` when the parameter is absent or empty.
999
+
1000
+ ```tsx
1001
+ import { useLinkParam } from '@alxia/react-router/session/react';
1002
+
1003
+ export default function ResetPassword() {
1004
+ const token = useLinkParam('token', { drop: true });
1005
+ if (!token) return <p>This link is not valid. Ask for a new one.</p>;
1006
+ return (
1007
+ <form method="post">
1008
+ <input type="hidden" name="token" value={token} />
1009
+ <input type="password" name="password" autoComplete="new-password" />
1010
+ <button type="submit">Choose a new password</button>
1011
+ </form>
1012
+ );
1013
+ }
1014
+ ```
1015
+
1016
+ The router's own location is not updated by `replaceState`: a loader or
1017
+ `useSearchParams` elsewhere still sees the token until the next navigation.
1018
+ Read it with the hook once, and pass it down.
1019
+
1020
+ #### `live: true`: a parameter set after the page mounted
1021
+
1022
+ By default the value is read once. A route that stays mounted while an action
1023
+ redirects back to it with a new parameter (`/settings` to
1024
+ `/settings?state=linked`) never sees it. `live: true` follows the URL: the
1025
+ value is the parameter of the current location, and each new location is read
1026
+ again.
1027
+
1028
+ ```tsx
1029
+ const state = useLinkParam('state', { live: true }); // 'linked' | 'invalid' | null
1030
+ ```
1031
+
1032
+ With `drop: true` too, the value is kept after the address bar loses the
1033
+ parameter, until a location carries a new one: a navigation that has no such
1034
+ parameter does not clear it, since the drop removed it. Without `drop` the
1035
+ value is exactly the current location's parameter, `null` when it is gone.
1036
+
1037
+ ### `useCodeField` and `useOnRefusal`
1038
+
1039
+ A form that asks for one code (an authenticator app's, a recovery code) and
1040
+ whose action answers a refusal object when the code is wrong: on each new
1041
+ refusal the refused code goes and the field takes focus, for the next try. A
1042
+ refusal already there when the component mounted is the page's own and is
1043
+ ignored.
1044
+
1045
+ `useCodeField(schema, refusal)` needs no form library. `schema` is any
1046
+ [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType; a
1047
+ synchronous one). It returns `code`, `setCode`, `ref` (put it on the input),
1048
+ `error` (the first message of the last `check()`, cleared by typing) and
1049
+ `check()` (the parsed value, or `undefined` with `error` set).
1050
+
1051
+ ```tsx
1052
+ import { useCodeField } from '@alxia/react-router/session/react';
1053
+ import { TOTP_CODE } from '@alxia/react-router/session/model';
1054
+ import { Form, useActionData } from 'react-router';
1055
+ import { z } from 'zod';
1056
+
1057
+ const schema = z.string().regex(TOTP_CODE, 'Six digits');
1058
+
1059
+ export function CodeForm() {
1060
+ const refusal = useActionData<object>(); // a new object on each refusal
1061
+ const field = useCodeField(schema, refusal);
1062
+ return (
1063
+ <Form method="post" onSubmit={(event) => { if (field.check() === undefined) event.preventDefault(); }}>
1064
+ <input
1065
+ ref={field.ref}
1066
+ name="code"
1067
+ inputMode="numeric"
1068
+ autoComplete="one-time-code"
1069
+ value={field.code}
1070
+ onChange={(event) => field.setCode(event.target.value)}
1071
+ />
1072
+ {field.error ? <p role="alert">{field.error}</p> : null}
1073
+ <button type="submit">Confirm</button>
1074
+ </Form>
1075
+ );
1076
+ }
1077
+ ```
1078
+
1079
+ An app on react-hook-form keeps its form and its resolver and uses the part
1080
+ underneath, `useOnRefusal(refusal, handler)`, which calls `handler` once per
1081
+ new refusal:
1082
+
1083
+ ```tsx
1084
+ import { useOnRefusal } from '@alxia/react-router/session/react';
1085
+ import { useForm } from 'react-hook-form';
1086
+
1087
+ const form = useForm({ defaultValues: { code: '' } /* , resolver */ });
1088
+ useOnRefusal(refusal, () => {
1089
+ form.setValue('code', '');
1090
+ form.setFocus('code');
1091
+ });
1092
+ ```
1093
+
1094
+ The package takes no form library as a peer: react-hook-form would also need
1095
+ its resolver for the schema, and a library-agnostic core serves every form
1096
+ library at the price of three lines.
1097
+
952
1098
  ## The types and the rest of `/session/model`
953
1099
 
954
1100
  The rest of `@alxia/react-router/session/model`, beside `safeNext`,
package/docs/roadmap.md CHANGED
@@ -7,7 +7,12 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- Nothing planned.
10
+ - **`@alxia/react-router/session/react`: the page's hooks around the session
11
+ flows.** `useSignOut`, `useLinkParam` (a mail link's parameter, read once and optionally dropped
12
+ from the address bar, or with `live: true` following the URL) and `useCodeField` /
13
+ `useOnRefusal` (a one-code field that clears and refocuses on each refusal),
14
+ so an app stops carrying its own copies. See the
15
+ [session guide](guide/session.md#the-pages-hooks).
11
16
 
12
17
  ## Next
13
18
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alxia/react-router",
3
- "version": "0.16.0",
4
- "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`) and what it shares with the page (`/session/model`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
3
+ "version": "0.18.0",
4
+ "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -72,6 +72,11 @@
72
72
  "import": "./dist/pwa/worker/index.js",
73
73
  "default": "./dist/pwa/worker/index.js"
74
74
  },
75
+ "./session/react": {
76
+ "types": "./dist/session/react/index.d.ts",
77
+ "import": "./dist/session/react/index.js",
78
+ "default": "./dist/session/react/index.js"
79
+ },
75
80
  "./session/model": {
76
81
  "types": "./dist/session/model/index.d.ts",
77
82
  "import": "./dist/session/model/index.js",
@@ -103,6 +108,7 @@
103
108
  "src/errors/index.ts",
104
109
  "src/session/model/index.ts",
105
110
  "src/session/index.ts",
111
+ "src/session/react/index.ts",
106
112
  "src/document/index.ts",
107
113
  "src/entry/index.ts",
108
114
  "src/pwa/worker/index.ts",
@@ -121,6 +127,7 @@
121
127
  "@types/react": "^19.3.0",
122
128
  "@types/react-dom": "^19.3.0",
123
129
  "graphql": "^17.0.2",
130
+ "happy-dom": "^20",
124
131
  "intl-messageformat": "^12.1.4",
125
132
  "isbot": "^5.2.2",
126
133
  "react": "^19.3.1",