@ofidj/generator-fidj 3.18.1 → 3.19.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.
@@ -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.1"
18
+ "@ofidj/entry": "^3.19.0",
19
+ "@ofidj/node": "^3.19.0"
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,30 +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
- // 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("content");
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="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>`;
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() : `<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>
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 people who had just
900
- // signed up on the shell, one click short of the app they came for.
901
- 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());
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 + "#/content");
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
- 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.1",
3
+ "version": "3.19.0",
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": {