@ofidj/generator-fidj 3.16.0 → 3.18.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.16.0",
19
- "@ofidj/node": "^3.16.0"
18
+ "@ofidj/entry": "^3.18.0",
19
+ "@ofidj/node": "^3.18.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
@@ -1,5 +1,5 @@
1
- import {signInErrorMessage, 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, oidcInteractionMarkup, oidcInteractionStyles, bindOidcInteraction} from "@ofidj/entry/dom";
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";
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";
@@ -330,7 +330,10 @@ const PUBLIC_ROUTE = "pub";
330
330
  // spinner's worth of ceremony, and it says what it is doing.
331
331
  let recognising = false;
332
332
  function mightBeRecognised() {
333
- if (!oidc || !isFidjItself) return false;
333
+ // Fidj itself, or an app this browser signed in to before: its session lives
334
+ // in the tab, so a new tab would otherwise draw "Continue as …" for somebody
335
+ // Fidj still recognises.
336
+ if (!oidc || !(isFidjItself || signInHint(config.appId))) return false;
334
337
  if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
335
338
  // Not on a screen somebody was sent to by a link.
336
339
  //
@@ -354,7 +357,12 @@ function mightBeRecognised() {
354
357
  }
355
358
 
356
359
  async function askWhetherFidjKnowsThisBrowser() {
357
- if (!oidc || !isFidjItself || sdk.isLoggedIn() || oidc.signedOutHere())
360
+ if (
361
+ !oidc ||
362
+ !(isFidjItself || signInHint(config.appId)) ||
363
+ sdk.isLoggedIn() ||
364
+ oidc.signedOutHere()
365
+ )
358
366
  return false;
359
367
  if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
360
368
  // Not on a screen somebody was sent to by a link.
@@ -404,16 +412,16 @@ function signInThroughProvider(
404
412
  void action(async () => {
405
413
  let url: string;
406
414
  try {
407
- // Being recognised again is the one thing somebody who has just signed
408
- // out did not ask for, and skipping this is what let a sign-out be undone
409
- // by pressing the door again: the provider still knew the browser and
410
- // answered with a code, no screen at all. Ending the provider session is
411
- // what should make that impossible, and that call can be refused — so the
412
- // door asks rather than assumes, until somebody signs in again.
415
+ // Somebody who has just signed out of this app did not ask to be walked
416
+ // straight back in: the provider still knows the browser and would answer
417
+ // with a code and no screen. So the door asks which account — the consent
418
+ // screen names the one Fidj recognises and offers another. It does not ask
419
+ // for a password: prompt=login told Fidj "this is not me" and ended the
420
+ // Fidj session along with the app's.
413
421
  url = await oidc.beginLogin(
414
422
  options.prompt || options.silent || !oidc.signedOutHere()
415
423
  ? options
416
- : {...options, prompt: "login"},
424
+ : {...options, prompt: "consent"},
417
425
  );
418
426
  } catch (error) {
419
427
  providerWindow?.giveUp();
@@ -603,7 +611,7 @@ function render() {
603
611
  // The two ways out, last and together: back to the app, or out of this
604
612
  // session. Leaving the app itself is a different decision and stays where
605
613
  // the things it erases are listed.
606
- root.innerHTML = `<section class="content-account">${profileSummary()}<div class="card profile-body">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail}, {compact: true})}${privacyBlock()}</div></section>`;
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>`;
607
615
  renderNav("account");
608
616
  wireAccount("account");
609
617
  wireSignOut();
@@ -909,13 +917,15 @@ function wirePrivacy() {
909
917
  navigate("signin");
910
918
  }),
911
919
  );
912
- element("terms")?.addEventListener(
920
+ element("accept-terms")?.addEventListener(
913
921
  "click",
914
922
  () =>
915
923
  void action(async () => {
924
+ // The version published now is the version recorded.
925
+ const agreement = await readAgreement();
916
926
  await request(appPath + "/consents", "PUT", {
917
927
  terms: true,
918
- cguVersion: "starter-demo-1",
928
+ ...(agreement ? {cguVersion: agreement.version} : {}),
919
929
  source: "profile",
920
930
  });
921
931
  await refresh();
@@ -984,27 +994,22 @@ function wirePrivacy() {
984
994
  // about it. It used to be a screen of its own called My privacy, one tab
985
995
  // away from the account it was about — so a person looking for their own
986
996
  // data had two places to try and no way to tell which.
997
+ // The account, once there is nothing left to verify: one line, and the way to
998
+ // change it, which is on Fidj — the account is Fidj's, not this app's.
999
+ function accountRows() {
1000
+ return `<div class="member-rows"><div class="member-row"><div><strong>Email</strong><small>${escape(accountEmail)} · verified</small></div><a href="${escape(config.dashboardUrl)}/#/my/profile" target="_blank" rel="noopener noreferrer">Manage on Fidj ↗</a></div></div>`;
1001
+ }
1002
+
987
1003
  function privacyBlock() {
988
- return `<h2>What ${escape(config.title)} holds</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
989
- <p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
990
- <p>Service agreement: ${consent.terms ? "Accepted" : "Not recorded"}. ${consent.terms ? "Leaving withdraws this agreement." : 'This generated example uses a demo agreement. <button id="terms">Accept demo agreement</button>'}</p>
991
- ${["analytics", "communications", "optionalData"].map((key, i) => `<label class="toggle"><span>${["Analytics", "Communications", "Optional data"][i]}</span><input type="checkbox" data-purpose="${key}" ${consent[key] ? "checked" : ""}></label>`).join("")}
992
- <h3>Consent history</h3>${
993
- history.length
994
- ? history
995
- .slice()
996
- .reverse()
997
- .map(
998
- (entry) =>
999
- `<p>${escape(entry.type)} · ${entry.granted ? "Accepted" : "Withdrawn"} · ${escape(entry.changedAt)}</p>`,
1000
- )
1001
- .join("")
1002
- : "<p>No changes yet.</p>"
1003
- }
1004
- <button id="export">Export</button>
1005
- <p>This app stores its session in this browser. The export covers Fidj-held records for this membership. There is no separate app database in this static template.</p>
1006
- ${roles.includes("Owner") ? "<p>Resolve app ownership before leaving.</p>" : leaving ? '<p>Confirm departure: your membership and its Fidj-held data will be removed. Your other apps remain available.</p><button id="confirm-leave" class="danger">Leave &amp; erase</button><button id="cancel-leave">Keep my membership</button>' : '<button id="leave" class="danger">Leave &amp; erase</button>'}
1007
- <p class="leaving"><a href="${escape(config.dashboardUrl)}/#/my" target="_blank" rel="noopener">Open Fidj to manage every app you use ↗</a><br><small>Fidj is the account provider behind ${escape(config.title)}. This opens it in a new tab; you stay signed in here.</small></p>`;
1004
+ const version = String((consent as Record<string, unknown>).termsVersion || "");
1005
+ return memberCard({
1006
+ consent,
1007
+ history,
1008
+ agreementHref: `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(version)}`,
1009
+ owner: roles.includes("Owner"),
1010
+ leaving,
1011
+ manageHref: `${config.dashboardUrl}/#/my/gdpr`,
1012
+ });
1008
1013
  }
1009
1014
 
1010
1015
  // ------------------------------------------------ signing in, for the provider
@@ -1031,6 +1036,10 @@ type Interaction = {
1031
1036
  agreement?: {version: string; text: string} | null;
1032
1037
  // Who the consent screen recognises, as the provider names them.
1033
1038
  recognisedEmail?: string;
1039
+ // The passkey door's challenge, on the login step.
1040
+ passkey?: {ticket: string; options: unknown};
1041
+ // The recognised person's agreement is on file at the version in force.
1042
+ agreementAccepted?: boolean;
1034
1043
  action: string;
1035
1044
  };
1036
1045
  let interactionId = "";
@@ -1155,6 +1164,8 @@ function interactionScreen() {
1155
1164
  // The agreement is a document read in the browser, like the privacy notice.
1156
1165
  agreementHref: details.termsUri || undefined,
1157
1166
  recognisedEmail: details.recognisedEmail || undefined,
1167
+ passkey: details.passkey,
1168
+ agreementAccepted: details.agreementAccepted,
1158
1169
  logoSrc: "./fidj-logo.png",
1159
1170
  })}`;
1160
1171
  bindOidcInteraction(root);
@@ -1,5 +1,5 @@
1
- import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, type SigninShape} from "@ofidj/entry";
2
- import {acceptedAgreement, agreementScreen, bindAgreementScreen, bindPasswordReveal, providerEntry, showEmailEntry, showVersionBadge} from "@ofidj/entry/dom";
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";
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";
@@ -223,19 +223,19 @@ function render() {
223
223
  <nav><button id="workspace-tab" class="${view === "workspace" ? "selected" : ""}">My notes</button><button id="privacy-tab" class="${view === "privacy" ? "selected" : ""}">My privacy</button><button id="refresh">Refresh access</button></nav>
224
224
  ${
225
225
  view === "workspace"
226
- ? `<section class="workspace"><div><div class="section-heading"><h2>Your notes</h2><span>${notes.length} saved</span></div>${notes.length ? notes.map((note) => `<article class="card note"><small>${escape(new Date(note.createdAt).toLocaleString())}</small><h3>${escape(note.title)}</h3><p>${escape(note.body)}</p></article>`).join("") : `<article class="card empty"><span>✳</span><h3>Room for your next idea.</h3><p>Your saved notes will appear here. Only you can read your notes.</p></article>`}</div><form id="note" class="card"><p class="eyebrow">CAPTURE SOMETHING</p><h2>A fresh note</h2><p>${canWrite ? "You have permission to save notes." : "Ask your app owner for Editor access to save notes."}</p><label for="note-title">Title</label><input id="note-title" maxlength="120" required ${!canWrite ? "disabled" : ""}><label for="note-body">Your note</label><textarea id="note-body" rows="6" maxlength="5000" ${!canWrite ? "disabled" : ""}></textarea><button class="primary" type="submit" ${!canWrite ? "disabled" : ""}>Save note</button><small>Your notes are saved by this app and survive a server restart.</small></form></section>`
227
- : `<section class="privacy-grid"><article class="card"><p class="eyebrow">ONLY ${escape(settings.title).toUpperCase()}</p><h2>Your choices in this app</h2><p>These preferences are independent of Fidj and your other apps.</p><div class="agreement"><strong>Service agreement</strong><span>${privacy?.consent.terms ? "Accepted" : "Not recorded"}</span>${!privacy?.consent.terms ? '<p>This starter uses a demo agreement. Accept it to record your choice.</p><button id="accept-terms">Accept demo agreement</button>' : "<p>Leaving this app withdraws its required agreement.</p>"}</div>${["analytics", "communications", "optionalData"].map((key, index) => `<label class="toggle"><span><strong>${["Analytics", "Communications", "Optional data"][index]}</strong><small>${["Help improve this app.", "Receive optional news and updates.", "Allow data beyond the essential service."][index]}</small></span><input type="checkbox" data-purpose="${key}" ${privacy?.consent[key] ? "checked" : ""}></label>`).join("")}<button id="export">Export</button></article><article class="card"><h2>Consent history</h2>${
228
- privacy?.history.length
229
- ? privacy.history
230
- .slice()
231
- .reverse()
232
- .map(
233
- (entry) =>
234
- `<p><strong>${escape(entry.type)}</strong> · ${entry.granted ? "Accepted" : "Withdrawn"}<br><small>${escape(new Date(entry.changedAt).toLocaleString())}</small></p>`,
235
- )
236
- .join("")
237
- : "<p>No changes yet.</p>"
238
- }<hr><h2>Leave this app</h2><p>This removes your app membership and this starter’s notes. Your Fidj account and other memberships remain.</p>${session.roles.includes("Owner") ? "<p>As the app owner, resolve ownership before leaving.</p>" : leaving ? '<div role="alertdialog" aria-labelledby="leave-title"><h3 id="leave-title">Confirm departure</h3><p>Your membership, consent and notes in this app will be removed. Your Fidj account and other apps remain available.</p><button id="confirm-leave" class="danger">Leave &amp; erase</button><button id="cancel-leave">Keep my membership</button></div>' : '<button id="leave" class="danger">Leave &amp; erase</button>'}<p class="fineprint">Exports here include your Fidj membership and this starter’s notes. The registered app-data handler lets Fidj export and erase these notes too. If cleanup is pending, retry from My privacy on Fidj. Minimal completion receipts are retained; backups and unregistered systems are outside this operation.</p></article></section>`
226
+ ? `<section class="workspace"><div><div class="section-heading"><h2>Your notes</h2><span>${notes.length} saved</span></div>${notes.length ? notes.map((note) => `<article class="card note"><small>${escape(formatDate(note.createdAt, "datetime"))}</small><h3>${escape(note.title)}</h3><p>${escape(note.body)}</p></article>`).join("") : `<article class="card empty"><span>✳</span><h3>Room for your next idea.</h3><p>Your saved notes will appear here. Only you can read your notes.</p></article>`}</div><form id="note" class="card"><p class="eyebrow">CAPTURE SOMETHING</p><h2>A fresh note</h2><p>${canWrite ? "You have permission to save notes." : "Ask your app owner for Editor access to save notes."}</p><label for="note-title">Title</label><input id="note-title" maxlength="120" required ${!canWrite ? "disabled" : ""}><label for="note-body">Your note</label><textarea id="note-body" rows="6" maxlength="5000" ${!canWrite ? "disabled" : ""}></textarea><button class="primary" type="submit" ${!canWrite ? "disabled" : ""}>Save note</button><small>Your notes are saved by this app and survive a server restart.</small></form></section>`
227
+ : `<section class="card privacy-card">${memberCard({
228
+ consent: privacy?.consent || {},
229
+ history: privacy?.history || [],
230
+ agreementHref: privacy?.consent.termsVersion
231
+ ? `${settings.apiEndpoint}/apps/${encodeURIComponent(settings.appId)}/agreements/${encodeURIComponent(String(privacy.consent.termsVersion))}`
232
+ : undefined,
233
+ owner: session.roles.includes("Owner"),
234
+ leaving,
235
+ leaveScope:
236
+ "Your membership, your choices and your notes in this app will be erased. Your Fidj account and other apps remain available.",
237
+ manageHref: `${settings.dashboardUrl}/#/my/gdpr`,
238
+ })}</section>`
239
239
  }`
240
240
  }
241
241
  <footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
@@ -348,7 +348,7 @@ function render() {
348
348
  void action(async () => {
349
349
  await api("privacy", "PUT", { terms: true });
350
350
  await load();
351
- notice = "Demo agreement accepted.";
351
+ notice = "Service agreement accepted.";
352
352
  }),
353
353
  );
354
354
  el("export")?.addEventListener(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "3.16.0",
3
+ "version": "3.18.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": {