@ofidj/generator-fidj 3.18.0 → 3.18.2
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.
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"privacy:rehearse": "npm run build && node --test test/server.test.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@ofidj/entry": "^3.18.
|
|
19
|
-
"@ofidj/node": "^3.18.
|
|
18
|
+
"@ofidj/entry": "^3.18.1",
|
|
19
|
+
"@ofidj/node": "^3.18.1"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A request made for the person signed in, and what a 401 is allowed to cost.
|
|
2
|
+
//
|
|
3
|
+
// The SDK's own session keeps an ID token that lives half an hour and a refresh
|
|
4
|
+
// token that lives for days, and `isLoggedIn()` reads only the second. Sending
|
|
5
|
+
// the stored ID token as it stood therefore failed on every tab opened more
|
|
6
|
+
// than thirty minutes after signing in, and the 401 branch then ended the
|
|
7
|
+
// session for good — `logout(true)` deletes the refresh token on the server and
|
|
8
|
+
// in the browser. So the token is refreshed before it is sent, and a 401 is
|
|
9
|
+
// retried once on a forced refresh before anything is thrown away.
|
|
10
|
+
//
|
|
11
|
+
// A provider (OIDC) session refreshes its own token when it reads it, so it is
|
|
12
|
+
// not synced here: that would cost a round trip on every request.
|
|
13
|
+
|
|
14
|
+
type Sdk = {
|
|
15
|
+
sync(options: { forceRefresh: boolean }): Promise<unknown>;
|
|
16
|
+
fidjGetIdToken(): Promise<string | undefined> | string | undefined;
|
|
17
|
+
logout(force?: boolean): Promise<unknown>;
|
|
18
|
+
isLoggedIn(): boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function authorizedRequest(options: {
|
|
22
|
+
sdk: Sdk;
|
|
23
|
+
providerSession: () => boolean;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
onSignedOut: () => void;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}) {
|
|
29
|
+
const send = async (
|
|
30
|
+
path: string,
|
|
31
|
+
method: string,
|
|
32
|
+
data: unknown,
|
|
33
|
+
forceRefresh: boolean,
|
|
34
|
+
) => {
|
|
35
|
+
if (!options.providerSession()) await options.sdk.sync({ forceRefresh });
|
|
36
|
+
const token = await options.sdk.fidjGetIdToken();
|
|
37
|
+
return (options.fetch ?? fetch)(options.baseUrl + path, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${token}`,
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
body: data === undefined ? undefined : JSON.stringify(data),
|
|
44
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10000),
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return async function request(
|
|
49
|
+
path: string,
|
|
50
|
+
method = "GET",
|
|
51
|
+
data?: unknown,
|
|
52
|
+
): Promise<any> {
|
|
53
|
+
let response: Response;
|
|
54
|
+
try {
|
|
55
|
+
response = await send(path, method, data, false);
|
|
56
|
+
if (response.status === 401 && !options.providerSession())
|
|
57
|
+
response = await send(path, method, data, true);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// A refresh the API refused has already ended the session in the SDK;
|
|
60
|
+
// one that only failed to reach it leaves it standing.
|
|
61
|
+
if (!options.sdk.isLoggedIn()) options.onSignedOut();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
const result = await response.json();
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
// Only a 401 means the session is gone; a 403 refuses one action to
|
|
67
|
+
// somebody still signed in, and signing them out for it ended the
|
|
68
|
+
// console's session on the first forbidden request.
|
|
69
|
+
if (response.status === 401) {
|
|
70
|
+
options.onSignedOut();
|
|
71
|
+
await options.sdk.logout(true);
|
|
72
|
+
}
|
|
73
|
+
throw new Error(result.message || result.status || "Please retry.");
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {signInErrorMessage, formatDate, agreementRequired, agreementFromRefusal, verificationPending, pollVerification, rememberSignIn, forgetSignIn, signInHint, type SigninShape} from "@ofidj/entry";
|
|
2
|
-
import {agreementScreen, bindAgreementScreen, bindPasswordReveal, acceptedAgreement, verificationWait, providerEntry, showEmailEntry, showVersionBadge, escape, masthead, highlightCells, badgeStrip, credentialFields, accountForm, passkeySupported, passkeyAssertion, walletDoor, memberCard, oidcInteractionMarkup, oidcInteractionStyles, bindOidcInteraction} from "@ofidj/entry/dom";
|
|
2
|
+
import {agreementScreen, bindAgreementScreen, bindPasswordReveal, acceptedAgreement, verificationWait, providerEntry, showEmailEntry, showVersionBadge, escape, masthead, highlightCells, badgeStrip, credentialFields, accountForm, passkeySupported, passkeyAssertion, walletDoor, memberCard, bindMemberHistory, oidcInteractionMarkup, oidcInteractionStyles, bindOidcInteraction} from "@ofidj/entry/dom";
|
|
3
3
|
import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "@ofidj/entry/window";
|
|
4
4
|
import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
|
|
5
5
|
import config from "../app.config.json";
|
|
6
|
+
import {authorizedRequest} from "./api-request";
|
|
6
7
|
import "@ofidj/entry/style.css";
|
|
7
8
|
|
|
8
9
|
const sdk = new FidjNodeService();
|
|
@@ -154,27 +155,16 @@ function wireSignOut() {
|
|
|
154
155
|
);
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
});
|
|
168
|
-
const result = await response.json();
|
|
169
|
-
if (!response.ok) {
|
|
170
|
-
if ([401, 403].includes(response.status)) {
|
|
171
|
-
signedIn = false;
|
|
172
|
-
await sdk.logout(true);
|
|
173
|
-
}
|
|
174
|
-
throw new Error(result.message || result.status || "Please retry.");
|
|
175
|
-
}
|
|
176
|
-
return result;
|
|
177
|
-
}
|
|
158
|
+
// Refreshes the SDK's token before sending it, and retries a 401 once before
|
|
159
|
+
// ending the session: see api-request.ts.
|
|
160
|
+
const request = authorizedRequest({
|
|
161
|
+
sdk,
|
|
162
|
+
providerSession: () => !!oidc?.hasSession(),
|
|
163
|
+
baseUrl: config.apiEndpoint,
|
|
164
|
+
onSignedOut: () => {
|
|
165
|
+
signedIn = false;
|
|
166
|
+
},
|
|
167
|
+
});
|
|
178
168
|
async function refresh() {
|
|
179
169
|
const membership = await request(
|
|
180
170
|
`/apps/${encodeURIComponent(config.appId)}/me`,
|
|
@@ -387,6 +377,7 @@ async function askWhetherFidjKnowsThisBrowser() {
|
|
|
387
377
|
return false;
|
|
388
378
|
}
|
|
389
379
|
try {
|
|
380
|
+
rememberRoute();
|
|
390
381
|
window.location.assign(await oidc.beginLogin({ silent: true }));
|
|
391
382
|
return true;
|
|
392
383
|
} catch {
|
|
@@ -448,7 +439,7 @@ function signInThroughProvider(
|
|
|
448
439
|
} catch {}
|
|
449
440
|
await refresh();
|
|
450
441
|
anonymous = false;
|
|
451
|
-
navigate(
|
|
442
|
+
navigate(takeReturnRoute());
|
|
452
443
|
});
|
|
453
444
|
}
|
|
454
445
|
// A person moving between screens is making history, so push an entry: Back has
|
|
@@ -461,6 +452,50 @@ function navigate(route: string) {
|
|
|
461
452
|
if (!busy) render();
|
|
462
453
|
}
|
|
463
454
|
|
|
455
|
+
// The screen somebody asked for when signing in got in the way.
|
|
456
|
+
//
|
|
457
|
+
// An address names a screen, and signing in is a detour on the way to it. A
|
|
458
|
+
// new tab opened on #/profile used to land on #/content: the tab asked Fidj
|
|
459
|
+
// silently, and the answer came back to the redirect URI — which carries no
|
|
460
|
+
// hash — and was sent to the app's home. Signing in by hand lost it the same
|
|
461
|
+
// way: the entry took the address over, and the door led home afterwards.
|
|
462
|
+
//
|
|
463
|
+
// The silent question leaves the document, so what it must bring back crosses
|
|
464
|
+
// in sessionStorage; a sign-in on this page only needs to remember it.
|
|
465
|
+
const RETURN_ROUTE = "fidj.return-route";
|
|
466
|
+
let returnRoute = "";
|
|
467
|
+
function worthReturningTo(route: string) {
|
|
468
|
+
const name = route.split("?")[0];
|
|
469
|
+
// The home is where a sign-in lands anyway; the entry and the mailed links
|
|
470
|
+
// are not destinations.
|
|
471
|
+
return (
|
|
472
|
+
Boolean(name) &&
|
|
473
|
+
!["content", "signin", "forgot", "reset", "verify"].includes(name)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
function rememberRoute(route = window.location.hash.slice(2)) {
|
|
477
|
+
if (!worthReturningTo(route)) return;
|
|
478
|
+
returnRoute = route;
|
|
479
|
+
try {
|
|
480
|
+
sessionStorage.setItem(RETURN_ROUTE, route);
|
|
481
|
+
} catch {
|
|
482
|
+
// Storage refused: the app's home is still somewhere to land.
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Read once: a route is returned to by the sign-in that detoured from it, not
|
|
486
|
+
// by every sign-in after.
|
|
487
|
+
function takeReturnRoute() {
|
|
488
|
+
let route = returnRoute;
|
|
489
|
+
returnRoute = "";
|
|
490
|
+
try {
|
|
491
|
+
route = sessionStorage.getItem(RETURN_ROUTE) || route;
|
|
492
|
+
sessionStorage.removeItem(RETURN_ROUTE);
|
|
493
|
+
} catch {
|
|
494
|
+
// Storage refused: what this page remembered is all there is.
|
|
495
|
+
}
|
|
496
|
+
return worthReturningTo(route) ? route : "content";
|
|
497
|
+
}
|
|
498
|
+
|
|
464
499
|
function moduleRoute() {
|
|
465
500
|
const route = window.location.hash.slice(2).split("?")[0];
|
|
466
501
|
if (
|
|
@@ -576,8 +611,10 @@ function render() {
|
|
|
576
611
|
!signedIn &&
|
|
577
612
|
!(config.allowAnonymous && anonymous) &&
|
|
578
613
|
!["forgot", "reset", "verify"].includes(route)
|
|
579
|
-
)
|
|
614
|
+
) {
|
|
615
|
+
if (route !== "signin") rememberRoute();
|
|
580
616
|
route = "signin";
|
|
617
|
+
}
|
|
581
618
|
else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
|
|
582
619
|
route = "content";
|
|
583
620
|
// The privacy screen and the profile screen became one. The address that
|
|
@@ -611,7 +648,7 @@ function render() {
|
|
|
611
648
|
// The two ways out, last and together: back to the app, or out of this
|
|
612
649
|
// session. Leaving the app itself is a different decision and stays where
|
|
613
650
|
// the things it erases are listed.
|
|
614
|
-
root.innerHTML = `<section class="content-account">${profileSummary()}<div class="
|
|
651
|
+
root.innerHTML = `<section class="content-account">${profileSummary()}<div class="profile-body">${banner()}${emailVerified ? accountRows() : accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail}, {compact: true})}${privacyBlock()}</div></section>`;
|
|
615
652
|
renderNav("account");
|
|
616
653
|
wireAccount("account");
|
|
617
654
|
wireSignOut();
|
|
@@ -638,7 +675,7 @@ function render() {
|
|
|
638
675
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
639
676
|
${highlightCells(config.highlights)}</div>
|
|
640
677
|
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin">${credentialFields({email: signInEmail, password: signInPassword}, {passkey: passkeyHere})}</form>${config.allowAnonymous ? `<div class="signin-divider"><span>or explore first</span></div><button class="anonymous-entry" id="anonymous">Enter anonymously <span aria-hidden="true">→</span></button><p class="signin-footnote">No account needed to view the content.</p>` : ""}
|
|
641
|
-
${config.title === "Fidj" ? walletDoor() :
|
|
678
|
+
${config.title === "Fidj" ? walletDoor() : ""}</div>
|
|
642
679
|
${badgeStrip(config.badges)}</div></section>`;
|
|
643
680
|
wireNav();
|
|
644
681
|
element("anonymous")?.addEventListener("click", () => {
|
|
@@ -892,10 +929,11 @@ function completeSignIn() {
|
|
|
892
929
|
}
|
|
893
930
|
await refresh();
|
|
894
931
|
anonymous = false;
|
|
895
|
-
// A new account belongs where a returning one lands: inside the app
|
|
896
|
-
// Sending it to the account card instead dropped
|
|
897
|
-
// signed up on the shell, one click short of the app
|
|
898
|
-
|
|
932
|
+
// A new account belongs where a returning one lands: inside the app, on the
|
|
933
|
+
// screen it asked for. Sending it to the account card instead dropped
|
|
934
|
+
// people who had just signed up on the shell, one click short of the app
|
|
935
|
+
// they came for.
|
|
936
|
+
navigate(takeReturnRoute());
|
|
899
937
|
})();
|
|
900
938
|
}
|
|
901
939
|
|
|
@@ -903,6 +941,7 @@ function completeSignIn() {
|
|
|
903
941
|
// the roles it reads, the agreement, the optional choices, the export and
|
|
904
942
|
// the way out of the app itself.
|
|
905
943
|
function wirePrivacy() {
|
|
944
|
+
bindMemberHistory(root);
|
|
906
945
|
element("refresh")?.addEventListener("click", () => void action(refresh));
|
|
907
946
|
element("signout")?.addEventListener(
|
|
908
947
|
"click",
|
|
@@ -1294,7 +1333,7 @@ function boot() {
|
|
|
1294
1333
|
if (interactionId) return;
|
|
1295
1334
|
if (oidc && new URL(window.location.href).searchParams.has("state")) {
|
|
1296
1335
|
const callback = new URL(window.location.href);
|
|
1297
|
-
window.history.replaceState(null, "", window.location.pathname + "#/
|
|
1336
|
+
window.history.replaceState(null, "", window.location.pathname + "#/" + takeReturnRoute());
|
|
1298
1337
|
try {
|
|
1299
1338
|
await oidc.completeLogin(callback);
|
|
1300
1339
|
} catch (refusal) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, formatDate, type SigninShape} from "@ofidj/entry";
|
|
2
|
-
import {acceptedAgreement, agreementScreen, memberCard, bindAgreementScreen, bindPasswordReveal, providerEntry, showEmailEntry, showVersionBadge} from "@ofidj/entry/dom";
|
|
2
|
+
import {acceptedAgreement, agreementScreen, memberCard, bindMemberHistory, bindAgreementScreen, bindPasswordReveal, providerEntry, showEmailEntry, showVersionBadge} from "@ofidj/entry/dom";
|
|
3
3
|
import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "@ofidj/entry/window";
|
|
4
4
|
import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
|
|
5
5
|
import "@ofidj/entry/style.css";
|
|
6
|
+
import {authorizedRequest} from "./api-request";
|
|
6
7
|
|
|
7
8
|
type Session = { username: string; roles: string[] };
|
|
8
9
|
type Note = { id: string; title: string; body: string; createdAt: string };
|
|
@@ -56,26 +57,16 @@ const escape = (value: unknown) =>
|
|
|
56
57
|
);
|
|
57
58
|
const el = <T extends HTMLElement>(id: string) =>
|
|
58
59
|
document.getElementById(id) as T;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const result = await response.json();
|
|
70
|
-
if (!response.ok) {
|
|
71
|
-
if (response.status === 401) {
|
|
72
|
-
session = null;
|
|
73
|
-
await sdk.logout(true);
|
|
74
|
-
}
|
|
75
|
-
throw new Error(result.message || "Please retry.");
|
|
76
|
-
}
|
|
77
|
-
return result;
|
|
78
|
-
}
|
|
60
|
+
// Refreshes the SDK's token before sending it, and retries a 401 once before
|
|
61
|
+
// ending the session: see api-request.ts.
|
|
62
|
+
const api = authorizedRequest({
|
|
63
|
+
sdk,
|
|
64
|
+
providerSession: () => !!oidc?.hasSession(),
|
|
65
|
+
baseUrl: "/api/",
|
|
66
|
+
onSignedOut: () => {
|
|
67
|
+
session = null;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
79
70
|
// The credential fields, in one place because the entry shows them beside the
|
|
80
71
|
// Fidj door rather than instead of it.
|
|
81
72
|
function credentialFields() {
|
|
@@ -247,6 +238,7 @@ function render() {
|
|
|
247
238
|
// The app's own form, folded away under the Fidj door rather than beside it.
|
|
248
239
|
if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
|
|
249
240
|
if (!pendingAgreement && el("signin")) bindPasswordReveal(el("signin")!);
|
|
241
|
+
bindMemberHistory(root);
|
|
250
242
|
el("use-email")?.addEventListener("click", () => {
|
|
251
243
|
emailEntryOpen = !emailEntryOpen;
|
|
252
244
|
showEmailEntry(emailEntryOpen, true);
|