@ofidj/generator-fidj 3.18.1 → 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.
|
@@ -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,30 +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
|
-
// Only a 401 means the session is gone; a 403 refuses one action to
|
|
171
|
-
// somebody still signed in, and signing them out for it ended the console's
|
|
172
|
-
// session on the first forbidden request.
|
|
173
|
-
if (response.status === 401) {
|
|
174
|
-
signedIn = false;
|
|
175
|
-
await sdk.logout(true);
|
|
176
|
-
}
|
|
177
|
-
throw new Error(result.message || result.status || "Please retry.");
|
|
178
|
-
}
|
|
179
|
-
return result;
|
|
180
|
-
}
|
|
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
|
+
});
|
|
181
168
|
async function refresh() {
|
|
182
169
|
const membership = await request(
|
|
183
170
|
`/apps/${encodeURIComponent(config.appId)}/me`,
|
|
@@ -390,6 +377,7 @@ async function askWhetherFidjKnowsThisBrowser() {
|
|
|
390
377
|
return false;
|
|
391
378
|
}
|
|
392
379
|
try {
|
|
380
|
+
rememberRoute();
|
|
393
381
|
window.location.assign(await oidc.beginLogin({ silent: true }));
|
|
394
382
|
return true;
|
|
395
383
|
} catch {
|
|
@@ -451,7 +439,7 @@ function signInThroughProvider(
|
|
|
451
439
|
} catch {}
|
|
452
440
|
await refresh();
|
|
453
441
|
anonymous = false;
|
|
454
|
-
navigate(
|
|
442
|
+
navigate(takeReturnRoute());
|
|
455
443
|
});
|
|
456
444
|
}
|
|
457
445
|
// A person moving between screens is making history, so push an entry: Back has
|
|
@@ -464,6 +452,50 @@ function navigate(route: string) {
|
|
|
464
452
|
if (!busy) render();
|
|
465
453
|
}
|
|
466
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
|
+
|
|
467
499
|
function moduleRoute() {
|
|
468
500
|
const route = window.location.hash.slice(2).split("?")[0];
|
|
469
501
|
if (
|
|
@@ -579,8 +611,10 @@ function render() {
|
|
|
579
611
|
!signedIn &&
|
|
580
612
|
!(config.allowAnonymous && anonymous) &&
|
|
581
613
|
!["forgot", "reset", "verify"].includes(route)
|
|
582
|
-
)
|
|
614
|
+
) {
|
|
615
|
+
if (route !== "signin") rememberRoute();
|
|
583
616
|
route = "signin";
|
|
617
|
+
}
|
|
584
618
|
else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
|
|
585
619
|
route = "content";
|
|
586
620
|
// The privacy screen and the profile screen became one. The address that
|
|
@@ -614,7 +648,7 @@ function render() {
|
|
|
614
648
|
// The two ways out, last and together: back to the app, or out of this
|
|
615
649
|
// session. Leaving the app itself is a different decision and stays where
|
|
616
650
|
// the things it erases are listed.
|
|
617
|
-
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>`;
|
|
618
652
|
renderNav("account");
|
|
619
653
|
wireAccount("account");
|
|
620
654
|
wireSignOut();
|
|
@@ -641,7 +675,7 @@ function render() {
|
|
|
641
675
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
642
676
|
${highlightCells(config.highlights)}</div>
|
|
643
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>` : ""}
|
|
644
|
-
${config.title === "Fidj" ? walletDoor() :
|
|
678
|
+
${config.title === "Fidj" ? walletDoor() : ""}</div>
|
|
645
679
|
${badgeStrip(config.badges)}</div></section>`;
|
|
646
680
|
wireNav();
|
|
647
681
|
element("anonymous")?.addEventListener("click", () => {
|
|
@@ -895,10 +929,11 @@ function completeSignIn() {
|
|
|
895
929
|
}
|
|
896
930
|
await refresh();
|
|
897
931
|
anonymous = false;
|
|
898
|
-
// A new account belongs where a returning one lands: inside the app
|
|
899
|
-
// Sending it to the account card instead dropped
|
|
900
|
-
// signed up on the shell, one click short of the app
|
|
901
|
-
|
|
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());
|
|
902
937
|
})();
|
|
903
938
|
}
|
|
904
939
|
|
|
@@ -906,6 +941,7 @@ function completeSignIn() {
|
|
|
906
941
|
// the roles it reads, the agreement, the optional choices, the export and
|
|
907
942
|
// the way out of the app itself.
|
|
908
943
|
function wirePrivacy() {
|
|
944
|
+
bindMemberHistory(root);
|
|
909
945
|
element("refresh")?.addEventListener("click", () => void action(refresh));
|
|
910
946
|
element("signout")?.addEventListener(
|
|
911
947
|
"click",
|
|
@@ -1297,7 +1333,7 @@ function boot() {
|
|
|
1297
1333
|
if (interactionId) return;
|
|
1298
1334
|
if (oidc && new URL(window.location.href).searchParams.has("state")) {
|
|
1299
1335
|
const callback = new URL(window.location.href);
|
|
1300
|
-
window.history.replaceState(null, "", window.location.pathname + "#/
|
|
1336
|
+
window.history.replaceState(null, "", window.location.pathname + "#/" + takeReturnRoute());
|
|
1301
1337
|
try {
|
|
1302
1338
|
await oidc.completeLogin(callback);
|
|
1303
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);
|