@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.0",
19
- "@ofidj/node": "^3.18.0"
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
- async function request(path: string, method = "GET", data?: unknown) {
158
- const token = await sdk.fidjGetIdToken();
159
- const response = await fetch(config.apiEndpoint + path, {
160
- method,
161
- headers: {
162
- Authorization: `Bearer ${token}`,
163
- "Content-Type": "application/json",
164
- },
165
- body: data === undefined ? undefined : JSON.stringify(data),
166
- signal: AbortSignal.timeout(10000),
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("content");
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="card profile-body">${banner()}${emailVerified ? accountRows() : accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail}, {compact: true})}${privacyBlock()}</div></section>`;
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() : `<div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>Your account, with Fidj</strong></p><p>Signing in creates one Fidj account you keep across every app that uses Fidj.</p><p>You choose what this app may store — and can export or erase it at any moment.</p></div>`}</div>
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 people who had just
897
- // signed up on the shell, one click short of the app they came for.
898
- navigate("content");
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 + "#/content");
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
- async function api(path: string, method = "GET", data?: unknown) {
60
- const token = await sdk.fidjGetIdToken();
61
- const response = await fetch("/api/" + path, {
62
- method,
63
- headers: {
64
- Authorization: `Bearer ${token}`,
65
- "Content-Type": "application/json",
66
- },
67
- body: data === undefined ? undefined : JSON.stringify(data),
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "3.18.0",
3
+ "version": "3.18.2",
4
4
  "description": "Generate a TypeScript app with Fidj sign-in, live server-side roles and per-app privacy.",
5
5
  "homepage": "https://fidj.ovh",
6
6
  "bugs": {