@alxia/react-router 0.16.0 → 0.17.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,23 @@ 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? }?)`, `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` |
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 { signOut, busy } = useSignOut();
1156
+ ```
1157
+
1140
1158
  From `@alxia/react-router/session/model` (no import at all):
1141
1159
 
1142
1160
  | 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,94 @@
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 { useSearchParams } from "react-router";
49
+ function useLinkParam(name, { drop = false } = {}) {
50
+ const [params] = useSearchParams();
51
+ const [value] = useState2(() => params.get(name) || null);
52
+ useEffect2(() => {
53
+ if (!drop)
54
+ return;
55
+ const url = new URL(window.location.href);
56
+ if (!url.searchParams.has(name))
57
+ return;
58
+ url.searchParams.delete(name);
59
+ window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
60
+ }, [drop, name]);
61
+ return value;
62
+ }
63
+ // src/session/react/use-sign-out.ts
64
+ import { useCallback as useCallback2, useState as useState3 } from "react";
65
+ import { useNavigation, useSubmit } from "react-router";
66
+ var SIGN_OUT_PATH = "/sign-out";
67
+ async function signOutAfter(before, submit) {
68
+ try {
69
+ await before?.();
70
+ } catch {}
71
+ await submit();
72
+ }
73
+ function useSignOut(options = {}) {
74
+ const { action = SIGN_OUT_PATH, before } = options;
75
+ const submit = useSubmit();
76
+ const [preparing, setPreparing] = useState3(false);
77
+ const submitting = useNavigation().formAction === action;
78
+ const signOut = useCallback2(() => {
79
+ setPreparing(true);
80
+ signOutAfter(before, () => submit({}, { method: "post", action, encType: "application/json" })).finally(() => setPreparing(false));
81
+ }, [submit, action, before]);
82
+ return { signOut, busy: preparing || submitting };
83
+ }
84
+ export {
85
+ SIGN_OUT_PATH,
86
+ signOutAfter,
87
+ useCodeField,
88
+ useLinkParam,
89
+ useOnRefusal,
90
+ useSignOut
91
+ };
92
+
93
+ //# debugId=26E2FEF9A38E677964756E2164756E21
94
+ //# 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 { 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}\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. `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 }: LinkParamOptions = {},\n): string | null {\n\tconst [params] = useSearchParams();\n\tconst [value] = useState(() => params.get(name) || null);\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]);\n\treturn value;\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;AAiBO,SAAS,YAAY,CAC3B,QACE,OAAO,UAA4B,CAAC,GACtB;AAAA,EAChB,OAAO,UAAU,gBAAgB;AAAA,EACjC,OAAO,SAAS,UAAS,MAAM,OAAO,IAAI,IAAI,KAAK,IAAI;AAAA,EACvD,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,IAAI,CAAC;AAAA,EACf,OAAO;AAAA;;ACnCR,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": "26E2FEF9A38E677964756E2164756E21",
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,16 @@
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
+ /**
10
+ * The query parameter a mail's link carries (`token`, `code`, `state`),
11
+ * read once: the value of the first render is kept for the page's life, so
12
+ * dropping it from the address bar never loses it. `null` when absent or
13
+ * empty. Safe to render on the server, where nothing is dropped.
14
+ */
15
+ export declare function useLinkParam(name: string, { drop }?: LinkParamOptions): string | null;
16
+ //# 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;CACxB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC3B,IAAI,EAAE,MAAM,EACZ,EAAE,IAAY,EAAE,GAAE,gBAAqB,GACrC,MAAM,GAAG,IAAI,CAef"}
@@ -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,131 @@ 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. 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
+ ### `useCodeField` and `useOnRefusal`
1021
+
1022
+ A form that asks for one code (an authenticator app's, a recovery code) and
1023
+ whose action answers a refusal object when the code is wrong: on each new
1024
+ refusal the refused code goes and the field takes focus, for the next try. A
1025
+ refusal already there when the component mounted is the page's own and is
1026
+ ignored.
1027
+
1028
+ `useCodeField(schema, refusal)` needs no form library. `schema` is any
1029
+ [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType; a
1030
+ synchronous one). It returns `code`, `setCode`, `ref` (put it on the input),
1031
+ `error` (the first message of the last `check()`, cleared by typing) and
1032
+ `check()` (the parsed value, or `undefined` with `error` set).
1033
+
1034
+ ```tsx
1035
+ import { useCodeField } from '@alxia/react-router/session/react';
1036
+ import { TOTP_CODE } from '@alxia/react-router/session/model';
1037
+ import { Form, useActionData } from 'react-router';
1038
+ import { z } from 'zod';
1039
+
1040
+ const schema = z.string().regex(TOTP_CODE, 'Six digits');
1041
+
1042
+ export function CodeForm() {
1043
+ const refusal = useActionData<object>(); // a new object on each refusal
1044
+ const field = useCodeField(schema, refusal);
1045
+ return (
1046
+ <Form method="post" onSubmit={(event) => { if (field.check() === undefined) event.preventDefault(); }}>
1047
+ <input
1048
+ ref={field.ref}
1049
+ name="code"
1050
+ inputMode="numeric"
1051
+ autoComplete="one-time-code"
1052
+ value={field.code}
1053
+ onChange={(event) => field.setCode(event.target.value)}
1054
+ />
1055
+ {field.error ? <p role="alert">{field.error}</p> : null}
1056
+ <button type="submit">Confirm</button>
1057
+ </Form>
1058
+ );
1059
+ }
1060
+ ```
1061
+
1062
+ An app on react-hook-form keeps its form and its resolver and uses the part
1063
+ underneath, `useOnRefusal(refusal, handler)`, which calls `handler` once per
1064
+ new refusal:
1065
+
1066
+ ```tsx
1067
+ import { useOnRefusal } from '@alxia/react-router/session/react';
1068
+ import { useForm } from 'react-hook-form';
1069
+
1070
+ const form = useForm({ defaultValues: { code: '' } /* , resolver */ });
1071
+ useOnRefusal(refusal, () => {
1072
+ form.setValue('code', '');
1073
+ form.setFocus('code');
1074
+ });
1075
+ ```
1076
+
1077
+ The package takes no form library as a peer: react-hook-form would also need
1078
+ its resolver for the schema, and a library-agnostic core serves every form
1079
+ library at the price of three lines.
1080
+
952
1081
  ## The types and the rest of `/session/model`
953
1082
 
954
1083
  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
12
+ optionally dropped from the address bar) 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.17.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",