@ofidj/generator-fidj 3.11.0 → 3.13.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.
package/README.md CHANGED
@@ -64,6 +64,13 @@ generated to reach them. A style change now lands in that package, and this
64
64
  template is what remains genuinely the generator's: the shells that hold the
65
65
  screens, the content app and the Notes app.
66
66
 
67
+ This is an architecture boundary, not only a packaging detail: never copy a
68
+ credential, signup, recovery or agreement component into a generated output.
69
+ The template may compose a shell and call `@ofidj/entry` after dynamic rendering,
70
+ but the markup rules and browser behavior such as password reveal belong in
71
+ `fidj-entry`. A fix there must reach Fidj, mleweb and newly generated apps
72
+ without maintaining parallel component implementations.
73
+
67
74
  The design system is `@ofidj/entry`'s `tokens.css` — every colour, family and
68
75
  radius — and its `style.css`, which may not introduce a literal of its own.
69
76
  Fonts are self-hosted under `public/fonts` and served from the build manifest,
@@ -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.11.0",
19
- "@ofidj/node": "^3.11.0"
18
+ "@ofidj/entry": "^3.13.0",
19
+ "@ofidj/node": "^3.13.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
@@ -342,7 +342,8 @@ if (require.main === module) {
342
342
  localDemo:
343
343
  process.env.LOCAL_DEMO === "true" &&
344
344
  host === "127.0.0.1" &&
345
- ["localhost", "127.0.0.1"].includes(api.hostname),
345
+ (["localhost", "127.0.0.1"].includes(api.hostname) ||
346
+ api.hostname.endsWith(".localhost")),
346
347
  };
