@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 +20 -2
- package/dist/session/react/index.d.ts +10 -0
- package/dist/session/react/index.d.ts.map +1 -0
- package/dist/session/react/index.js +94 -0
- package/dist/session/react/index.js.map +12 -0
- package/dist/session/react/use-code-field.d.ts +37 -0
- package/dist/session/react/use-code-field.d.ts.map +1 -0
- package/dist/session/react/use-link-param.d.ts +16 -0
- package/dist/session/react/use-link-param.d.ts.map +1 -0
- package/dist/session/react/use-sign-out.d.ts +26 -0
- package/dist/session/react/use-sign-out.d.ts.map +1 -0
- package/docs/README.md +1 -1
- package/docs/guide/session.md +130 -1
- package/docs/roadmap.md +6 -1
- package/package.json +9 -2
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/
|
|
941
|
-
|
|
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
|
|
package/docs/guide/session.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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",
|