@entifix/next-shell 0.1.0-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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/back-office/account-menu.d.ts +29 -0
- package/dist/lib/back-office/account-menu.d.ts.map +1 -0
- package/dist/lib/back-office/account-menu.js +70 -0
- package/dist/lib/back-office/account-menu.js.map +1 -0
- package/dist/lib/back-office/back-office-shell.d.ts +50 -0
- package/dist/lib/back-office/back-office-shell.d.ts.map +1 -0
- package/dist/lib/back-office/back-office-shell.js +128 -0
- package/dist/lib/back-office/back-office-shell.js.map +1 -0
- package/dist/lib/back-office/breadcrumbs.d.ts +16 -0
- package/dist/lib/back-office/breadcrumbs.d.ts.map +1 -0
- package/dist/lib/back-office/breadcrumbs.js +53 -0
- package/dist/lib/back-office/breadcrumbs.js.map +1 -0
- package/dist/lib/back-office/group-by-screen-type.d.ts +21 -0
- package/dist/lib/back-office/group-by-screen-type.d.ts.map +1 -0
- package/dist/lib/back-office/group-by-screen-type.js +39 -0
- package/dist/lib/back-office/group-by-screen-type.js.map +1 -0
- package/dist/lib/back-office/index.d.ts +6 -0
- package/dist/lib/back-office/index.d.ts.map +1 -0
- package/dist/lib/back-office/index.js +7 -0
- package/dist/lib/back-office/index.js.map +1 -0
- package/dist/lib/back-office/nav.d.ts +39 -0
- package/dist/lib/back-office/nav.d.ts.map +1 -0
- package/dist/lib/back-office/nav.js +3 -0
- package/dist/lib/back-office/nav.js.map +1 -0
- package/dist/lib/back-office/sidebar-nav.d.ts +26 -0
- package/dist/lib/back-office/sidebar-nav.d.ts.map +1 -0
- package/dist/lib/back-office/sidebar-nav.js +181 -0
- package/dist/lib/back-office/sidebar-nav.js.map +1 -0
- package/dist/lib/command-palette/command-palette-host.d.ts +41 -0
- package/dist/lib/command-palette/command-palette-host.d.ts.map +1 -0
- package/dist/lib/command-palette/command-palette-host.js +196 -0
- package/dist/lib/command-palette/command-palette-host.js.map +1 -0
- package/dist/lib/command-palette/index.d.ts +9 -0
- package/dist/lib/command-palette/index.d.ts.map +1 -0
- package/dist/lib/command-palette/index.js +10 -0
- package/dist/lib/command-palette/index.js.map +1 -0
- package/dist/lib/command-palette/palette-command.d.ts +32 -0
- package/dist/lib/command-palette/palette-command.d.ts.map +1 -0
- package/dist/lib/command-palette/palette-command.js +19 -0
- package/dist/lib/command-palette/palette-command.js.map +1 -0
- package/dist/lib/command-palette/use-command-recency.d.ts +28 -0
- package/dist/lib/command-palette/use-command-recency.d.ts.map +1 -0
- package/dist/lib/command-palette/use-command-recency.js +67 -0
- package/dist/lib/command-palette/use-command-recency.js.map +1 -0
- package/dist/lib/command-palette/use-command-source.d.ts +17 -0
- package/dist/lib/command-palette/use-command-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-command-source.js +70 -0
- package/dist/lib/command-palette/use-command-source.js.map +1 -0
- package/dist/lib/command-palette/use-nav-source.d.ts +21 -0
- package/dist/lib/command-palette/use-nav-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-nav-source.js +78 -0
- package/dist/lib/command-palette/use-nav-source.js.map +1 -0
- package/dist/lib/command-palette/use-records-source.d.ts +23 -0
- package/dist/lib/command-palette/use-records-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-records-source.js +199 -0
- package/dist/lib/command-palette/use-records-source.js.map +1 -0
- package/dist/lib/command-palette/use-tabs-source.d.ts +20 -0
- package/dist/lib/command-palette/use-tabs-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-tabs-source.js +52 -0
- package/dist/lib/command-palette/use-tabs-source.js.map +1 -0
- package/dist/lib/command-palette/use-use-cases-source.d.ts +22 -0
- package/dist/lib/command-palette/use-use-cases-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-use-cases-source.js +93 -0
- package/dist/lib/command-palette/use-use-cases-source.js.map +1 -0
- package/dist/lib/config/config-route.d.ts +51 -0
- package/dist/lib/config/config-route.d.ts.map +1 -0
- package/dist/lib/config/config-route.js +65 -0
- package/dist/lib/config/config-route.js.map +1 -0
- package/dist/lib/config/service-proxy-route.d.ts +28 -0
- package/dist/lib/config/service-proxy-route.d.ts.map +1 -0
- package/dist/lib/config/service-proxy-route.js +125 -0
- package/dist/lib/config/service-proxy-route.js.map +1 -0
- package/dist/lib/config/service-token.d.ts +5 -0
- package/dist/lib/config/service-token.d.ts.map +1 -0
- package/dist/lib/config/service-token.js +18 -0
- package/dist/lib/config/service-token.js.map +1 -0
- package/dist/lib/crud/entity-crud-form.d.ts +49 -0
- package/dist/lib/crud/entity-crud-form.d.ts.map +1 -0
- package/dist/lib/crud/entity-crud-form.js +133 -0
- package/dist/lib/crud/entity-crud-form.js.map +1 -0
- package/dist/lib/crud/hand-off-write.d.ts +35 -0
- package/dist/lib/crud/hand-off-write.d.ts.map +1 -0
- package/dist/lib/crud/hand-off-write.js +27 -0
- package/dist/lib/crud/hand-off-write.js.map +1 -0
- package/dist/lib/crud/index.d.ts +9 -0
- package/dist/lib/crud/index.d.ts.map +1 -0
- package/dist/lib/crud/index.js +10 -0
- package/dist/lib/crud/index.js.map +1 -0
- package/dist/lib/crud/make-entity-crud.d.ts +28 -0
- package/dist/lib/crud/make-entity-crud.d.ts.map +1 -0
- package/dist/lib/crud/make-entity-crud.js +318 -0
- package/dist/lib/crud/make-entity-crud.js.map +1 -0
- package/dist/lib/crud/make-entity-crud.types.d.ts +237 -0
- package/dist/lib/crud/make-entity-crud.types.d.ts.map +1 -0
- package/dist/lib/crud/make-entity-crud.types.js +6 -0
- package/dist/lib/crud/make-entity-crud.types.js.map +1 -0
- package/dist/lib/crud/pending-notice.d.ts +31 -0
- package/dist/lib/crud/pending-notice.d.ts.map +1 -0
- package/dist/lib/crud/pending-notice.js +72 -0
- package/dist/lib/crud/pending-notice.js.map +1 -0
- package/dist/lib/crud/slug.d.ts +13 -0
- package/dist/lib/crud/slug.d.ts.map +1 -0
- package/dist/lib/crud/slug.js +13 -0
- package/dist/lib/crud/slug.js.map +1 -0
- package/dist/lib/crud/use-entity-affordances.d.ts +20 -0
- package/dist/lib/crud/use-entity-affordances.d.ts.map +1 -0
- package/dist/lib/crud/use-entity-affordances.js +22 -0
- package/dist/lib/crud/use-entity-affordances.js.map +1 -0
- package/dist/lib/crud/use-entity-bulk.d.ts +43 -0
- package/dist/lib/crud/use-entity-bulk.d.ts.map +1 -0
- package/dist/lib/crud/use-entity-bulk.js +106 -0
- package/dist/lib/crud/use-entity-bulk.js.map +1 -0
- package/dist/lib/crud/use-entity-link-sources.d.ts +25 -0
- package/dist/lib/crud/use-entity-link-sources.d.ts.map +1 -0
- package/dist/lib/crud/use-entity-link-sources.js +37 -0
- package/dist/lib/crud/use-entity-link-sources.js.map +1 -0
- package/dist/lib/health/health-routes.d.ts +37 -0
- package/dist/lib/health/health-routes.d.ts.map +1 -0
- package/dist/lib/health/health-routes.js +90 -0
- package/dist/lib/health/health-routes.js.map +1 -0
- package/dist/lib/i18n/catalog/en.d.ts +4 -0
- package/dist/lib/i18n/catalog/en.d.ts.map +1 -0
- package/dist/lib/i18n/catalog/en.js +347 -0
- package/dist/lib/i18n/catalog/en.js.map +1 -0
- package/dist/lib/i18n/catalog/es.d.ts +397 -0
- package/dist/lib/i18n/catalog/es.d.ts.map +1 -0
- package/dist/lib/i18n/catalog/es.js +407 -0
- package/dist/lib/i18n/catalog/es.js.map +1 -0
- package/dist/lib/i18n/catalog/index.d.ts +671 -0
- package/dist/lib/i18n/catalog/index.d.ts.map +1 -0
- package/dist/lib/i18n/catalog/index.js +28 -0
- package/dist/lib/i18n/catalog/index.js.map +1 -0
- package/dist/lib/i18n/index.d.ts +2 -0
- package/dist/lib/i18n/index.d.ts.map +1 -0
- package/dist/lib/i18n/index.js +3 -0
- package/dist/lib/i18n/index.js.map +1 -0
- package/dist/lib/i18n/locale-link.d.ts +23 -0
- package/dist/lib/i18n/locale-link.d.ts.map +1 -0
- package/dist/lib/i18n/locale-link.js +43 -0
- package/dist/lib/i18n/locale-link.js.map +1 -0
- package/dist/lib/i18n/spec-catalog.d.ts +2 -0
- package/dist/lib/i18n/spec-catalog.d.ts.map +1 -0
- package/dist/lib/i18n/spec-catalog.js +84 -0
- package/dist/lib/i18n/spec-catalog.js.map +1 -0
- package/dist/lib/search/record-search-route.d.ts +44 -0
- package/dist/lib/search/record-search-route.d.ts.map +1 -0
- package/dist/lib/search/record-search-route.js +158 -0
- package/dist/lib/search/record-search-route.js.map +1 -0
- package/dist/lib/search/record-search-source.d.ts +59 -0
- package/dist/lib/search/record-search-source.d.ts.map +1 -0
- package/dist/lib/search/record-search-source.js +118 -0
- package/dist/lib/search/record-search-source.js.map +1 -0
- package/dist/lib/search/record-search.types.d.ts +85 -0
- package/dist/lib/search/record-search.types.d.ts.map +1 -0
- package/dist/lib/search/record-search.types.js +29 -0
- package/dist/lib/search/record-search.types.js.map +1 -0
- package/dist/lib/search/search-records.d.ts +25 -0
- package/dist/lib/search/search-records.d.ts.map +1 -0
- package/dist/lib/search/search-records.js +36 -0
- package/dist/lib/search/search-records.js.map +1 -0
- package/dist/lib/session/account-links.d.ts +46 -0
- package/dist/lib/session/account-links.d.ts.map +1 -0
- package/dist/lib/session/account-links.js +41 -0
- package/dist/lib/session/account-links.js.map +1 -0
- package/dist/lib/session/bearer.d.ts +25 -0
- package/dist/lib/session/bearer.d.ts.map +1 -0
- package/dist/lib/session/bearer.js +31 -0
- package/dist/lib/session/bearer.js.map +1 -0
- package/dist/lib/session/cookies.d.ts +14 -0
- package/dist/lib/session/cookies.d.ts.map +1 -0
- package/dist/lib/session/cookies.js +33 -0
- package/dist/lib/session/cookies.js.map +1 -0
- package/dist/lib/session/device.d.ts +31 -0
- package/dist/lib/session/device.d.ts.map +1 -0
- package/dist/lib/session/device.js +80 -0
- package/dist/lib/session/device.js.map +1 -0
- package/dist/lib/session/index.d.ts +3 -0
- package/dist/lib/session/index.d.ts.map +1 -0
- package/dist/lib/session/index.js +12 -0
- package/dist/lib/session/index.js.map +1 -0
- package/dist/lib/session/refresh-route.d.ts +19 -0
- package/dist/lib/session/refresh-route.d.ts.map +1 -0
- package/dist/lib/session/refresh-route.js +85 -0
- package/dist/lib/session/refresh-route.js.map +1 -0
- package/dist/lib/session/session-keepalive.d.ts +52 -0
- package/dist/lib/session/session-keepalive.d.ts.map +1 -0
- package/dist/lib/session/session-keepalive.js +72 -0
- package/dist/lib/session/session-keepalive.js.map +1 -0
- package/dist/lib/session/use-session-refresh.d.ts +43 -0
- package/dist/lib/session/use-session-refresh.d.ts.map +1 -0
- package/dist/lib/session/use-session-refresh.js +120 -0
- package/dist/lib/session/use-session-refresh.js.map +1 -0
- package/dist/lib/wizard/index.d.ts +4 -0
- package/dist/lib/wizard/index.d.ts.map +1 -0
- package/dist/lib/wizard/index.js +5 -0
- package/dist/lib/wizard/index.js.map +1 -0
- package/dist/lib/wizard/use-wizard-draft.d.ts +22 -0
- package/dist/lib/wizard/use-wizard-draft.d.ts.map +1 -0
- package/dist/lib/wizard/use-wizard-draft.js +39 -0
- package/dist/lib/wizard/use-wizard-draft.js.map +1 -0
- package/dist/lib/wizard/use-wizard-step-param.d.ts +60 -0
- package/dist/lib/wizard/use-wizard-step-param.d.ts.map +1 -0
- package/dist/lib/wizard/use-wizard-step-param.js +94 -0
- package/dist/lib/wizard/use-wizard-step-param.js.map +1 -0
- package/dist/lib/wizard/wizard-tab-kind.d.ts +31 -0
- package/dist/lib/wizard/wizard-tab-kind.d.ts.map +1 -0
- package/dist/lib/wizard/wizard-tab-kind.js +33 -0
- package/dist/lib/wizard/wizard-tab-kind.js.map +1 -0
- package/dist/lib/workspace/drafts-state.d.ts +88 -0
- package/dist/lib/workspace/drafts-state.d.ts.map +1 -0
- package/dist/lib/workspace/drafts-state.js +128 -0
- package/dist/lib/workspace/drafts-state.js.map +1 -0
- package/dist/lib/workspace/entity-nav.d.ts +26 -0
- package/dist/lib/workspace/entity-nav.d.ts.map +1 -0
- package/dist/lib/workspace/entity-nav.js +56 -0
- package/dist/lib/workspace/entity-nav.js.map +1 -0
- package/dist/lib/workspace/entity-tab-kind.d.ts +51 -0
- package/dist/lib/workspace/entity-tab-kind.d.ts.map +1 -0
- package/dist/lib/workspace/entity-tab-kind.js +47 -0
- package/dist/lib/workspace/entity-tab-kind.js.map +1 -0
- package/dist/lib/workspace/idb-state-storage.d.ts +9 -0
- package/dist/lib/workspace/idb-state-storage.d.ts.map +1 -0
- package/dist/lib/workspace/idb-state-storage.js +32 -0
- package/dist/lib/workspace/idb-state-storage.js.map +1 -0
- package/dist/lib/workspace/index.d.ts +13 -0
- package/dist/lib/workspace/index.d.ts.map +1 -0
- package/dist/lib/workspace/index.js +14 -0
- package/dist/lib/workspace/index.js.map +1 -0
- package/dist/lib/workspace/pending-state.d.ts +84 -0
- package/dist/lib/workspace/pending-state.d.ts.map +1 -0
- package/dist/lib/workspace/pending-state.js +155 -0
- package/dist/lib/workspace/pending-state.js.map +1 -0
- package/dist/lib/workspace/pending-transactions.d.ts +44 -0
- package/dist/lib/workspace/pending-transactions.d.ts.map +1 -0
- package/dist/lib/workspace/pending-transactions.js +114 -0
- package/dist/lib/workspace/pending-transactions.js.map +1 -0
- package/dist/lib/workspace/tab-kind.d.ts +48 -0
- package/dist/lib/workspace/tab-kind.d.ts.map +1 -0
- package/dist/lib/workspace/tab-kind.js +47 -0
- package/dist/lib/workspace/tab-kind.js.map +1 -0
- package/dist/lib/workspace/tab-state.d.ts +36 -0
- package/dist/lib/workspace/tab-state.d.ts.map +1 -0
- package/dist/lib/workspace/tab-state.js +81 -0
- package/dist/lib/workspace/tab-state.js.map +1 -0
- package/dist/lib/workspace/tabs-state.d.ts +46 -0
- package/dist/lib/workspace/tabs-state.d.ts.map +1 -0
- package/dist/lib/workspace/tabs-state.js +56 -0
- package/dist/lib/workspace/tabs-state.js.map +1 -0
- package/dist/lib/workspace/use-entity-draft.d.ts +25 -0
- package/dist/lib/workspace/use-entity-draft.d.ts.map +1 -0
- package/dist/lib/workspace/use-entity-draft.js +38 -0
- package/dist/lib/workspace/use-entity-draft.js.map +1 -0
- package/dist/lib/workspace/workspace-scope.d.ts +51 -0
- package/dist/lib/workspace/workspace-scope.d.ts.map +1 -0
- package/dist/lib/workspace/workspace-scope.js +42 -0
- package/dist/lib/workspace/workspace-scope.js.map +1 -0
- package/dist/lib/workspace/workspace-shell.d.ts +33 -0
- package/dist/lib/workspace/workspace-shell.d.ts.map +1 -0
- package/dist/lib/workspace/workspace-shell.js +206 -0
- package/dist/lib/workspace/workspace-shell.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +41 -0
- package/dist/server.js.map +1 -0
- package/package.json +95 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/session/index.ts"],"sourcesContent":["// Client-safe session helpers only.\n//\n// The cookie writers, the refresh route handler AND the account-link builders\n// ship from `@entifix/next-shell/server` instead. The link builders are\n// pure functions, but everything reachable from this entry is stamped\n// `\"use client\"` by the bundler — so exporting them here turns a plain function\n// call in a server layout into \"Attempted to call accountPaths() from the\n// server but accountPaths is on the client\".\nexport * from './session-keepalive.js';\nexport * from './use-session-refresh.js';\n"],"names":[],"mappings":"AAAA,oCAAoC;AACpC,EAAE;AACF,8EAA8E;AAC9E,wEAAwE;AACxE,sEAAsE;AACtE,gFAAgF;AAChF,0EAA0E;AAC1E,6CAA6C;AAC7C,cAAc,yBAAyB;AACvC,cAAc,2BAA2B"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
export interface RefreshRouteOptions {
|
|
3
|
+
/** auth-service base URL; defaults to `AUTH_SERVICE_URL` then :3102. */
|
|
4
|
+
readonly authServiceUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Builds `POST /api/auth/refresh` for an `-app`.
|
|
8
|
+
*
|
|
9
|
+
* Every app needs its own copy of this route because cookies are per-origin: in
|
|
10
|
+
* production each `-app` is a different host and mints its own, and only
|
|
11
|
+
* localhost's shared hostname makes it look otherwise. So the *handler* is
|
|
12
|
+
* shared from here and each app mounts it.
|
|
13
|
+
*
|
|
14
|
+
* A dead session clears the cookies rather than leaving them to rot — otherwise
|
|
15
|
+
* the browser keeps presenting a session id that will never refresh again, and
|
|
16
|
+
* the middleware keeps believing there is a session to protect.
|
|
17
|
+
*/
|
|
18
|
+
export declare const createRefreshRoute: (options?: RefreshRouteOptions) => (request: NextRequest) => Promise<NextResponse>;
|
|
19
|
+
//# sourceMappingURL=refresh-route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refresh-route.d.ts","sourceRoot":"","sources":["../../../src/lib/session/refresh-route.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAW7D,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CAClC;AAiCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAC5B,UAAS,mBAAwB,MAC3B,SAAS,WAAW,KAAG,OAAO,CAAC,YAAY,CAgDjD,CAAC"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { SESSION_COOKIE } from '@entifix/core';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { applySessionCookies, clearSessionCookies } from './cookies.js';
|
|
4
|
+
/**
|
|
5
|
+
* In-flight refreshes, keyed by session id.
|
|
6
|
+
*
|
|
7
|
+
* Several tabs and a lazy server-side refresh can all notice an expiring token
|
|
8
|
+
* within the same second. Collapsing them costs one map and saves the round
|
|
9
|
+
* trips. It is deliberately per-process and not the Redis `LockService`: session
|
|
10
|
+
* ids do not rotate, so the worst a lost race can do is mint a second, equally
|
|
11
|
+
* valid token — a wasted signature, not a correctness problem.
|
|
12
|
+
*/ const inFlight = new Map();
|
|
13
|
+
const refreshOnce = (authServiceUrl, sessionId)=>{
|
|
14
|
+
const existing = inFlight.get(sessionId);
|
|
15
|
+
if (existing !== undefined) return existing;
|
|
16
|
+
const pending = fetch(`${authServiceUrl}/api/auth/refresh`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
'content-type': 'application/json'
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
sessionId
|
|
23
|
+
}),
|
|
24
|
+
cache: 'no-store'
|
|
25
|
+
}).finally(()=>{
|
|
26
|
+
inFlight.delete(sessionId);
|
|
27
|
+
});
|
|
28
|
+
inFlight.set(sessionId, pending);
|
|
29
|
+
return pending;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Builds `POST /api/auth/refresh` for an `-app`.
|
|
33
|
+
*
|
|
34
|
+
* Every app needs its own copy of this route because cookies are per-origin: in
|
|
35
|
+
* production each `-app` is a different host and mints its own, and only
|
|
36
|
+
* localhost's shared hostname makes it look otherwise. So the *handler* is
|
|
37
|
+
* shared from here and each app mounts it.
|
|
38
|
+
*
|
|
39
|
+
* A dead session clears the cookies rather than leaving them to rot — otherwise
|
|
40
|
+
* the browser keeps presenting a session id that will never refresh again, and
|
|
41
|
+
* the middleware keeps believing there is a session to protect.
|
|
42
|
+
*/ export const createRefreshRoute = (options = {})=>async (request)=>{
|
|
43
|
+
var _ref, _options_authServiceUrl;
|
|
44
|
+
var _request_cookies_get;
|
|
45
|
+
const authServiceUrl = (_ref = (_options_authServiceUrl = options.authServiceUrl) != null ? _options_authServiceUrl : process.env.AUTH_SERVICE_URL) != null ? _ref : 'http://localhost:3102';
|
|
46
|
+
const sessionId = (_request_cookies_get = request.cookies.get(SESSION_COOKIE)) == null ? void 0 : _request_cookies_get.value;
|
|
47
|
+
if (sessionId === undefined) {
|
|
48
|
+
return NextResponse.json({
|
|
49
|
+
error: 'no session',
|
|
50
|
+
code: 'unauthenticated'
|
|
51
|
+
}, {
|
|
52
|
+
status: 401
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const upstream = await refreshOnce(authServiceUrl, sessionId).catch(()=>undefined);
|
|
56
|
+
if (upstream === undefined) {
|
|
57
|
+
// The service is unreachable. The session may well still be fine, so the
|
|
58
|
+
// cookies stay and the client is told to retry rather than sign out.
|
|
59
|
+
return NextResponse.json({
|
|
60
|
+
error: 'auth service unreachable',
|
|
61
|
+
code: 'network'
|
|
62
|
+
}, {
|
|
63
|
+
status: 503
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// `clone()` because a collapsed refresh hands the same Response to every
|
|
67
|
+
// waiter, and a body can only be read once.
|
|
68
|
+
const data = await upstream.clone().json();
|
|
69
|
+
if (!upstream.ok) {
|
|
70
|
+
return clearSessionCookies(NextResponse.json(data, {
|
|
71
|
+
status: upstream.status
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
const payload = data;
|
|
75
|
+
return applySessionCookies(NextResponse.json({
|
|
76
|
+
ok: true,
|
|
77
|
+
expiresIn: payload.expiresIn,
|
|
78
|
+
sessionExpiresIn: payload.sessionExpiresIn
|
|
79
|
+
}), {
|
|
80
|
+
accessToken: payload.accessToken,
|
|
81
|
+
sessionExpiresIn: payload.sessionExpiresIn
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=refresh-route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/session/refresh-route.ts"],"sourcesContent":["import { SESSION_COOKIE } from '@entifix/core';\nimport { type NextRequest, NextResponse } from 'next/server';\n\nimport { applySessionCookies, clearSessionCookies } from './cookies.js';\n\n/** The subset of auth-service's refresh response this handler needs. */\ninterface RefreshPayload {\n readonly accessToken: string;\n readonly expiresIn: number;\n readonly sessionExpiresIn: number;\n}\n\nexport interface RefreshRouteOptions {\n /** auth-service base URL; defaults to `AUTH_SERVICE_URL` then :3102. */\n readonly authServiceUrl?: string;\n}\n\n/**\n * In-flight refreshes, keyed by session id.\n *\n * Several tabs and a lazy server-side refresh can all notice an expiring token\n * within the same second. Collapsing them costs one map and saves the round\n * trips. It is deliberately per-process and not the Redis `LockService`: session\n * ids do not rotate, so the worst a lost race can do is mint a second, equally\n * valid token — a wasted signature, not a correctness problem.\n */\nconst inFlight = new Map<string, Promise<Response>>();\n\nconst refreshOnce = (\n authServiceUrl: string,\n sessionId: string,\n): Promise<Response> => {\n const existing = inFlight.get(sessionId);\n if (existing !== undefined) return existing;\n\n const pending = fetch(`${authServiceUrl}/api/auth/refresh`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ sessionId }),\n cache: 'no-store',\n }).finally(() => {\n inFlight.delete(sessionId);\n });\n\n inFlight.set(sessionId, pending);\n return pending;\n};\n\n/**\n * Builds `POST /api/auth/refresh` for an `-app`.\n *\n * Every app needs its own copy of this route because cookies are per-origin: in\n * production each `-app` is a different host and mints its own, and only\n * localhost's shared hostname makes it look otherwise. So the *handler* is\n * shared from here and each app mounts it.\n *\n * A dead session clears the cookies rather than leaving them to rot — otherwise\n * the browser keeps presenting a session id that will never refresh again, and\n * the middleware keeps believing there is a session to protect.\n */\nexport const createRefreshRoute =\n (options: RefreshRouteOptions = {}) =>\n async (request: NextRequest): Promise<NextResponse> => {\n const authServiceUrl =\n options.authServiceUrl ??\n process.env.AUTH_SERVICE_URL ??\n 'http://localhost:3102';\n\n const sessionId = request.cookies.get(SESSION_COOKIE)?.value;\n if (sessionId === undefined) {\n return NextResponse.json(\n { error: 'no session', code: 'unauthenticated' },\n { status: 401 },\n );\n }\n\n const upstream = await refreshOnce(authServiceUrl, sessionId).catch(\n () => undefined,\n );\n if (upstream === undefined) {\n // The service is unreachable. The session may well still be fine, so the\n // cookies stay and the client is told to retry rather than sign out.\n return NextResponse.json(\n { error: 'auth service unreachable', code: 'network' },\n { status: 503 },\n );\n }\n\n // `clone()` because a collapsed refresh hands the same Response to every\n // waiter, and a body can only be read once.\n const data = await upstream.clone().json();\n\n if (!upstream.ok) {\n return clearSessionCookies(\n NextResponse.json(data, { status: upstream.status }),\n );\n }\n\n const payload = data as RefreshPayload;\n return applySessionCookies(\n NextResponse.json({\n ok: true,\n expiresIn: payload.expiresIn,\n sessionExpiresIn: payload.sessionExpiresIn,\n }),\n {\n accessToken: payload.accessToken,\n sessionExpiresIn: payload.sessionExpiresIn,\n },\n );\n };\n"],"names":["SESSION_COOKIE","NextResponse","applySessionCookies","clearSessionCookies","inFlight","Map","refreshOnce","authServiceUrl","sessionId","existing","get","undefined","pending","fetch","method","headers","body","JSON","stringify","cache","finally","delete","set","createRefreshRoute","options","request","process","env","AUTH_SERVICE_URL","cookies","value","json","error","code","status","upstream","catch","data","clone","ok","payload","expiresIn","sessionExpiresIn","accessToken"],"mappings":"AAAA,SAASA,cAAc,QAAQ,gBAAgB;AAC/C,SAA2BC,YAAY,QAAQ,cAAc;AAE7D,SAASC,mBAAmB,EAAEC,mBAAmB,QAAQ,eAAe;AAcxE;;;;;;;;CAQC,GACD,MAAMC,WAAW,IAAIC;AAErB,MAAMC,cAAc,CAClBC,gBACAC;IAEA,MAAMC,WAAWL,SAASM,GAAG,CAACF;IAC9B,IAAIC,aAAaE,WAAW,OAAOF;IAEnC,MAAMG,UAAUC,MAAM,GAAGN,eAAe,iBAAiB,CAAC,EAAE;QAC1DO,QAAQ;QACRC,SAAS;YAAE,gBAAgB;QAAmB;QAC9CC,MAAMC,KAAKC,SAAS,CAAC;YAAEV;QAAU;QACjCW,OAAO;IACT,GAAGC,OAAO,CAAC;QACThB,SAASiB,MAAM,CAACb;IAClB;IAEAJ,SAASkB,GAAG,CAACd,WAAWI;IACxB,OAAOA;AACT;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,MAAMW,qBACX,CAACC,UAA+B,CAAC,CAAC,GAClC,OAAOC;YAEHD,MAAAA;YAIgBC;QALlB,MAAMlB,kBACJiB,QAAAA,0BAAAA,QAAQjB,cAAc,YAAtBiB,0BACAE,QAAQC,GAAG,CAACC,gBAAgB,YAD5BJ,OAEA;QAEF,MAAMhB,aAAYiB,uBAAAA,QAAQI,OAAO,CAACnB,GAAG,CAACV,oCAApByB,qBAAqCK,KAAK;QAC5D,IAAItB,cAAcG,WAAW;YAC3B,OAAOV,aAAa8B,IAAI,CACtB;gBAAEC,OAAO;gBAAcC,MAAM;YAAkB,GAC/C;gBAAEC,QAAQ;YAAI;QAElB;QAEA,MAAMC,WAAW,MAAM7B,YAAYC,gBAAgBC,WAAW4B,KAAK,CACjE,IAAMzB;QAER,IAAIwB,aAAaxB,WAAW;YAC1B,yEAAyE;YACzE,qEAAqE;YACrE,OAAOV,aAAa8B,IAAI,CACtB;gBAAEC,OAAO;gBAA4BC,MAAM;YAAU,GACrD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,yEAAyE;QACzE,4CAA4C;QAC5C,MAAMG,OAAO,MAAMF,SAASG,KAAK,GAAGP,IAAI;QAExC,IAAI,CAACI,SAASI,EAAE,EAAE;YAChB,OAAOpC,oBACLF,aAAa8B,IAAI,CAACM,MAAM;gBAAEH,QAAQC,SAASD,MAAM;YAAC;QAEtD;QAEA,MAAMM,UAAUH;QAChB,OAAOnC,oBACLD,aAAa8B,IAAI,CAAC;YAChBQ,IAAI;YACJE,WAAWD,QAAQC,SAAS;YAC5BC,kBAAkBF,QAAQE,gBAAgB;QAC5C,IACA;YACEC,aAAaH,QAAQG,WAAW;YAChCD,kBAAkBF,QAAQE,gBAAgB;QAC5C;IAEJ,EAAE"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface SessionKeepaliveProps {
|
|
2
|
+
/** Where this app mounts the shared refresh handler. */
|
|
3
|
+
readonly endpoint?: string;
|
|
4
|
+
/**
|
|
5
|
+
* What to do when the session is over. Defaults to reloading the page, which
|
|
6
|
+
* is almost always the right answer — see the note on {@link
|
|
7
|
+
* SessionKeepalive}.
|
|
8
|
+
*/
|
|
9
|
+
readonly onExpired?: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Mounts {@link useSessionRefresh} and acts on what it reports.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **This component is why the hook does anything at all.** The hook shipped
|
|
15
|
+
* with its schedule, its idle gate, its countdown and a full spec, and was
|
|
16
|
+
* referenced by nothing — no app, no shell, no layout. So no browser in the
|
|
17
|
+
* fleet ever posted to `/api/auth/refresh`, and three separately reasonable
|
|
18
|
+
* facts combined into a bug (#252): the access token lives 15 minutes, both
|
|
19
|
+
* cookies are sized to the seven-day session ceiling so that a cookie does not
|
|
20
|
+
* sign everyone out every 15 minutes, and the middleware can only check that a
|
|
21
|
+
* cookie is *present* because the edge cannot verify a signature. The visitor
|
|
22
|
+
* was therefore admitted to pages that rendered fine while every call behind
|
|
23
|
+
* them answered `401`.
|
|
24
|
+
*
|
|
25
|
+
* **Expiry reloads rather than routing anywhere**, and that is the smaller
|
|
26
|
+
* change rather than the lazier one. By the time the hook reports `expired` the
|
|
27
|
+
* refresh route has already cleared both cookies — a dead session clears them
|
|
28
|
+
* instead of leaving the browser presenting a session id that will never
|
|
29
|
+
* refresh again. A reload then meets the middleware with no cookie, and the
|
|
30
|
+
* middleware redirects to sign-in with the right locale and the right
|
|
31
|
+
* `redirect` parameter. Building that URL here would be a second copy of a rule
|
|
32
|
+
* that already exists, and the two would drift the first time either changed.
|
|
33
|
+
*
|
|
34
|
+
* A form's unsaved work survives it, because a workspace draft is autosaved to
|
|
35
|
+
* IndexedDB rather than held in the page
|
|
36
|
+
* ([ADR 0032](../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).
|
|
37
|
+
*
|
|
38
|
+
* Renders nothing but a warning strip, and only inside the final window before
|
|
39
|
+
* the ceiling. `role="status"` rather than `role="alert"`: the session ending
|
|
40
|
+
* soon is a thing to notice, not an error to interrupt for, and an assertive
|
|
41
|
+
* live region would talk over whatever the person is actually doing.
|
|
42
|
+
*
|
|
43
|
+
* Neutral surface tokens rather than a warning colour, because the contract has
|
|
44
|
+
* no warning colour — it carries `danger` and its subtle and content pair, and
|
|
45
|
+
* nothing between that and the neutrals. Borrowing `danger` for "your session
|
|
46
|
+
* ends in five minutes" would spend the one alarming colour on something that is
|
|
47
|
+
* not an error; adding a semantic colour is a change to the token contract
|
|
48
|
+
* ([ADR 0027](../../../../../../docs/adr/0027-two-scales-a-density-mode-and-the-type-system.md))
|
|
49
|
+
* and does not belong in a session fix.
|
|
50
|
+
*/
|
|
51
|
+
export declare function SessionKeepalive({ endpoint, onExpired, }?: SessionKeepaliveProps): import("react").JSX.Element | null;
|
|
52
|
+
//# sourceMappingURL=session-keepalive.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-keepalive.d.ts","sourceRoot":"","sources":["../../../src/lib/session/session-keepalive.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,SAAS,GACV,GAAE,qBAA0B,sCA6B5B"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useT } from '@entifix/react-controls';
|
|
4
|
+
import { useCallback } from 'react';
|
|
5
|
+
import { useSessionRefresh } from './use-session-refresh.js';
|
|
6
|
+
/**
|
|
7
|
+
* Mounts {@link useSessionRefresh} and acts on what it reports.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **This component is why the hook does anything at all.** The hook shipped
|
|
10
|
+
* with its schedule, its idle gate, its countdown and a full spec, and was
|
|
11
|
+
* referenced by nothing — no app, no shell, no layout. So no browser in the
|
|
12
|
+
* fleet ever posted to `/api/auth/refresh`, and three separately reasonable
|
|
13
|
+
* facts combined into a bug (#252): the access token lives 15 minutes, both
|
|
14
|
+
* cookies are sized to the seven-day session ceiling so that a cookie does not
|
|
15
|
+
* sign everyone out every 15 minutes, and the middleware can only check that a
|
|
16
|
+
* cookie is *present* because the edge cannot verify a signature. The visitor
|
|
17
|
+
* was therefore admitted to pages that rendered fine while every call behind
|
|
18
|
+
* them answered `401`.
|
|
19
|
+
*
|
|
20
|
+
* **Expiry reloads rather than routing anywhere**, and that is the smaller
|
|
21
|
+
* change rather than the lazier one. By the time the hook reports `expired` the
|
|
22
|
+
* refresh route has already cleared both cookies — a dead session clears them
|
|
23
|
+
* instead of leaving the browser presenting a session id that will never
|
|
24
|
+
* refresh again. A reload then meets the middleware with no cookie, and the
|
|
25
|
+
* middleware redirects to sign-in with the right locale and the right
|
|
26
|
+
* `redirect` parameter. Building that URL here would be a second copy of a rule
|
|
27
|
+
* that already exists, and the two would drift the first time either changed.
|
|
28
|
+
*
|
|
29
|
+
* A form's unsaved work survives it, because a workspace draft is autosaved to
|
|
30
|
+
* IndexedDB rather than held in the page
|
|
31
|
+
* ([ADR 0032](../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).
|
|
32
|
+
*
|
|
33
|
+
* Renders nothing but a warning strip, and only inside the final window before
|
|
34
|
+
* the ceiling. `role="status"` rather than `role="alert"`: the session ending
|
|
35
|
+
* soon is a thing to notice, not an error to interrupt for, and an assertive
|
|
36
|
+
* live region would talk over whatever the person is actually doing.
|
|
37
|
+
*
|
|
38
|
+
* Neutral surface tokens rather than a warning colour, because the contract has
|
|
39
|
+
* no warning colour — it carries `danger` and its subtle and content pair, and
|
|
40
|
+
* nothing between that and the neutrals. Borrowing `danger` for "your session
|
|
41
|
+
* ends in five minutes" would spend the one alarming colour on something that is
|
|
42
|
+
* not an error; adding a semantic colour is a change to the token contract
|
|
43
|
+
* ([ADR 0027](../../../../../../docs/adr/0027-two-scales-a-density-mode-and-the-type-system.md))
|
|
44
|
+
* and does not belong in a session fix.
|
|
45
|
+
*/ export function SessionKeepalive({ endpoint, onExpired } = {}) {
|
|
46
|
+
// A reload rather than a route change: middleware is what knows where a
|
|
47
|
+
// signed-out visitor goes, and `router.push` would keep the dead client-side
|
|
48
|
+
// tree around while it got there.
|
|
49
|
+
const expire = useCallback(()=>{
|
|
50
|
+
if (onExpired) {
|
|
51
|
+
onExpired();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
window.location.reload();
|
|
55
|
+
}, [
|
|
56
|
+
onExpired
|
|
57
|
+
]);
|
|
58
|
+
const { expiringSoon, expired } = useSessionRefresh({
|
|
59
|
+
endpoint,
|
|
60
|
+
onExpired: expire
|
|
61
|
+
});
|
|
62
|
+
const t = useT('shell');
|
|
63
|
+
if (expired || !expiringSoon) return null;
|
|
64
|
+
return /*#__PURE__*/ _jsx("div", {
|
|
65
|
+
role: "status",
|
|
66
|
+
"data-testid": "session-expiring",
|
|
67
|
+
className: "border-b border-border bg-surface-elevated px-s py-2xs text-step-sm text-content",
|
|
68
|
+
children: t('session.expiringSoon')
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//# sourceMappingURL=session-keepalive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/session/session-keepalive.tsx"],"sourcesContent":["'use client';\n\nimport { useT } from '@entifix/react-controls';\nimport { useCallback } from 'react';\n\nimport { useSessionRefresh } from './use-session-refresh.js';\n\nexport interface SessionKeepaliveProps {\n /** Where this app mounts the shared refresh handler. */\n readonly endpoint?: string;\n /**\n * What to do when the session is over. Defaults to reloading the page, which\n * is almost always the right answer — see the note on {@link\n * SessionKeepalive}.\n */\n readonly onExpired?: () => void;\n}\n\n/**\n * Mounts {@link useSessionRefresh} and acts on what it reports.\n *\n * ⚠️ **This component is why the hook does anything at all.** The hook shipped\n * with its schedule, its idle gate, its countdown and a full spec, and was\n * referenced by nothing — no app, no shell, no layout. So no browser in the\n * fleet ever posted to `/api/auth/refresh`, and three separately reasonable\n * facts combined into a bug (#252): the access token lives 15 minutes, both\n * cookies are sized to the seven-day session ceiling so that a cookie does not\n * sign everyone out every 15 minutes, and the middleware can only check that a\n * cookie is *present* because the edge cannot verify a signature. The visitor\n * was therefore admitted to pages that rendered fine while every call behind\n * them answered `401`.\n *\n * **Expiry reloads rather than routing anywhere**, and that is the smaller\n * change rather than the lazier one. By the time the hook reports `expired` the\n * refresh route has already cleared both cookies — a dead session clears them\n * instead of leaving the browser presenting a session id that will never\n * refresh again. A reload then meets the middleware with no cookie, and the\n * middleware redirects to sign-in with the right locale and the right\n * `redirect` parameter. Building that URL here would be a second copy of a rule\n * that already exists, and the two would drift the first time either changed.\n *\n * A form's unsaved work survives it, because a workspace draft is autosaved to\n * IndexedDB rather than held in the page\n * ([ADR 0032](../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).\n *\n * Renders nothing but a warning strip, and only inside the final window before\n * the ceiling. `role=\"status\"` rather than `role=\"alert\"`: the session ending\n * soon is a thing to notice, not an error to interrupt for, and an assertive\n * live region would talk over whatever the person is actually doing.\n *\n * Neutral surface tokens rather than a warning colour, because the contract has\n * no warning colour — it carries `danger` and its subtle and content pair, and\n * nothing between that and the neutrals. Borrowing `danger` for \"your session\n * ends in five minutes\" would spend the one alarming colour on something that is\n * not an error; adding a semantic colour is a change to the token contract\n * ([ADR 0027](../../../../../../docs/adr/0027-two-scales-a-density-mode-and-the-type-system.md))\n * and does not belong in a session fix.\n */\nexport function SessionKeepalive({\n endpoint,\n onExpired,\n}: SessionKeepaliveProps = {}) {\n // A reload rather than a route change: middleware is what knows where a\n // signed-out visitor goes, and `router.push` would keep the dead client-side\n // tree around while it got there.\n const expire = useCallback(() => {\n if (onExpired) {\n onExpired();\n return;\n }\n window.location.reload();\n }, [onExpired]);\n\n const { expiringSoon, expired } = useSessionRefresh({\n endpoint,\n onExpired: expire,\n });\n const t = useT('shell');\n\n if (expired || !expiringSoon) return null;\n\n return (\n <div\n role=\"status\"\n data-testid=\"session-expiring\"\n className=\"border-b border-border bg-surface-elevated px-s py-2xs text-step-sm text-content\"\n >\n {t('session.expiringSoon')}\n </div>\n );\n}\n"],"names":["useT","useCallback","useSessionRefresh","SessionKeepalive","endpoint","onExpired","expire","window","location","reload","expiringSoon","expired","t","div","role","data-testid","className"],"mappings":"AAAA;;AAEA,SAASA,IAAI,QAAQ,0BAA0B;AAC/C,SAASC,WAAW,QAAQ,QAAQ;AAEpC,SAASC,iBAAiB,QAAQ,2BAA2B;AAa7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GACD,OAAO,SAASC,iBAAiB,EAC/BC,QAAQ,EACRC,SAAS,EACa,GAAG,CAAC,CAAC;IAC3B,wEAAwE;IACxE,6EAA6E;IAC7E,kCAAkC;IAClC,MAAMC,SAASL,YAAY;QACzB,IAAII,WAAW;YACbA;YACA;QACF;QACAE,OAAOC,QAAQ,CAACC,MAAM;IACxB,GAAG;QAACJ;KAAU;IAEd,MAAM,EAAEK,YAAY,EAAEC,OAAO,EAAE,GAAGT,kBAAkB;QAClDE;QACAC,WAAWC;IACb;IACA,MAAMM,IAAIZ,KAAK;IAEf,IAAIW,WAAW,CAACD,cAAc,OAAO;IAErC,qBACE,KAACG;QACCC,MAAK;QACLC,eAAY;QACZC,WAAU;kBAETJ,EAAE;;AAGT"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface UseSessionRefreshOptions {
|
|
2
|
+
/** Where this app mounts the shared refresh handler. */
|
|
3
|
+
readonly endpoint?: string;
|
|
4
|
+
/** Access-token lifetime in seconds; the schedule derives from it. */
|
|
5
|
+
readonly tokenTtlSeconds?: number;
|
|
6
|
+
/**
|
|
7
|
+
* How long without interaction before the timer stops asking.
|
|
8
|
+
*
|
|
9
|
+
* This is what makes "idle timeout" measure whether a person is there rather
|
|
10
|
+
* than whether a tab is open, so a host raising it is choosing to keep
|
|
11
|
+
* forgotten tabs alive.
|
|
12
|
+
*/
|
|
13
|
+
readonly idleStopSeconds?: number;
|
|
14
|
+
/** How long before the absolute ceiling `expiringSoon` turns true. */
|
|
15
|
+
readonly expiryWarningSeconds?: number;
|
|
16
|
+
/** Fraction of the token's life at which to refresh. */
|
|
17
|
+
readonly refreshLeadRatio?: number;
|
|
18
|
+
/** Called when the session is gone and the user has to sign in again. */
|
|
19
|
+
readonly onExpired?: () => void;
|
|
20
|
+
}
|
|
21
|
+
export interface SessionRefreshState {
|
|
22
|
+
/** Seconds left before the session's absolute ceiling, once known. */
|
|
23
|
+
readonly sessionExpiresIn: number | undefined;
|
|
24
|
+
/** True inside the final window before the ceiling — prompt the user. */
|
|
25
|
+
readonly expiringSoon: boolean;
|
|
26
|
+
/** True once a refresh has failed and the session is over. */
|
|
27
|
+
readonly expired: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Keeps the access-token cookie fresh while someone is actually using the app.
|
|
31
|
+
*
|
|
32
|
+
* The refresh runs on a timer at 80% of the token's life, but **only while the
|
|
33
|
+
* user has interacted recently**. That condition is the whole point: the server
|
|
34
|
+
* slides the session on refresh, so a timer that fired unconditionally would
|
|
35
|
+
* keep a forgotten tab's session alive forever and "idle timeout" would measure
|
|
36
|
+
* whether a tab is open rather than whether a person is there. Going quiet lets
|
|
37
|
+
* the token lapse and the session age out on schedule.
|
|
38
|
+
*
|
|
39
|
+
* The token itself is never visible here — it lives in an httpOnly cookie, and
|
|
40
|
+
* the endpoint below rewrites it server-side.
|
|
41
|
+
*/
|
|
42
|
+
export declare function useSessionRefresh(options?: UseSessionRefreshOptions): SessionRefreshState;
|
|
43
|
+
//# sourceMappingURL=use-session-refresh.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-session-refresh.d.ts","sourceRoot":"","sources":["../../../src/lib/session/use-session-refresh.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,wBAAwB;IACvC,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,wDAAwD;IACxD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,yEAAyE;IACzE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,mBAAmB;IAClC,sEAAsE;IACtE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,wBAA6B,GACrC,mBAAmB,CAiGrB"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
/** Browser events that count as a person being present. */ const ACTIVITY_EVENTS = [
|
|
4
|
+
'pointerdown',
|
|
5
|
+
'keydown',
|
|
6
|
+
'scroll',
|
|
7
|
+
'visibilitychange'
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Session timing, as this shell assumes it when a host says nothing.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ **Defaults, not policy.** These four used to be imported from the
|
|
13
|
+
* application's own session package, which made a framework hook carry one
|
|
14
|
+
* product's security decisions — and meant a second application could not use
|
|
15
|
+
* the hook at all without adopting r10c's token lifetime. A host that has a
|
|
16
|
+
* session policy passes it in; a host that has not yet thought about it gets
|
|
17
|
+
* numbers that are defensible rather than absent.
|
|
18
|
+
*
|
|
19
|
+
* The server is still the authority. These only decide when the browser *asks*;
|
|
20
|
+
* the refresh endpoint decides whether the session actually slides.
|
|
21
|
+
*/ const DEFAULT_TOKEN_TTL_SECONDS = 60 * 15;
|
|
22
|
+
const DEFAULT_IDLE_STOP_SECONDS = 60 * 15;
|
|
23
|
+
const DEFAULT_EXPIRY_WARNING_SECONDS = 60 * 5;
|
|
24
|
+
const DEFAULT_REFRESH_LEAD_RATIO = 0.8;
|
|
25
|
+
/**
|
|
26
|
+
* Keeps the access-token cookie fresh while someone is actually using the app.
|
|
27
|
+
*
|
|
28
|
+
* The refresh runs on a timer at 80% of the token's life, but **only while the
|
|
29
|
+
* user has interacted recently**. That condition is the whole point: the server
|
|
30
|
+
* slides the session on refresh, so a timer that fired unconditionally would
|
|
31
|
+
* keep a forgotten tab's session alive forever and "idle timeout" would measure
|
|
32
|
+
* whether a tab is open rather than whether a person is there. Going quiet lets
|
|
33
|
+
* the token lapse and the session age out on schedule.
|
|
34
|
+
*
|
|
35
|
+
* The token itself is never visible here — it lives in an httpOnly cookie, and
|
|
36
|
+
* the endpoint below rewrites it server-side.
|
|
37
|
+
*/ export function useSessionRefresh(options = {}) {
|
|
38
|
+
const { endpoint = '/api/auth/refresh', tokenTtlSeconds = DEFAULT_TOKEN_TTL_SECONDS, idleStopSeconds = DEFAULT_IDLE_STOP_SECONDS, expiryWarningSeconds = DEFAULT_EXPIRY_WARNING_SECONDS, refreshLeadRatio = DEFAULT_REFRESH_LEAD_RATIO, onExpired } = options;
|
|
39
|
+
const [sessionExpiresIn, setSessionExpiresIn] = useState(undefined);
|
|
40
|
+
const [expired, setExpired] = useState(false);
|
|
41
|
+
// Seeded on mount rather than at construction: reading the clock during
|
|
42
|
+
// render is impure, and the value is only ever consumed from an effect.
|
|
43
|
+
const lastActivity = useRef(0);
|
|
44
|
+
const onExpiredRef = useRef(onExpired);
|
|
45
|
+
/**
|
|
46
|
+
* Mirrors `expired` synchronously. The state update only tears the interval
|
|
47
|
+
* down on the next render, so without this a burst of ticks fires — and
|
|
48
|
+
* `onExpired` runs — several times over for one dead session.
|
|
49
|
+
*/ const expiredRef = useRef(false);
|
|
50
|
+
useEffect(()=>{
|
|
51
|
+
onExpiredRef.current = onExpired;
|
|
52
|
+
}, [
|
|
53
|
+
onExpired
|
|
54
|
+
]);
|
|
55
|
+
const markActive = useCallback(()=>{
|
|
56
|
+
lastActivity.current = Date.now();
|
|
57
|
+
}, []);
|
|
58
|
+
useEffect(()=>{
|
|
59
|
+
markActive();
|
|
60
|
+
for (const event of ACTIVITY_EVENTS){
|
|
61
|
+
window.addEventListener(event, markActive, {
|
|
62
|
+
passive: true
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return ()=>{
|
|
66
|
+
for (const event of ACTIVITY_EVENTS){
|
|
67
|
+
window.removeEventListener(event, markActive);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}, [
|
|
71
|
+
markActive
|
|
72
|
+
]);
|
|
73
|
+
useEffect(()=>{
|
|
74
|
+
if (expired) return undefined;
|
|
75
|
+
const periodMs = Math.max(1, Math.floor(tokenTtlSeconds * refreshLeadRatio)) * 1000;
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
const tick = async ()=>{
|
|
78
|
+
if (expiredRef.current) return;
|
|
79
|
+
const idleFor = (Date.now() - lastActivity.current) / 1000;
|
|
80
|
+
// Nobody is here. Skipping the refresh is what lets the session lapse.
|
|
81
|
+
if (idleFor > idleStopSeconds) return;
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(endpoint, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
cache: 'no-store'
|
|
86
|
+
});
|
|
87
|
+
if (cancelled) return;
|
|
88
|
+
if (response.status === 401) {
|
|
89
|
+
expiredRef.current = true;
|
|
90
|
+
setExpired(true);
|
|
91
|
+
onExpiredRef.current == null ? void 0 : onExpiredRef.current.call(onExpiredRef);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) return; // transient (503) — try again next tick
|
|
95
|
+
const body = await response.json();
|
|
96
|
+
if (!cancelled && typeof body.sessionExpiresIn === 'number') {
|
|
97
|
+
setSessionExpiresIn(body.sessionExpiresIn);
|
|
98
|
+
}
|
|
99
|
+
} catch (unused) {
|
|
100
|
+
// Offline or a dropped request: keep the schedule and retry.
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const timer = setInterval(tick, periodMs);
|
|
104
|
+
return ()=>{
|
|
105
|
+
cancelled = true;
|
|
106
|
+
clearInterval(timer);
|
|
107
|
+
};
|
|
108
|
+
}, [
|
|
109
|
+
endpoint,
|
|
110
|
+
tokenTtlSeconds,
|
|
111
|
+
expired
|
|
112
|
+
]);
|
|
113
|
+
return {
|
|
114
|
+
sessionExpiresIn,
|
|
115
|
+
expiringSoon: sessionExpiresIn !== undefined && sessionExpiresIn <= expiryWarningSeconds,
|
|
116
|
+
expired
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//# sourceMappingURL=use-session-refresh.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/session/use-session-refresh.ts"],"sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\n/** Browser events that count as a person being present. */\nconst ACTIVITY_EVENTS = [\n 'pointerdown',\n 'keydown',\n 'scroll',\n 'visibilitychange',\n] as const;\n\n/**\n * Session timing, as this shell assumes it when a host says nothing.\n *\n * ⚠️ **Defaults, not policy.** These four used to be imported from the\n * application's own session package, which made a framework hook carry one\n * product's security decisions — and meant a second application could not use\n * the hook at all without adopting r10c's token lifetime. A host that has a\n * session policy passes it in; a host that has not yet thought about it gets\n * numbers that are defensible rather than absent.\n *\n * The server is still the authority. These only decide when the browser *asks*;\n * the refresh endpoint decides whether the session actually slides.\n */\nconst DEFAULT_TOKEN_TTL_SECONDS = 60 * 15;\nconst DEFAULT_IDLE_STOP_SECONDS = 60 * 15;\nconst DEFAULT_EXPIRY_WARNING_SECONDS = 60 * 5;\nconst DEFAULT_REFRESH_LEAD_RATIO = 0.8;\n\nexport interface UseSessionRefreshOptions {\n /** Where this app mounts the shared refresh handler. */\n readonly endpoint?: string;\n /** Access-token lifetime in seconds; the schedule derives from it. */\n readonly tokenTtlSeconds?: number;\n /**\n * How long without interaction before the timer stops asking.\n *\n * This is what makes \"idle timeout\" measure whether a person is there rather\n * than whether a tab is open, so a host raising it is choosing to keep\n * forgotten tabs alive.\n */\n readonly idleStopSeconds?: number;\n /** How long before the absolute ceiling `expiringSoon` turns true. */\n readonly expiryWarningSeconds?: number;\n /** Fraction of the token's life at which to refresh. */\n readonly refreshLeadRatio?: number;\n /** Called when the session is gone and the user has to sign in again. */\n readonly onExpired?: () => void;\n}\n\nexport interface SessionRefreshState {\n /** Seconds left before the session's absolute ceiling, once known. */\n readonly sessionExpiresIn: number | undefined;\n /** True inside the final window before the ceiling — prompt the user. */\n readonly expiringSoon: boolean;\n /** True once a refresh has failed and the session is over. */\n readonly expired: boolean;\n}\n\n/**\n * Keeps the access-token cookie fresh while someone is actually using the app.\n *\n * The refresh runs on a timer at 80% of the token's life, but **only while the\n * user has interacted recently**. That condition is the whole point: the server\n * slides the session on refresh, so a timer that fired unconditionally would\n * keep a forgotten tab's session alive forever and \"idle timeout\" would measure\n * whether a tab is open rather than whether a person is there. Going quiet lets\n * the token lapse and the session age out on schedule.\n *\n * The token itself is never visible here — it lives in an httpOnly cookie, and\n * the endpoint below rewrites it server-side.\n */\nexport function useSessionRefresh(\n options: UseSessionRefreshOptions = {},\n): SessionRefreshState {\n const {\n endpoint = '/api/auth/refresh',\n tokenTtlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n idleStopSeconds = DEFAULT_IDLE_STOP_SECONDS,\n expiryWarningSeconds = DEFAULT_EXPIRY_WARNING_SECONDS,\n refreshLeadRatio = DEFAULT_REFRESH_LEAD_RATIO,\n onExpired,\n } = options;\n\n const [sessionExpiresIn, setSessionExpiresIn] = useState<number | undefined>(\n undefined,\n );\n const [expired, setExpired] = useState(false);\n // Seeded on mount rather than at construction: reading the clock during\n // render is impure, and the value is only ever consumed from an effect.\n const lastActivity = useRef(0);\n const onExpiredRef = useRef(onExpired);\n /**\n * Mirrors `expired` synchronously. The state update only tears the interval\n * down on the next render, so without this a burst of ticks fires — and\n * `onExpired` runs — several times over for one dead session.\n */\n const expiredRef = useRef(false);\n\n useEffect(() => {\n onExpiredRef.current = onExpired;\n }, [onExpired]);\n\n const markActive = useCallback(() => {\n lastActivity.current = Date.now();\n }, []);\n\n useEffect(() => {\n markActive();\n for (const event of ACTIVITY_EVENTS) {\n window.addEventListener(event, markActive, { passive: true });\n }\n return () => {\n for (const event of ACTIVITY_EVENTS) {\n window.removeEventListener(event, markActive);\n }\n };\n }, [markActive]);\n\n useEffect(() => {\n if (expired) return undefined;\n\n const periodMs =\n Math.max(1, Math.floor(tokenTtlSeconds * refreshLeadRatio)) * 1000;\n let cancelled = false;\n\n const tick = async () => {\n if (expiredRef.current) return;\n\n const idleFor = (Date.now() - lastActivity.current) / 1000;\n // Nobody is here. Skipping the refresh is what lets the session lapse.\n if (idleFor > idleStopSeconds) return;\n\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n cache: 'no-store',\n });\n if (cancelled) return;\n\n if (response.status === 401) {\n expiredRef.current = true;\n setExpired(true);\n onExpiredRef.current?.();\n return;\n }\n if (!response.ok) return; // transient (503) — try again next tick\n\n const body = (await response.json()) as { sessionExpiresIn?: number };\n if (!cancelled && typeof body.sessionExpiresIn === 'number') {\n setSessionExpiresIn(body.sessionExpiresIn);\n }\n } catch {\n // Offline or a dropped request: keep the schedule and retry.\n }\n };\n\n const timer = setInterval(tick, periodMs);\n return () => {\n cancelled = true;\n clearInterval(timer);\n };\n }, [endpoint, tokenTtlSeconds, expired]);\n\n return {\n sessionExpiresIn,\n expiringSoon:\n sessionExpiresIn !== undefined &&\n sessionExpiresIn <= expiryWarningSeconds,\n expired,\n };\n}\n"],"names":["useCallback","useEffect","useRef","useState","ACTIVITY_EVENTS","DEFAULT_TOKEN_TTL_SECONDS","DEFAULT_IDLE_STOP_SECONDS","DEFAULT_EXPIRY_WARNING_SECONDS","DEFAULT_REFRESH_LEAD_RATIO","useSessionRefresh","options","endpoint","tokenTtlSeconds","idleStopSeconds","expiryWarningSeconds","refreshLeadRatio","onExpired","sessionExpiresIn","setSessionExpiresIn","undefined","expired","setExpired","lastActivity","onExpiredRef","expiredRef","current","markActive","Date","now","event","window","addEventListener","passive","removeEventListener","periodMs","Math","max","floor","cancelled","tick","idleFor","response","fetch","method","cache","status","ok","body","json","timer","setInterval","clearInterval","expiringSoon"],"mappings":"AAAA;AAEA,SAASA,WAAW,EAAEC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAQ;AAEjE,yDAAyD,GACzD,MAAMC,kBAAkB;IACtB;IACA;IACA;IACA;CACD;AAED;;;;;;;;;;;;CAYC,GACD,MAAMC,4BAA4B,KAAK;AACvC,MAAMC,4BAA4B,KAAK;AACvC,MAAMC,iCAAiC,KAAK;AAC5C,MAAMC,6BAA6B;AAgCnC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,kBACdC,UAAoC,CAAC,CAAC;IAEtC,MAAM,EACJC,WAAW,mBAAmB,EAC9BC,kBAAkBP,yBAAyB,EAC3CQ,kBAAkBP,yBAAyB,EAC3CQ,uBAAuBP,8BAA8B,EACrDQ,mBAAmBP,0BAA0B,EAC7CQ,SAAS,EACV,GAAGN;IAEJ,MAAM,CAACO,kBAAkBC,oBAAoB,GAAGf,SAC9CgB;IAEF,MAAM,CAACC,SAASC,WAAW,GAAGlB,SAAS;IACvC,wEAAwE;IACxE,wEAAwE;IACxE,MAAMmB,eAAepB,OAAO;IAC5B,MAAMqB,eAAerB,OAAOc;IAC5B;;;;GAIC,GACD,MAAMQ,aAAatB,OAAO;IAE1BD,UAAU;QACRsB,aAAaE,OAAO,GAAGT;IACzB,GAAG;QAACA;KAAU;IAEd,MAAMU,aAAa1B,YAAY;QAC7BsB,aAAaG,OAAO,GAAGE,KAAKC,GAAG;IACjC,GAAG,EAAE;IAEL3B,UAAU;QACRyB;QACA,KAAK,MAAMG,SAASzB,gBAAiB;YACnC0B,OAAOC,gBAAgB,CAACF,OAAOH,YAAY;gBAAEM,SAAS;YAAK;QAC7D;QACA,OAAO;YACL,KAAK,MAAMH,SAASzB,gBAAiB;gBACnC0B,OAAOG,mBAAmB,CAACJ,OAAOH;YACpC;QACF;IACF,GAAG;QAACA;KAAW;IAEfzB,UAAU;QACR,IAAImB,SAAS,OAAOD;QAEpB,MAAMe,WACJC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACzB,kBAAkBG,qBAAqB;QAChE,IAAIuB,YAAY;QAEhB,MAAMC,OAAO;YACX,IAAIf,WAAWC,OAAO,EAAE;YAExB,MAAMe,UAAU,AAACb,CAAAA,KAAKC,GAAG,KAAKN,aAAaG,OAAO,AAAD,IAAK;YACtD,uEAAuE;YACvE,IAAIe,UAAU3B,iBAAiB;YAE/B,IAAI;gBACF,MAAM4B,WAAW,MAAMC,MAAM/B,UAAU;oBACrCgC,QAAQ;oBACRC,OAAO;gBACT;gBACA,IAAIN,WAAW;gBAEf,IAAIG,SAASI,MAAM,KAAK,KAAK;oBAC3BrB,WAAWC,OAAO,GAAG;oBACrBJ,WAAW;oBACXE,aAAaE,OAAO,oBAApBF,aAAaE,OAAO,MAApBF;oBACA;gBACF;gBACA,IAAI,CAACkB,SAASK,EAAE,EAAE,QAAQ,wCAAwC;gBAElE,MAAMC,OAAQ,MAAMN,SAASO,IAAI;gBACjC,IAAI,CAACV,aAAa,OAAOS,KAAK9B,gBAAgB,KAAK,UAAU;oBAC3DC,oBAAoB6B,KAAK9B,gBAAgB;gBAC3C;YACF,EAAE,eAAM;YACN,6DAA6D;YAC/D;QACF;QAEA,MAAMgC,QAAQC,YAAYX,MAAML;QAChC,OAAO;YACLI,YAAY;YACZa,cAAcF;QAChB;IACF,GAAG;QAACtC;QAAUC;QAAiBQ;KAAQ;IAEvC,OAAO;QACLH;QACAmC,cACEnC,qBAAqBE,aACrBF,oBAAoBH;QACtBM;IACF;AACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/wizard/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/wizard/index.ts"],"sourcesContent":["export * from './use-wizard-draft.js';\nexport * from './use-wizard-step-param.js';\nexport * from './wizard-tab-kind.js';\n"],"names":[],"mappings":"AAAA,cAAc,wBAAwB;AACtC,cAAc,6BAA6B;AAC3C,cAAc,uBAAuB"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { WizardDraftStore } from '@entifix/react-integration';
|
|
2
|
+
/**
|
|
3
|
+
* The workspace's implementation of `useWizard`'s draft port: binds a wizard to
|
|
4
|
+
* the address-keyed state this workspace persists to IndexedDB.
|
|
5
|
+
*
|
|
6
|
+
* The sibling of `useEntityDraft`, and deliberately a second hook rather than a
|
|
7
|
+
* widening of it: the two persist different shapes — one an `EntityDraft`, this
|
|
8
|
+
* a whole `WizardState` — so one hook serving both would let a form be handed a
|
|
9
|
+
* wizard's state by a caller that mixed up the addresses.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ What comes back from storage is **narrowed**, not trusted. A stored wizard
|
|
12
|
+
* can be anything a previous build wrote, and a step whose shape no longer
|
|
13
|
+
* parses is answered with a fresh wizard rather than a crash — `readWizardState`
|
|
14
|
+
* makes that call, and it fails the whole state rather than dropping one step,
|
|
15
|
+
* because resuming with one page silently forgotten is worse than starting over.
|
|
16
|
+
*
|
|
17
|
+
* The returned object is memoised because the port requires a stable `save`: the
|
|
18
|
+
* hook writes from a handler keyed on it, so a fresh identity each render would
|
|
19
|
+
* turn every render into an IndexedDB write.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useWizardDraft(address: string): WizardDraftStore;
|
|
22
|
+
//# sourceMappingURL=use-wizard-draft.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-wizard-draft.d.ts","sourceRoot":"","sources":["../../../src/lib/wizard/use-wizard-draft.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAKnE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,CAShE"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { readWizardState } from '@entifix/core';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useDraft } from '../workspace/drafts-state.js';
|
|
5
|
+
/**
|
|
6
|
+
* The workspace's implementation of `useWizard`'s draft port: binds a wizard to
|
|
7
|
+
* the address-keyed state this workspace persists to IndexedDB.
|
|
8
|
+
*
|
|
9
|
+
* The sibling of `useEntityDraft`, and deliberately a second hook rather than a
|
|
10
|
+
* widening of it: the two persist different shapes — one an `EntityDraft`, this
|
|
11
|
+
* a whole `WizardState` — so one hook serving both would let a form be handed a
|
|
12
|
+
* wizard's state by a caller that mixed up the addresses.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ What comes back from storage is **narrowed**, not trusted. A stored wizard
|
|
15
|
+
* can be anything a previous build wrote, and a step whose shape no longer
|
|
16
|
+
* parses is answered with a fresh wizard rather than a crash — `readWizardState`
|
|
17
|
+
* makes that call, and it fails the whole state rather than dropping one step,
|
|
18
|
+
* because resuming with one page silently forgotten is worse than starting over.
|
|
19
|
+
*
|
|
20
|
+
* The returned object is memoised because the port requires a stable `save`: the
|
|
21
|
+
* hook writes from a handler keyed on it, so a fresh identity each render would
|
|
22
|
+
* turn every render into an IndexedDB write.
|
|
23
|
+
*/ export function useWizardDraft(address) {
|
|
24
|
+
const { draft, setDraft, clearDraft } = useDraft(address);
|
|
25
|
+
const state = useMemo(()=>readWizardState(draft), [
|
|
26
|
+
draft
|
|
27
|
+
]);
|
|
28
|
+
return useMemo(()=>({
|
|
29
|
+
state,
|
|
30
|
+
save: setDraft,
|
|
31
|
+
clear: clearDraft
|
|
32
|
+
}), [
|
|
33
|
+
state,
|
|
34
|
+
setDraft,
|
|
35
|
+
clearDraft
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//# sourceMappingURL=use-wizard-draft.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/wizard/use-wizard-draft.ts"],"sourcesContent":["'use client';\n\nimport { readWizardState, type WizardState } from '@entifix/core';\nimport type { WizardDraftStore } from '@entifix/react-integration';\nimport { useMemo } from 'react';\n\nimport { useDraft } from '../workspace/drafts-state.js';\n\n/**\n * The workspace's implementation of `useWizard`'s draft port: binds a wizard to\n * the address-keyed state this workspace persists to IndexedDB.\n *\n * The sibling of `useEntityDraft`, and deliberately a second hook rather than a\n * widening of it: the two persist different shapes — one an `EntityDraft`, this\n * a whole `WizardState` — so one hook serving both would let a form be handed a\n * wizard's state by a caller that mixed up the addresses.\n *\n * ⚠️ What comes back from storage is **narrowed**, not trusted. A stored wizard\n * can be anything a previous build wrote, and a step whose shape no longer\n * parses is answered with a fresh wizard rather than a crash — `readWizardState`\n * makes that call, and it fails the whole state rather than dropping one step,\n * because resuming with one page silently forgotten is worse than starting over.\n *\n * The returned object is memoised because the port requires a stable `save`: the\n * hook writes from a handler keyed on it, so a fresh identity each render would\n * turn every render into an IndexedDB write.\n */\nexport function useWizardDraft(address: string): WizardDraftStore {\n const { draft, setDraft, clearDraft } = useDraft<WizardState>(address);\n\n const state = useMemo(() => readWizardState(draft), [draft]);\n\n return useMemo(\n () => ({ state, save: setDraft, clear: clearDraft }),\n [state, setDraft, clearDraft],\n );\n}\n"],"names":["readWizardState","useMemo","useDraft","useWizardDraft","address","draft","setDraft","clearDraft","state","save","clear"],"mappings":"AAAA;AAEA,SAASA,eAAe,QAA0B,gBAAgB;AAElE,SAASC,OAAO,QAAQ,QAAQ;AAEhC,SAASC,QAAQ,QAAQ,+BAA+B;AAExD;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,eAAeC,OAAe;IAC5C,MAAM,EAAEC,KAAK,EAAEC,QAAQ,EAAEC,UAAU,EAAE,GAAGL,SAAsBE;IAE9D,MAAMI,QAAQP,QAAQ,IAAMD,gBAAgBK,QAAQ;QAACA;KAAM;IAE3D,OAAOJ,QACL,IAAO,CAAA;YAAEO;YAAOC,MAAMH;YAAUI,OAAOH;QAAW,CAAA,GAClD;QAACC;QAAOF;QAAUC;KAAW;AAEjC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The query parameter a wizard route carries its step in. */
|
|
2
|
+
export declare const WIZARD_STEP_PARAM = "step";
|
|
3
|
+
/**
|
|
4
|
+
* Writes a step into the address — what `useWizard`'s `onStepChange` is wired to.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ **This must be handed to `useWizard` directly, never through a ref.** The
|
|
7
|
+
* push has to land in the same commit as the move: the follower below compares
|
|
8
|
+
* the address with the active step, so a report that arrives a tick late leaves
|
|
9
|
+
* the two disagreeing, and the follower reads that as a Back and moves the
|
|
10
|
+
* wizard *back* to the step it just left. Splitting the writer from the follower
|
|
11
|
+
* is what removes the cycle that made a ref look necessary.
|
|
12
|
+
*
|
|
13
|
+
* It **checks the address before pushing**, and that guard is what stops the
|
|
14
|
+
* loop: a Back drives `goTo`, which reports the move, which would otherwise push
|
|
15
|
+
* the very address Back had just restored — turning one press into an entry the
|
|
16
|
+
* operator has to get past twice.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useWizardStepUrl(): (stepId: string) => void;
|
|
19
|
+
export interface FollowWizardStepUrlOptions {
|
|
20
|
+
/** Where the wizard currently is, from `useWizard`. */
|
|
21
|
+
activeStep: string;
|
|
22
|
+
/** The flow's first step — where an address carrying no step points. */
|
|
23
|
+
entryStep: string;
|
|
24
|
+
/**
|
|
25
|
+
* `useWizard`'s `goTo`. It ignores a step that is not on the path already
|
|
26
|
+
* walked, which is what makes browser-Forward safe to hand straight to it.
|
|
27
|
+
*/
|
|
28
|
+
goTo: (stepId: string) => void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Moves the wizard when the address moves, so browser-Back moves a step instead
|
|
32
|
+
* of leaving the flow.
|
|
33
|
+
*
|
|
34
|
+
* **Back works; Forward deliberately does not re-advance.** Going back pops the
|
|
35
|
+
* history, so the step Forward names is no longer on the path and `goTo` ignores
|
|
36
|
+
* it — re-entering it would skip the per-step submit that validated everything
|
|
37
|
+
* in between, which is the only thing gating advancement.
|
|
38
|
+
*
|
|
39
|
+
* An address carrying **no** step means the flow's beginning — which is where
|
|
40
|
+
* browser-Back from the second step lands, since the first step is never pushed.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **It reacts to the address *changing*, never to what the address says.**
|
|
43
|
+
* Two faults sit on either side of that, and both were measured:
|
|
44
|
+
*
|
|
45
|
+
* - *Rewriting the address to match the wizard* races the advance. The replace
|
|
46
|
+
* is scheduled at mount with the entry step captured, the operator advances
|
|
47
|
+
* before it lands, and the navigation then puts the older step back — this
|
|
48
|
+
* effect reads it and rewinds a step they had already passed. It appeared only
|
|
49
|
+
* under load, which is the kind of race that reaches a user and not a test.
|
|
50
|
+
* - *Acting on the address at mount* breaks resume. A restored wizard takes its
|
|
51
|
+
* position from the persisted draft, so it opens on step four with an address
|
|
52
|
+
* that says nothing — read as "the beginning", that sends it back to step one
|
|
53
|
+
* and the resume is undone.
|
|
54
|
+
*
|
|
55
|
+
* So the first address seen is a **baseline**, and only a move away from it is a
|
|
56
|
+
* navigation. One direction throughout: the writer owns the address, the
|
|
57
|
+
* follower owns the wizard.
|
|
58
|
+
*/
|
|
59
|
+
export declare function useFollowWizardStepUrl({ activeStep, entryStep, goTo, }: FollowWizardStepUrlOptions): void;
|
|
60
|
+
//# sourceMappingURL=use-wizard-step-param.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-wizard-step-param.d.ts","sourceRoot":"","sources":["../../../src/lib/wizard/use-wizard-step-param.ts"],"names":[],"mappings":"AAKA,8DAA8D;AAC9D,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAuBxC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,IAAI,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAY3D;AAED,MAAM,WAAW,0BAA0B;IACzC,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,UAAU,EACV,SAAS,EACT,IAAI,GACL,EAAE,0BAA0B,GAAG,IAAI,CAUnC"}
|