347
348
  createApp(settings, {
348
349
  dataDir: process.env.FIDJ_DATA_DIR,
@@ -1,5 +1,5 @@
1
- import {signInErrorMessage, agreementRequired, agreementFromRefusal, verificationPending, pollVerification, rememberSignIn, forgetSignIn, signInHint, type SigninShape} from "@ofidj/entry";
2
- import {agreementScreen, bindAgreementScreen, acceptedAgreement, verificationWait, providerEntry, showEmailEntry, showVersionBadge, escape, masthead, highlightCells, badgeStrip, credentialFields, accountForm, returnNotice} from "@ofidj/entry/dom";
1
+ import {agreementModel, 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, returnNotice} 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";
@@ -123,7 +123,10 @@ function wireSignOut() {
123
123
  () =>
124
124
  void action(async () => {
125
125
  const wasSignedIn = signedIn;
126
- if (signedIn) await sdk.logout(true);
126
+ if (signedIn) {
127
+ if (oidc?.hasSession()) await oidc.logout();
128
+ else await sdk.logout(true);
129
+ }
127
130
  forgetSignIn(config.appId);
128
131
  signedIn = false;
129
132
  anonymous = false;
@@ -497,6 +500,8 @@ function render() {
497
500
  if (interactionId && !addressedInteraction()) {
498
501
  interactionId = "";
499
502
  interactionError = "";
503
+ interactionResent = false;
504
+ interactionNotYet = false;
500
505
  interaction = null;
501
506
  interactionFailed = false;
502
507
  }
@@ -609,18 +614,10 @@ function render() {
609
614
  root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
610
615
  <div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
611
616
  ${highlightCells(config.highlights)}</div>
612
- <div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin"><label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" placeholder="you@company.com" autocomplete="username" required><div class="field-head"><label for="password">Password</label><a href="#/forgot">Forgot?</a></div><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" placeholder="••••••••••" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div><button class="primary" type="submit">Sign in</button><button class="secondary" type="submit" name="signup" value="true">Create an account</button></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>` : ""}
617
+ <div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin">${credentialFields({email: signInEmail, password: signInPassword})}</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>` : ""}
613
618
  <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>
614
619
  ${badgeStrip(config.badges)}</div></section>`;
615
620
  wireNav();
616
- element("reveal")?.addEventListener("click", () => {
617
- const field = element<HTMLInputElement>("password");
618
- const button = element("reveal");
619
- if (!field || !button) return;
620
- const hidden = field.type === "password";
621
- field.type = hidden ? "text" : "password";
622
- button.textContent = hidden ? "Hide" : "Show";
623
- });
624
621
  element("anonymous")?.addEventListener("click", () => {
625
622
  if (!config.allowAnonymous) return;
626
623
  anonymous = true;
@@ -647,13 +644,14 @@ function render() {
647
644
  // form's place rather than sitting under it: the credentials were accepted,
648
645
  // and what is left is a decision about this app.
649
646
  if (pendingAgreement && element("signin")) {
650
- element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement);
647
+ element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement, `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
651
648
  bindAgreementScreen(element<HTMLFormElement>("signin"));
652
649
  }
653
650
  // The app's own form, for whoever came to type a password. Folded away rather
654
651
  // than removed: the door above it is the one to take, and the person who
655
652
  // wants this one is one click from it.
656
653
  if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
654
+ if (!pendingAgreement && element("signin")) bindPasswordReveal(element("signin")!);
657
655
  element("use-email")?.addEventListener("click", () => {
658
656
  emailEntryOpen = !emailEntryOpen;
659
657
  showEmailEntry(emailEntryOpen, true);
@@ -817,14 +815,12 @@ function completeSignIn() {
817
815
  // every app must not be mintable by one that collects its own passwords.
818
816
  if (isFidjItself) {
819
817
  try {
820
- await sdk.sendOnEndpoint({
821
- verb: "POST",
822
- key: "me",
823
- relativePath: "oidc/session",
824
- // The whole point of the call is the cookie it comes back with, and
825
- // a cross-origin response's Set-Cookie is dropped without this.
826
- withCredentials: true,
827
- });
818
+ const transfer = await request("/me/oidc/session-transfer", "POST");
819
+ // The provider cookie belongs to the API origin. A cross-site fetch
820
+ // cannot reliably replace it, so let that origin answer once as the
821
+ // top-level page, consume a one-use ticket, then send us back here.
822
+ window.location.assign(transfer.location);
823
+ return;
828
824
  } catch {
829
825
  // Being signed in here still worked. The person is inside Fidj; what
830
826
  // they lose is being recognised by the next app without typing again,
@@ -970,14 +966,24 @@ function privacyBlock() {
970
966
  type Interaction = {
971
967
  prompt: string;
972
968
  csrf: string;
969
+ // The address this interaction is waiting on, when it is waiting rather than
970
+ // asking. Empty on every other screen.
971
+ awaiting?: string;
973
972
  app: { id: string; title: string; description: string };
974
973
  scopes: string[];
975
974
  termsUri: string;
976
975
  privacyUri: string;
976
+ // The agreement's own text, so it can be read here rather than in another tab.
977
+ agreement?: {version: string; text: string} | null;
977
978
  action: string;
978
979
  };
979
980
  let interactionId = "";
980
981
  let interactionError = "";
982
+ // Whether the link was just sent again, or pressed Continue before opening it.
983
+ // They are about the wait, not about the person, so they travel in the address
984
+ // where the address itself does not.
985
+ let interactionResent = false;
986
+ let interactionNotYet = false;
981
987
  let interaction: Interaction | null = null;
982
988
  let interactionFailed = false;
983
989
 
@@ -1026,6 +1032,8 @@ function readInteraction() {
1026
1032
  if (!uid) return false;
1027
1033
  interactionId = uid;
1028
1034
  interactionError = parameters.get("error") || "";
1035
+ interactionResent = parameters.get("resent") === "1";
1036
+ interactionNotYet = parameters.get("notyet") === "1";
1029
1037
  // The id stays in the address while the screen is up. Taking it out looked
1030
1038
  // tidier and made the screen a trap: the address became "#/signin", so going
1031
1039
  // back to "#/signin" changed nothing, the document never reloaded, and the
@@ -1049,6 +1057,15 @@ async function loadInteraction() {
1049
1057
  }
1050
1058
 
1051
1059
 
1060
+ // What the wait has to say beyond the address, if anything.
1061
+ function interactionWaitNotice() {
1062
+ if (interactionNotYet)
1063
+ return '<p class="fineprint">The link has not been opened yet. Open it, then press Continue again.</p>';
1064
+ if (interactionResent)
1065
+ return '<p class="fineprint">The link was sent again. Only the newest one works.</p>';
1066
+ return "";
1067
+ }
1068
+
1052
1069
  function interactionScreen() {
1053
1070
  const details = interaction!;
1054
1071
  const asking = escape(details.app.title);
@@ -1067,8 +1084,24 @@ function interactionScreen() {
1067
1084
  // sees your password" about itself is nonsense in the same family as offering
1068
1085
  // to sign in with Fidj on Fidj.
1069
1086
  const itself = details.app.id === config.appId;
1070
- const body =
1071
- details.prompt === "login"
1087
+ // The wait, when this interaction is waiting on an address rather than asking
1088
+ // for anything. It is not a refusal and must not be drawn as one: the account
1089
+ // exists, the link was sent, and what is left is opening it. The address is
1090
+ // read from the interaction's own context rather than from the URL, so
1091
+ // nothing about the person travels in an address bar.
1092
+ const body = details.awaiting
1093
+ ? `<h2>Check your email</h2>
1094
+ <p class="signin-lead">Your account is created. Waiting for you to open the link sent to <strong>${escape(details.awaiting)}</strong>.</p>
1095
+ ${returnNotice(itself ? "Fidj" : asking)}
1096
+ <form method="post" action="${escape(action)}" id="interaction">
1097
+ <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1098
+ <p class="fineprint">The link may take a minute, and it sometimes lands in spam. Open it, then come back here.</p>
1099
+ ${interactionWaitNotice()}
1100
+ <button class="primary" type="submit" name="action" value="continue">Continue</button>
1101
+ <button class="secondary" type="submit" name="action" value="resend">Send the link again</button>
1102
+ <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1103
+ </form>`
1104
+ : details.prompt === "login"
1072
1105
  ? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
1073
1106
  <p class="signin-lead">${itself ? "One account across every app that uses Fidj, and a separate set of choices for each one." : `This is Fidj, the account behind ${asking}. One account, and separate choices for every app that uses it — ${asking} never sees your password.`}</p>
1074
1107
  ${returnNotice(itself ? "Fidj" : asking)}
@@ -1092,7 +1125,8 @@ function interactionScreen() {
1092
1125
  .join("")}</ul>
1093
1126
  <form method="post" action="${escape(action)}" id="interaction">
1094
1127
  <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1095
- <label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>I accept ${asking}'s service agreement.</span></label>
1128
+ <label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>${escape(agreementModel(details.app.title, {}).checkboxLabel)}</span></label>
1129
+ ${details.agreement ? `<details class="agreement"><summary>Read service agreement</summary><p class="fineprint">Version ${escape(details.agreement.version)}</p><p>${escape(details.agreement.text)}</p></details><p class="fineprint">Required to sign in. Optional data choices stay separate.</p>` : ""}
1096
1130
  ${details.termsUri ? `<p class="fineprint"><a href="${escape(details.termsUri)}" target="_blank" rel="noopener noreferrer">Service agreement</a>${details.privacyUri ? ` · <a href="${escape(details.privacyUri)}" target="_blank" rel="noopener noreferrer">Privacy notice</a>` : ""}</p>` : ""}
1097
1131
  <button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
1098
1132
  <button class="quiet" type="submit" id="not-me" name="action" value="switch" formnovalidate>Not you? Sign in with another account</button>
@@ -1106,14 +1140,7 @@ function interactionScreen() {
1106
1140
  <div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>What Fidj is</strong></p><p>Fidj holds your account so each app does not have to. You can see every app you use, what it holds, and take it back — at any time.</p></div></div>
1107
1141
  ${badgeStrip(config.badges)}</section>`;
1108
1142
 
1109
- element("reveal")?.addEventListener("click", () => {
1110
- const field = element<HTMLInputElement>("password");
1111
- const button = element("reveal");
1112
- if (!field || !button) return;
1113
- const hidden = field.type === "password";
1114
- field.type = hidden ? "text" : "password";
1115
- button.textContent = hidden ? "Hide" : "Show";
1116
- });
1143
+ if (element("interaction")) bindPasswordReveal(element("interaction")!);
1117
1144
  // Being recognised is the point, and a dead end when the person is not who
1118
1145
  // Fidj thinks — a shared computer, a second account, somebody else's tab. So
1119
1146
  // the screen that recognises them asks again on request. It goes back to the
@@ -1141,6 +1168,7 @@ function renderAccount(route: string) {
1141
1168
  <div class="signin-identity"><h1>Your account.<br>Your control.</h1><p class="signin-description">Secure access to the apps you use, with one Fidj identity.</p></div>
1142
1169
  </div>
1143
1170
  <div class="signin-form"><div>${banner()}${accountForm(route, {linkToken, verificationConfirmed, emailVerified, accountEmail})}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
1171
+ bindPasswordReveal(root);
1144
1172
  wireAccount(route);
1145
1173
  }
1146
1174
 
@@ -1,5 +1,5 @@
1
1
  import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, type SigninShape} from "@ofidj/entry";
2
- import {acceptedAgreement, agreementScreen, bindAgreementScreen, providerEntry, showEmailEntry, showVersionBadge} from "@ofidj/entry/dom";
2
+ import {acceptedAgreement, agreementScreen, 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";
@@ -79,7 +79,7 @@ async function api(path: string, method = "GET", data?: unknown) {
79
79
  // The credential fields, in one place because the entry shows them beside the
80
80
  // Fidj door rather than instead of it.
81
81
  function credentialFields() {
82
- return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username"><label for="password">Password</label><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password"><button class="primary" type="submit" name="entry" value="credentials">Continue</button>`;
82
+ return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username"><label for="password">Password</label><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password"><button type="button" aria-controls="password">Show</button></div><button class="primary" type="submit" name="entry" value="credentials">Continue</button>`;
83
83
  }
84
84
 
85
85
  async function load() {
@@ -241,11 +241,12 @@ function render() {
241
241
  <footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
242
242
  // The agreement takes the form's place once the API says this app is owed one.
243
243
  if (pendingAgreement && el("signin")) {
244
- el("signin")!.innerHTML = agreementScreen(settings.title, pendingAgreement);
244
+ el("signin")!.innerHTML = agreementScreen(settings.title, pendingAgreement, `${settings.apiEndpoint}/apps/${encodeURIComponent(settings.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
245
245
  bindAgreementScreen(el<HTMLFormElement>("signin"));
246
246
  }
247
247
  // The app's own form, folded away under the Fidj door rather than beside it.
248
248
  if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
249
+ if (!pendingAgreement && el("signin")) bindPasswordReveal(el("signin")!);
249
250
  el("use-email")?.addEventListener("click", () => {
250
251
  emailEntryOpen = !emailEntryOpen;
251
252
  showEmailEntry(emailEntryOpen, true);
package/lib/scaffold.cjs CHANGED
@@ -81,7 +81,8 @@ function scaffold(destination, options) {
81
81
  );
82
82
  if (
83
83
  api.protocol === "http:" &&
84
- !["localhost", "127.0.0.1", "[::1]"].includes(api.hostname)
84
+ !["localhost", "127.0.0.1", "[::1]"].includes(api.hostname) &&
85
+ !api.hostname.endsWith(".localhost")
85
86
  )
86
87
  throw new Error("Use HTTPS for a remote API.");
87
88
  if (options.oidcIssuer) {
@@ -198,7 +199,7 @@ function scaffold(destination, options) {
198
199
  const issuerSetting = options.oidcIssuer ? `FIDJ_OIDC_ISSUER=${options.oidcIssuer}\n` : "";
199
200
  const settings = `FIDJ_APP_ID=${options.appId}\nFIDJ_API_ENDPOINT=${api.href.replace(/\/$/, "")}\n${issuerSetting}FIDJ_DASHBOARD_URL=https://fidj.ovh\nAPP_TITLE=${title}\nAPP_VERSION=${releaseVersion}\nFIDJ_SIGNIN=${signin}\nPORT=8200\nHOST=127.0.0.1\nLOCAL_DEMO=false\nFIDJ_DATA_DIR=../.fidj-data/${name}\nFIDJ_PRIVACY_ADAPTER_KEY=\n`;
200
201
  const localApi = options.local
201
- ? "http://localhost:3201/v3"
202
+ ? process.env.FIDJ_LOCAL_API_ENDPOINT || "http://localhost:3201/v3"
202
203
  : api.href.replace(/\/$/, "");
203
204
  const configuration = {
204
205
  appId: options.appId,
@@ -209,7 +210,9 @@ function scaffold(destination, options) {
209
210
  ? new URL("/oidc", localApi).href
210
211
  : null,
211
212
  apiEndpoint: localApi,
212
- dashboardUrl: options.local ? "http://localhost:4200" : dashboardFor(api),
213
+ dashboardUrl: options.local
214
+ ? process.env.FIDJ_LOCAL_DASHBOARD_URL || "http://localhost:4200"
215
+ : dashboardFor(api),
213
216
  title,
214
217
  releaseVersion,
215
218
  localDemo: Boolean(options.local),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "3.11.0",
3
+ "version": "3.13.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": {