@alxia/react-router 0.41.0 → 0.42.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 +69 -1
- package/dist/chunks/index-1f16k1sj.js +186 -0
- package/dist/chunks/index-1f16k1sj.js.map +14 -0
- package/dist/chunks/index-atxcmy0f.js +47 -0
- package/dist/chunks/index-atxcmy0f.js.map +11 -0
- package/dist/chunks/index-gchzf3mc.js +30 -0
- package/dist/chunks/index-gchzf3mc.js.map +10 -0
- package/dist/chunks/{index-2swe47m6.js → index-hn6htqc2.js} +3 -46
- package/dist/chunks/{index-2swe47m6.js.map → index-hn6htqc2.js.map} +4 -6
- package/dist/chunks/index-vr82typq.js +23 -0
- package/dist/chunks/index-vr82typq.js.map +11 -0
- package/dist/chunks/index-zyg8amjc.js +23 -0
- package/dist/chunks/index-zyg8amjc.js.map +10 -0
- package/dist/errors/index.js +15 -30
- package/dist/errors/index.js.map +3 -4
- package/dist/fetcher/index.js +14 -28
- package/dist/fetcher/index.js.map +3 -4
- package/dist/oauth/answer.d.ts +24 -0
- package/dist/oauth/answer.d.ts.map +1 -0
- package/dist/oauth/consent.d.ts +88 -0
- package/dist/oauth/consent.d.ts.map +1 -0
- package/dist/oauth/index.d.ts +12 -0
- package/dist/oauth/index.d.ts.map +1 -0
- package/dist/oauth/index.js +106 -0
- package/dist/oauth/index.js.map +11 -0
- package/dist/oauth/model.d.ts +13 -0
- package/dist/oauth/model.d.ts.map +1 -0
- package/dist/oauth/react/index.d.ts +7 -0
- package/dist/oauth/react/index.d.ts.map +1 -0
- package/dist/oauth/react/index.js +87 -0
- package/dist/oauth/react/index.js.map +10 -0
- package/dist/oauth/react/use-consent.d.ts +47 -0
- package/dist/oauth/react/use-consent.d.ts.map +1 -0
- package/dist/session/index.js +30 -39
- package/dist/session/index.js.map +3 -5
- package/dist/session/react/index.js +31 -204
- package/dist/session/react/index.js.map +3 -8
- package/docs/README.md +2 -1
- package/docs/guide/oauth.md +132 -0
- package/docs/roadmap.md +17 -1
- package/docs/troubleshooting.md +39 -0
- package/package.json +14 -2
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/session/react/
|
|
3
|
+
"sources": ["../src/session/react/use-code-field.ts", "../src/session/react/use-link-param.ts", "../src/session/react/use-sign-out.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import type {\n\tStepUpConfirmAnswer,\n\tStepUpRequestAnswer,\n} from '../../model/step-up-answers';\n\nconst intentOf = (data: unknown) =>\n\ttypeof data === 'object' && data !== null\n\t\t? (data as { intent?: unknown }).intent\n\t\t: undefined;\n\n/**\n * The step-up route answers both intents from one action, so a fetcher's data\n * is their union: this is the request's answer, or `undefined` for any other.\n */\nexport function requestAnswerOf(\n\tdata: unknown,\n): StepUpRequestAnswer | undefined {\n\treturn intentOf(data) === 'request-step-up'\n\t\t? (data as StepUpRequestAnswer)\n\t\t: undefined;\n}\n\n/** The confirm's answer out of the route's union, or `undefined` for any other. */\nexport function confirmAnswerOf(\n\tdata: unknown,\n): StepUpConfirmAnswer | undefined {\n\treturn intentOf(data) === 'confirm-step-up'\n\t\t? (data as StepUpConfirmAnswer)\n\t\t: undefined;\n}\n",
|
|
6
|
-
"import type { StepUpVia } from '../../model/second-factor';\nimport type { StepUpProps } from '../../model/step-up-props';\nimport type { StepUpDialogPhase } from './phase';\nimport type { useStepUp } from './use-step-up';\n\n/** What a step-up dialog is given: the phase to show and the handlers. */\nexport interface StepUpDialogProps {\n\topen: boolean;\n\tphase: StepUpDialogPhase;\n\t/** How the code is confirmed, in every phase once a challenge was granted (`undefined` before). */\n\tvia: StepUpVia | undefined;\n\t/** The words of the code form (`stepUpProps`). */\n\tprops: StepUpProps;\n\t/** The last refusal worded by the server. */\n\tmessage: string | undefined;\n\tbusy: boolean;\n\t/** The code was accepted and the action is being sent again. */\n\tconfirming: boolean;\n\tonCancel(): void;\n\t/** \"Retry\" after a refused request or an over challenge. */\n\tonRetry(): void;\n\tonSubmit(code: string): void;\n\t/** \"Resend code\", for a mailed code only. */\n\tonResend: (() => void) | undefined;\n}\n\n/** The dialog's props out of a step-up: plain, so any host can build them. */\nexport function dialogPropsOf(\n\tstepUp: ReturnType<typeof useStepUp>,\n\topen: boolean,\n\tcancel: () => void,\n): StepUpDialogProps {\n\treturn {\n\t\topen,\n\t\tphase: stepUp.phase,\n\t\tvia: stepUp.via,\n\t\tprops: stepUp.props,\n\t\tmessage: stepUp.message,\n\t\tbusy: stepUp.busy,\n\t\tconfirming: stepUp.phase === 'confirmed',\n\t\tonCancel: cancel,\n\t\tonRetry: () => stepUp.start(),\n\t\tonSubmit: (code) => stepUp.submit(code),\n\t\tonResend: stepUp.via === 'email' ? () => stepUp.start() : undefined,\n\t};\n}\n",
|
|
7
|
-
"import type { StepUpState } from '../../model/step-up';\n\n/** Where a step-up is, as a dialog shows it. */\nexport type StepUpDialogPhase =\n\t| 'asking'\n\t| 'failed'\n\t| 'code'\n\t| 'expired'\n\t| 'confirmed';\n\n/** The phase a reducer state is in: what the dialog shows. */\nexport function stepUpDialogPhase(state: StepUpState): StepUpDialogPhase {\n\tif (state.phase === 'done') return 'confirmed';\n\tif (state.challenge) return 'code';\n\tif (state.error === 'expired') return 'expired';\n\treturn state.error ? 'failed' : 'asking';\n}\n\n/** Whether an answer is a refusal asking for a step-up (`stepUp` set, not ok). */\nexport function asksStepUp(answer: unknown): boolean {\n\tif (typeof answer !== 'object' || answer === null) return false;\n\tconst a = answer as { ok?: unknown; stepUp?: unknown };\n\treturn !a.ok && Boolean(a.stepUp);\n}\n",
|
|
8
|
-
"import { useEffect, useReducer, useRef } from 'react';\nimport type { StepUpVia } from '../../model/second-factor';\nimport { INITIAL_STEP_UP, stepUpReducer } from '../../model/step-up';\nimport type {\n\tStepUpConfirmAnswer,\n\tStepUpRequestAnswer,\n} from '../../model/step-up-answers';\nimport { stepUpProps } from '../../model/step-up-props';\nimport { stepUpDialogPhase } from './phase';\n\n/**\n * One step-up intent as the host posts it: its own `post` (so the fields stay\n * typed by the host's action), the settled answer it got since it mounted\n * (`undefined` before), and whether it is in flight. `reset` forgets the\n * answer (a fetcher's `reset`).\n */\nexport interface StepUpChannel<Fields, Answer> {\n\tpost(fields: Fields): void;\n\treadonly answer: Answer | undefined;\n\treadonly busy: boolean;\n\treset?(): void;\n}\n\nexport interface StepUpOptions {\n\t/** Posts `{ intent: 'request-step-up' }`, to the route that serves `stepUpSteps().route`. */\n\trequest: StepUpChannel<Record<string, never>, StepUpRequestAnswer>;\n\t/** Posts `{ intent: 'confirm-step-up', challenge, code, via }`. */\n\tconfirm: StepUpChannel<\n\t\t{ challenge: string; code: string; via: StepUpVia | undefined },\n\t\tStepUpConfirmAnswer\n\t>;\n\t/** Runs once the API accepted the code. */\n\tonConfirmed(): void;\n}\n\n/**\n * The step-up as the page lives it, on `stepUpReducer`: `start` asks for a\n * challenge, `submit` posts the code, and once it is accepted `onConfirmed`\n * runs. The challenge lives in the reducer's state and the channels' answers\n * only; `release` forgets everything, and an answer that lands after it (a\n * request or a code in flight when the host closed) is ignored.\n */\nexport function useStepUp({ request, confirm, onConfirmed }: StepUpOptions) {\n\tconst [state, dispatch] = useReducer(stepUpReducer, INITIAL_STEP_UP);\n\tconst confirmedRun = useRef(onConfirmed);\n\tconfirmedRun.current = onConfirmed;\n\t// True from `start` to `release`: only then are answers this step-up's.\n\tconst live = useRef(false);\n\n\t// Each settled answer once, even one worded as the last (a new object).\n\tconst handledRequest = useRef(request.answer);\n\tconst handledConfirm = useRef(confirm.answer);\n\tuseEffect(() => {\n\t\tconst answer = request.answer;\n\t\tif (!answer || handledRequest.current === answer) return;\n\t\thandledRequest.current = answer;\n\t\tif (!live.current) return;\n\t\tdispatch({ type: 'requested', answer });\n\t}, [request.answer]);\n\tuseEffect(() => {\n\t\tconst answer = confirm.answer;\n\t\tif (!answer || handledConfirm.current === answer) return;\n\t\thandledConfirm.current = answer;\n\t\tif (!live.current) return;\n\t\tdispatch({ type: 'confirmed', answer });\n\t\tif (answer.ok) confirmedRun.current();\n\t}, [confirm.answer]);\n\n\treturn {\n\t\tphase: stepUpDialogPhase(state),\n\t\t/** How the challenge held (or the last one) is confirmed. */\n\t\tvia: state.via,\n\t\t/** What a dialog words its texts from (`stepUpProps`). */\n\t\tprops: stepUpProps(state),\n\t\t/** The last refusal worded by the server: the fallback of the dialog's own words. */\n\t\tmessage: state.message,\n\t\t/** A request or a code is in flight. */\n\t\tbusy: request.busy || confirm.busy,\n\t\t/** Asks for a challenge: the first, or \"Resend code\" / \"New code\". */\n\t\tstart() {\n\t\t\tlive.current = true;\n\t\t\tdispatch({ type: 'start' });\n\t\t\trequest.post({});\n\t\t},\n\t\t/** Posts the code for the challenge held. */\n\t\tsubmit(code: string) {\n\t\t\tif (!state.challenge) return;\n\t\t\tdispatch({ type: 'submit' });\n\t\t\tconfirm.post({ challenge: state.challenge, code, via: state.via });\n\t\t},\n\t\t/** Forgets the challenge, the answers and the errors. */\n\t\trelease() {\n\t\t\tlive.current = false;\n\t\t\trequest.reset?.();\n\t\t\tconfirm.reset?.();\n\t\t\thandledRequest.current = request.answer;\n\t\t\thandledConfirm.current = confirm.answer;\n\t\t\tdispatch({ type: 'release' });\n\t\t},\n\t};\n}\n",
|
|
9
|
-
"import { useEffect, useRef, useState } from 'react';\nimport { dialogPropsOf } from './dialog-props';\nimport { asksStepUp } from './phase';\nimport { type StepUpOptions, useStepUp } from './use-step-up';\n\nexport interface StepUpReplayOptions<Action>\n\textends Pick<StepUpOptions, 'request' | 'confirm'> {\n\t/**\n\t * The settled answer of the sensitive action. A refusal carrying `stepUp`\n\t * opens the step-up; the first answer after the replay closes it.\n\t */\n\tanswer: unknown;\n\t/** Sends the refused action again, once the session is confirmed. */\n\treplay(): void;\n\t/** The action that was refused, for `spendsCode`. */\n\taction?: Action | undefined;\n\t/**\n\t * Whether the action carries a one-time code that a step-up by the\n\t * second factor just spent. Such an action is not replayed: the dialog\n\t * closes and `confirmedNotice` is true (\"confirmed, type a new code\").\n\t * The notice clears when `action` becomes another value (a new action was\n\t * posted), at once and not at its answer, so pass the action as you post\n\t * it, a new object for each post.\n\t */\n\tspendsCode?(action: Action): boolean;\n}\n\n/**\n * The step-up a sensitive action asks for: an answer carrying `stepUp` opens\n * it; once the session is confirmed the same action is sent again by itself\n * (`replay`), except an action that `spendsCode` after a step-up by the\n * second factor, whose code was just spent: the user types the next one\n * (`confirmedNotice`). The dialog stays, saying so, until the replayed\n * action is answered; if that is refused for a step-up again, it asks again.\n * If `replay` posts nothing, no answer ends that wait: call `cancel()`.\n * Generic over the host's `post`s, so each intent keeps its typed fields.\n *\n * @example\n * const { open, dialogProps, cancel, confirmedNotice } = useStepUpReplay({\n * \tanswer: fetcher.data,\n * \treplay: () => post(lastAction.current),\n * \trequest: { post: () => stepUpPost({ intent: 'request-step-up' }), answer, busy },\n * \tconfirm: { post: (f) => stepUpPost({ intent: 'confirm-step-up', ...f }), answer, busy },\n * });\n */\nexport function useStepUpReplay<Action = never>({\n\tanswer,\n\treplay,\n\taction,\n\tspendsCode,\n\trequest,\n\tconfirm,\n}: StepUpReplayOptions<Action>) {\n\tconst [open, setOpen] = useState(false);\n\tconst [confirmedNotice, setConfirmedNotice] = useState(false);\n\tconst isOpen = useRef(false);\n\tconst replaying = useRef(false);\n\tconst latest = useRef({ replay, action, spendsCode });\n\tlatest.current = { replay, action, spendsCode };\n\n\tfunction show(value: boolean) {\n\t\tisOpen.current = value;\n\t\tsetOpen(value);\n\t}\n\n\t// The notice belongs to the action it was shown for: another action\n\t// posted clears it at once, not at that action's answer.\n\tconst noticeFor = useRef(action);\n\tuseEffect(() => {\n\t\tif (noticeFor.current === action) return;\n\t\tnoticeFor.current = action;\n\t\tsetConfirmedNotice(false);\n\t}, [action]);\n\n\tconst stepUp = useStepUp({\n\t\trequest,\n\t\tconfirm,\n\t\tonConfirmed() {\n\t\t\t// A code accepted after the host cancelled replays nothing.\n\t\t\tif (!isOpen.current) return;\n\t\t\tconst { replay, action, spendsCode } = latest.current;\n\t\t\tconst spent =\n\t\t\t\tstepUpRef.current.via === 'secondFactor' &&\n\t\t\t\taction !== undefined &&\n\t\t\t\tspendsCode?.(action) === true;\n\t\t\tif (spent) {\n\t\t\t\tstepUpRef.current.release();\n\t\t\t\tshow(false);\n\t\t\t\tsetConfirmedNotice(true);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// The dialog stays, saying so, until the action is answered.\n\t\t\treplaying.current = true;\n\t\t\treplay();\n\t\t},\n\t});\n\tconst stepUpRef = useRef(stepUp);\n\tstepUpRef.current = stepUp;\n\n\t// Each answer once: a refusal asking for a step-up opens it; the answer\n\t// of the replayed action closes it (a new step-up opens it again).\n\tconst handled = useRef(answer);\n\tuseEffect(() => {\n\t\tif (!answer || handled.current === answer) return;\n\t\thandled.current = answer;\n\t\tif (asksStepUp(answer)) {\n\t\t\treplaying.current = false;\n\t\t\tsetConfirmedNotice(false);\n\t\t\tshow(true);\n\t\t\tstepUpRef.current.start();\n\t\t\treturn;\n\t\t}\n\t\tsetConfirmedNotice(false);\n\t\tif (replaying.current) {\n\t\t\treplaying.current = false;\n\t\t\tshow(false);\n\t\t\tstepUpRef.current.release();\n\t\t}\n\t}, [answer]);\n\n\tfunction cancel() {\n\t\treplaying.current = false;\n\t\tstepUp.release();\n\t\tshow(false);\n\t\tsetConfirmedNotice(false);\n\t}\n\n\treturn {\n\t\topen,\n\t\t/** True after a step-up by the second factor spent the action's own code. */\n\t\tconfirmedNotice,\n\t\tdialogProps: dialogPropsOf(stepUp, open, cancel),\n\t\t/** Closes the dialog without sending the action again. */\n\t\tcancel,\n\t\tstepUp,\n\t};\n}\n",
|
|
10
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",
|
|
11
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",
|
|
12
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"
|
|
13
8
|
],
|
|
14
|
-
"mappings": "
|
|
15
|
-
"debugId": "
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "116C793B397FA8DD64756E2164756E21",
|
|
16
11
|
"names": []
|
|
17
12
|
}
|
package/docs/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The [package README](../README.md) is the short version. This folder is
|
|
4
4
|
the long one: the setup, the Vite plugin and its default server, the build for Bun,
|
|
5
5
|
customising the server, the typed context, the client's files, making the app
|
|
6
|
-
installable and usable offline, wording an API's refusals, the BFF session, a GraphQL subscription relayed to the browser (`/subscription`), and what to do with each message.
|
|
6
|
+
installable and usable offline, wording an API's refusals, the BFF session, a GraphQL subscription relayed to the browser (`/subscription`), the consent screen of an OAuth server (`/oauth`), and what to do with each message.
|
|
7
7
|
|
|
8
8
|
| Page | Read it when |
|
|
9
9
|
| --- | --- |
|
|
@@ -23,6 +23,7 @@ installable and usable offline, wording an API's refusals, the BFF session, a Gr
|
|
|
23
23
|
| [Links through React Router](guide/link.md) | `RouterLink`, React Router's `Link` as the link component of a UI library that hands `href` and spreads anchor props; `@alxia/react-router/link` |
|
|
24
24
|
| [A fetcher's fresh answer](guide/fetcher.md) | `useFetcherAnswer`, `useFetcherPromise` (one submission's answer as a Promise), a handler called once per new answer of a keyed fetcher, not for the data it held at mount; `useDebouncedFetcher`, a resource route loaded for what is typed (pause, minimum length, stale answer kept, retry); `useVisibleInterval` and `useVisibleRevalidation`, a callback or a revalidation on an interval while the page is visible; `@alxia/react-router/fetcher` |
|
|
25
25
|
| [Lists whose state is the URL](guide/lists.md) | a search and filters in the address bar that drop the cursor (`useSearchChange`), a skeleton while the same page reloads (`useSameRouteLoading`), and a load-more button that appends pages by cursor and keeps the rows when one fails (`usePagedRows`); `@alxia/react-router/lists` |
|
|
26
|
+
| [The consent screen of an OAuth server](guide/oauth.md) | letting a viewer allow or deny an OAuth client: `oauthConsent` (the route's loader and action over the janus-oauth GraphQL, passed in as functions), `useConsent` (the decision, the step-up replay, the callback) and the pure helpers; `@alxia/react-router/oauth` |
|
|
26
27
|
| [Subscriptions for a BFF](guide/subscriptions.md) | showing a GraphQL subscription in the browser without giving it the API's token or a way to write a document: a resource route with a map of operation names to the server's documents, the same-site check, the bearer from the session, extra upstream headers, a heartbeat, abort propagation, size limits and a 429 passed through with `retryAfter` (`@alxia/react-router/subscription`) |
|
|
27
28
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a page streams, hydrates or reads its context unlike you expected |
|
|
28
29
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# The consent screen of an OAuth server
|
|
2
|
+
|
|
3
|
+
An app that is the OAuth server of other apps (`@nxgt/janus-oauth`) asks its
|
|
4
|
+
viewer, on one screen, whether a client may act for them. The API serves the
|
|
5
|
+
three calls (`oauthRequest`, `approveOAuthRequest`, `denyOAuthRequest` of
|
|
6
|
+
`@nxgt/janus-oauth/graphql`); the decision logic around them is the same in
|
|
7
|
+
every app, so `@alxia/react-router` holds it. The screen itself (the card, the
|
|
8
|
+
words, the scopes' labels) stays yours.
|
|
9
|
+
|
|
10
|
+
Two subpaths, so a page never loads the server side:
|
|
11
|
+
|
|
12
|
+
| subpath | holds | imports |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| `@alxia/react-router/oauth` | `oauthConsent`, `consentShouldRevalidate`, `requestIdOf`, `followableRedirect`, the `ConsentAnswer` type | `react-router` (its only package) |
|
|
15
|
+
| `@alxia/react-router/oauth/react` | `useConsent` | `react`, `react-router` |
|
|
16
|
+
|
|
17
|
+
## The route's server side
|
|
18
|
+
|
|
19
|
+
`oauthConsent` takes your GraphQL calls and returns the route's `loader` and
|
|
20
|
+
`action`. It does not import a GraphQL client: you pass the three operations
|
|
21
|
+
as functions, typed by your own generated documents.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// app/routes/oauth/consent.tsx
|
|
25
|
+
import { alxiaOf } from '@alxia/react-router';
|
|
26
|
+
import { consentShouldRevalidate, oauthConsent } from '@alxia/react-router/oauth';
|
|
27
|
+
import type { Route } from './+types/consent';
|
|
28
|
+
|
|
29
|
+
const consent = oauthConsent({
|
|
30
|
+
json, // your jsonActions()
|
|
31
|
+
errors, // your createApiErrors(...)
|
|
32
|
+
// a guest is redirected to sign in and brought back to this address
|
|
33
|
+
viewer: (request) => requireViewer(request),
|
|
34
|
+
caller: (request) => sessionCaller(request),
|
|
35
|
+
find: async (id, caller) => (await execute(OAuthRequestQuery, { id }, caller)).oauthRequest,
|
|
36
|
+
approve: async ({ id, scopes }, caller) =>
|
|
37
|
+
(await execute(ApproveOAuthRequestMutation, { id, scopes }, caller)).approveOAuthRequest,
|
|
38
|
+
deny: async ({ id }, caller) =>
|
|
39
|
+
(await execute(DenyOAuthRequestMutation, { id }, caller)).denyOAuthRequest,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const loader = ({ request }: Route.LoaderArgs) => consent.loader(request);
|
|
43
|
+
export const action = ({ context, request }: Route.ActionArgs) =>
|
|
44
|
+
consent.action(request, alxiaOf(context).language);
|
|
45
|
+
export const shouldRevalidate = consentShouldRevalidate;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
What each half does:
|
|
49
|
+
|
|
50
|
+
- **`loader(request)`** runs `viewer` first (a thrown redirect sends a guest to
|
|
51
|
+
sign in and back, request id included), reads the request id from
|
|
52
|
+
`?request_id=` (else `?request=`), asks `find`, and answers
|
|
53
|
+
`{ pending, viewer }` with `Cache-Control: no-store` and the headers `viewer`
|
|
54
|
+
gave (a refreshed cookie). No id, a `null` answer, or an API refusal that
|
|
55
|
+
`notFound` accepts (a 404 by default) answers `pending: null` with a 404
|
|
56
|
+
status: render your expired state. Any other failure is thrown.
|
|
57
|
+
- **`action(request, language)`** takes the JSON `{ intent: 'approve' | 'deny',
|
|
58
|
+
id, scopes? }` the page posts (`json.requireJsonPost` and `readJson` run
|
|
59
|
+
first: another site is a 403, a bad body a 400), calls `approve` or `deny`,
|
|
60
|
+
and answers a `ConsentAnswer`: `{ ok: true, intent, id, redirectTo }` with the
|
|
61
|
+
callback address (only `http:` and `https:` are followed, any other is
|
|
62
|
+
refused), or `{ ok: false, stepUp, expired, error }`. A session not confirmed
|
|
63
|
+
lately (the API's `STEP_UP_REQUIRED`) is `stepUp: true` with no message; a
|
|
64
|
+
request the API no longer knows is `expired: true`; any other refusal is the
|
|
65
|
+
worded `error` with its HTTP status.
|
|
66
|
+
- The action does not call `viewer`: the session reaches the API through `caller`, and a refreshed cookie is set by the loader's answer only.
|
|
67
|
+
- **`consentShouldRevalidate`** keeps the loader from running after a decision:
|
|
68
|
+
the decision spends the request, so a reload would answer "expired" while the
|
|
69
|
+
browser is on its way to the callback.
|
|
70
|
+
|
|
71
|
+
Options that differ from the defaults: `notFound(error)` (which refusals mean
|
|
72
|
+
"unknown request", a 404 by default) and `stepUpRequired` (the API's code,
|
|
73
|
+
`STEP_UP_REQUIRED` by default).
|
|
74
|
+
|
|
75
|
+
## The page's hook
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { useConsent } from '@alxia/react-router/oauth/react';
|
|
79
|
+
|
|
80
|
+
export default function Consent({ loaderData }: Route.ComponentProps) {
|
|
81
|
+
const { pending: request } = loaderData;
|
|
82
|
+
if (!request) return <Expired />;
|
|
83
|
+
const { approve, deny, pending, expired, error, dialogProps } = useConsent(request.id, {
|
|
84
|
+
request: stepUpRequestChannel, // the step-up channels of useStepUpReplay
|
|
85
|
+
confirm: stepUpConfirmChannel,
|
|
86
|
+
});
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
<Card request={request} pending={pending} error={error} onAllow={approve} onDeny={deny} />
|
|
90
|
+
<StepUpDialog {...dialogProps} />
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`useConsent(requestId, { request, confirm, go?, action? })` posts the decision
|
|
97
|
+
as JSON to the page's action (or `action`), and returns:
|
|
98
|
+
|
|
99
|
+
- `approve()` and `deny()`: ignored while a decision is in flight or the
|
|
100
|
+
browser is leaving.
|
|
101
|
+
- `pending`: the decision in flight (`'approve'` or `'deny'`, `true` while a
|
|
102
|
+
step-up is open before one was chosen), otherwise `false`. After a decision
|
|
103
|
+
lands it stays set until the page is left, since the browser follows the
|
|
104
|
+
callback.
|
|
105
|
+
- `expired`: the API no longer knows the request.
|
|
106
|
+
- `error`: any other refusal's message; cleared by the next decision.
|
|
107
|
+
- `dialogProps`: the step-up dialog's props. `request` and `confirm` are the
|
|
108
|
+
two channels `useStepUpReplay` takes (see the
|
|
109
|
+
[session guide](session.md#the-pages-hooks)); a refused decision opens the
|
|
110
|
+
dialog and is posted again by itself once the session is confirmed. A
|
|
111
|
+
decision carries no one-time code, so nothing is spent. Cancelling the dialog
|
|
112
|
+
frees the card.
|
|
113
|
+
|
|
114
|
+
`go(url)` is a hard navigation by default (`window.location.assign`): the
|
|
115
|
+
callback is another site's, so a client-side navigation would not leave. Pass
|
|
116
|
+
your own to watch it in a test.
|
|
117
|
+
|
|
118
|
+
## The pure helpers
|
|
119
|
+
|
|
120
|
+
- `requestIdOf(url: URL)`: the request an authorization link names, trimmed;
|
|
121
|
+
`undefined` when empty or absent.
|
|
122
|
+
- `followableRedirect(value)`: the normalised address when it is `http:` or
|
|
123
|
+
`https:` (a loopback callback is `http:`), else `undefined`. Use it for any
|
|
124
|
+
redirect an API hands you.
|
|
125
|
+
|
|
126
|
+
## Testing
|
|
127
|
+
|
|
128
|
+
`oauthConsent` is plain functions over `Request`: call `loader` and `action`
|
|
129
|
+
with a `Request` and fake `find`, `approve` and `deny`. Post a body with
|
|
130
|
+
`content-type: application/json` and an `Origin` of the app. For the hook,
|
|
131
|
+
mount a memory router whose route `action` answers `ConsentAnswer`s, as the
|
|
132
|
+
package's own spec does.
|
package/docs/roadmap.md
CHANGED
|
@@ -35,6 +35,14 @@ number on it. Every release, with each change it made, is in
|
|
|
35
35
|
over on a new first page, keep the rows when a page fails). See the
|
|
36
36
|
[lists guide](guide/lists.md).
|
|
37
37
|
|
|
38
|
+
- **`@alxia/react-router/oauth`: the consent screen of an OAuth server.**
|
|
39
|
+
`oauthConsent` gives a route's `loader` and `action` over the three
|
|
40
|
+
janus-oauth GraphQL calls (passed in as functions): the sign-in round trip,
|
|
41
|
+
`no-store`, the expired state, the step-up refusal and a callback address
|
|
42
|
+
that is only followed when it is http(s). `useConsent`
|
|
43
|
+
(`/oauth/react`) posts the decision, replays it after the shared step-up
|
|
44
|
+
and follows the callback. See the [OAuth guide](guide/oauth.md).
|
|
45
|
+
|
|
38
46
|
## Next
|
|
39
47
|
|
|
40
48
|
Nothing planned.
|
|
@@ -86,7 +94,7 @@ Nothing planned.
|
|
|
86
94
|
|
|
87
95
|
## Shipped
|
|
88
96
|
|
|
89
|
-
###
|
|
97
|
+
### 0.41.0
|
|
90
98
|
|
|
91
99
|
- **`useVisibleInterval` and `useVisibleRevalidation` on `/fetcher`.** A
|
|
92
100
|
callback, or a revalidation of the route's loaders, runs every N ms only
|
|
@@ -94,6 +102,8 @@ Nothing planned.
|
|
|
94
102
|
more than N ms. See [Fresh data while the page is
|
|
95
103
|
visible](guide/fetcher.md#fresh-data-while-the-page-is-visible-usevisibleintervalcallback-ms-options).
|
|
96
104
|
|
|
105
|
+
### 0.40.0
|
|
106
|
+
|
|
97
107
|
- **A delay-only global bucket for `createSignInThrottle`.** With
|
|
98
108
|
`globalMode: 'delay'`, `wait` answers per key only and `globalDelay()` gives
|
|
99
109
|
a bounded delay (capped at `maxDelayMs`) that grows with the failures past
|
|
@@ -101,6 +111,8 @@ Nothing planned.
|
|
|
101
111
|
real user away. Refusing stays the default. See [A delay-only global
|
|
102
112
|
bucket](guide/session.md#a-delay-only-global-bucket).
|
|
103
113
|
|
|
114
|
+
### 0.39.0
|
|
115
|
+
|
|
104
116
|
- **`createSignInThrottle` on `/session`.** A failed-attempt throttle for a
|
|
105
117
|
password sign-in: `wait`, `fail` and `clear` per key within a window, a
|
|
106
118
|
bounded number of keys, a global bucket that delays rather than locks, and
|
|
@@ -108,11 +120,15 @@ Nothing planned.
|
|
|
108
120
|
past a short queue. Per process. See [A sign-in
|
|
109
121
|
throttle](guide/session.md#a-sign-in-throttle).
|
|
110
122
|
|
|
123
|
+
### 0.38.0
|
|
124
|
+
|
|
111
125
|
- **`useFetcherPromise` on `/fetcher`.** `run(submit)` returns a Promise
|
|
112
126
|
settled by the fetcher's next new answer, rejected with an `Error` when your
|
|
113
127
|
`refusal(answer)` returns a message. See [One submission's answer as a
|
|
114
128
|
Promise](guide/fetcher.md#one-submissions-answer-as-a-promise-usefetcherpromisefetcher-options).
|
|
115
129
|
|
|
130
|
+
### 0.35.0
|
|
131
|
+
|
|
116
132
|
- **A health probe in `createServer`.** `health: '/health'` or `{ path,
|
|
117
133
|
check, timeout }` declares `@alxia/core`'s liveness probe, in text, before
|
|
118
134
|
every middleware. See [A health
|
package/docs/troubleshooting.md
CHANGED
|
@@ -3105,3 +3105,42 @@ action, a guard that returned early) produces no answer, so nothing ends it.
|
|
|
3105
3105
|
|
|
3106
3106
|
**Fix:** make `replay` post the refused action, or call `cancel()` when it has
|
|
3107
3107
|
nothing to send.
|
|
3108
|
+
|
|
3109
|
+
### The consent page shows "expired" right after a decision
|
|
3110
|
+
|
|
3111
|
+
**When:** a viewer allows or denies a client and, before the browser reaches the client's callback, the page replaces itself with the expired state.
|
|
3112
|
+
|
|
3113
|
+
**Why:** the decision spends the request, so the route's loader reloaded after the action's submission and the API no longer knew the request.
|
|
3114
|
+
|
|
3115
|
+
**Fix:** export `consentShouldRevalidate` from `@alxia/react-router/oauth` as the route's `shouldRevalidate`:
|
|
3116
|
+
|
|
3117
|
+
```ts
|
|
3118
|
+
import { consentShouldRevalidate } from '@alxia/react-router/oauth';
|
|
3119
|
+
|
|
3120
|
+
export const shouldRevalidate = consentShouldRevalidate;
|
|
3121
|
+
```
|
|
3122
|
+
|
|
3123
|
+
### A decision answers `ok: false` with an empty `error`
|
|
3124
|
+
|
|
3125
|
+
**When:** `useConsent` leaves `error` unset and the card does nothing after a click.
|
|
3126
|
+
|
|
3127
|
+
**Why:** an empty `error` goes with `stepUp: true` (the session needs confirming: the dialog opens, and the decision is posted again once confirmed) or `expired: true` (the API no longer knows the request). If neither dialog nor expired state shows, the page did not pass `dialogProps` to its step-up dialog or did not render `expired`.
|
|
3128
|
+
|
|
3129
|
+
**Fix:** render `<StepUpDialog {...dialogProps} />` and the `expired` state beside the card.
|
|
3130
|
+
|
|
3131
|
+
### `oauthConsent` treats a failing `find` as an expired request
|
|
3132
|
+
|
|
3133
|
+
**When:** every failure of `find` renders the expired page, or a real "unknown request" is thrown to the error page.
|
|
3134
|
+
|
|
3135
|
+
**Why:** only an API refusal that `notFound` accepts (a 404 by default) means "unknown request"; anything else is thrown.
|
|
3136
|
+
|
|
3137
|
+
**Fix:** pass `notFound: (error) => error.code === '<your API's code>'` when your API words it with another status.
|
|
3138
|
+
|
|
3139
|
+
### The decision fails with "The API answered a redirect that is not an address"
|
|
3140
|
+
|
|
3141
|
+
**When:** approving or denying throws instead of sending the browser to the client's callback.
|
|
3142
|
+
|
|
3143
|
+
**Why:** `oauthConsent` follows a callback only when it is an `http:` or `https:` address (a `javascript:` or `data:` one would run in your page). The `redirectTo` your `approve` or `deny` returned was something else, or not a string.
|
|
3144
|
+
|
|
3145
|
+
**Fix:** return the API's `approveOAuthRequest` / `denyOAuthRequest` object as is (`{ redirectTo }`), and check the client's registered redirect URI is an http(s) address.
|
|
3146
|
+
|
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`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
|
|
3
|
+
"version": "0.42.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`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`), the consent screen of an OAuth server: a route's loader and action (`/oauth`) and the decision hook (`/oauth/react`)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -82,6 +82,16 @@
|
|
|
82
82
|
"import": "./dist/session/model/index.js",
|
|
83
83
|
"default": "./dist/session/model/index.js"
|
|
84
84
|
},
|
|
85
|
+
"./oauth": {
|
|
86
|
+
"types": "./dist/oauth/index.d.ts",
|
|
87
|
+
"import": "./dist/oauth/index.js",
|
|
88
|
+
"default": "./dist/oauth/index.js"
|
|
89
|
+
},
|
|
90
|
+
"./oauth/react": {
|
|
91
|
+
"types": "./dist/oauth/react/index.d.ts",
|
|
92
|
+
"import": "./dist/oauth/react/index.js",
|
|
93
|
+
"default": "./dist/oauth/react/index.js"
|
|
94
|
+
},
|
|
85
95
|
"./lists": {
|
|
86
96
|
"types": "./dist/lists/index.d.ts",
|
|
87
97
|
"import": "./dist/lists/index.js",
|
|
@@ -131,6 +141,8 @@
|
|
|
131
141
|
"src/session/react/index.ts",
|
|
132
142
|
"src/subscription/index.ts",
|
|
133
143
|
"src/lists/index.ts",
|
|
144
|
+
"src/oauth/index.ts",
|
|
145
|
+
"src/oauth/react/index.ts",
|
|
134
146
|
"src/link/index.ts",
|
|
135
147
|
"src/fetcher/index.ts",
|
|
136
148
|
"src/document/index.ts",
|