@ofidj/generator-fidj 3.11.0 → 3.14.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.14.0",
19
+ "@ofidj/node": "^3.14.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
@@ -32,6 +32,6 @@ function mountedHead(config) {
32
32
  export function renderContent(config) {
33
33
  // HTML is supplied by the developer at generation time, never by an app visitor.
34
34
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escape(config.title)}</title><link rel="icon" href="${escape(config.favicon)}"><link rel="stylesheet" href="./main.css">${mountedHead(config)}</head><body>
35
- <header class="topbar"><a class="brand" href="#/content"><img src="${escape(config.logo)}" alt="">${escape(config.title)}</a><nav class="content-nav" id="app-nav" aria-label="App navigation" hidden></nav></header>
35
+ <header class="topbar"><a class="brand" href="#/content"><img src="${escape(config.logo)}" alt=""><span class="brand-name">${escape(config.title)}</span></a><nav class="content-nav" id="app-nav" aria-label="App navigation" hidden></nav></header>
36
36
  <main><template id="public-content"><section class="card public-content"><h1>${escape(config.welcome)}</h1><div>${config.content}</div></section></template><div id="app" aria-live="polite"></div><footer>Built with Fidj · Your choices belong to this app.</footer></main><script type="module" src="./main.js"></script></body></html>`;
37
37
  }
@@ -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";
@@ -45,7 +45,7 @@ let accountEmail = "";
45
45
  // with the agreement unchecked folded the form away and left the complaint
46
46
  // floating above a door the person could no longer see.
47
47
  let emailEntryOpen = false;
48
- const accountRoutes = ["forgot", "reset", "verify", "account"];
48
+ const accountRoutes = ["forgot", "reset", "verify", "profile"];
49
49
  let linkToken = "";
50
50
  let verificationConfirmed = false;
51
51
  function currentRoute() {
@@ -88,17 +88,23 @@ function banner() {
88
88
  // account, and it lives on that account's screen.
89
89
  function appNav(current: "content" | "account") {
90
90
  const tab = (id: string, label: string, selected: boolean) =>
91
- `<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
91
+ `<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}><span class="tab-label">${label}</span></button>`;
92
92
  const account = signedIn
93
93
  ? tab(
94
94
  "account-tab",
95
- accountEmail ? `Account (${escape(accountEmail)})` : "Account",
95
+ accountEmail ? `Profile (${escape(accountEmail)})` : "Profile",
96
96
  current === "account",
97
97
  )
98
98
  : tab("account-tab", "Sign in", false);
99
99
  return tab("content-tab", "Content", current === "content") + account;
100
100
  }
101
101
 
102
+ function profileSummary() {
103
+ const publicUrl = `${config.dashboardUrl}/#/pub/${encodeURIComponent(config.appId)}`;
104
+ const badgeUrl = `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/badge`;
105
+ return `<header class="profile-summary"><div class="profile-summary-copy"><span class="eyebrow">Profile</span><strong>${escape(accountEmail)}</strong><span>${escape(config.title)}</span></div><a class="profile-public" href="${escape(publicUrl)}" target="_blank" rel="noopener"><img src="${escape(badgeUrl)}" alt="${escape(config.title)} public badge" width="133" height="20"></a><button id="exit" class="danger">Sign out</button></header>`;
106
+ }
107
+
102
108
  // The bar belongs to the document, not to the screen being drawn: it survives
103
109
  // every route change, so it is filled rather than rebuilt with the page.
104
110
  function renderNav(current: "content" | "account") {
@@ -112,7 +118,7 @@ function renderNav(current: "content" | "account") {
112
118
  function wireNav() {
113
119
  element("content-tab")?.addEventListener("click", () => navigate("content"));
114
120
  element("account-tab")?.addEventListener("click", () =>
115
- navigate(signedIn ? "account" : "signin"),
121
+ navigate(signedIn ? "profile" : "signin"),
116
122
  );
117
123
  }
118
124
 
@@ -123,7 +129,10 @@ function wireSignOut() {
123
129
  () =>
124
130
  void action(async () => {
125
131
  const wasSignedIn = signedIn;
126
- if (signedIn) await sdk.logout(true);
132
+ if (signedIn) {
133
+ if (oidc?.hasSession()) await oidc.logout();
134
+ else await sdk.logout(true);
135
+ }
127
136
  forgetSignIn(config.appId);
128
137
  signedIn = false;
129
138
  anonymous = false;
@@ -497,6 +506,8 @@ function render() {
497
506
  if (interactionId && !addressedInteraction()) {
498
507
  interactionId = "";
499
508
  interactionError = "";
509
+ interactionResent = false;
510
+ interactionNotYet = false;
500
511
  interaction = null;
501
512
  interactionFailed = false;
502
513
  }
@@ -552,15 +563,15 @@ function render() {
552
563
  route = "signin";
553
564
  else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
554
565
  route = "content";
555
- // The privacy screen and the account screen became one. The address that
566
+ // The privacy screen and the profile screen became one. The address that
556
567
  // named the first still means something to anyone who bookmarked it, so it
557
568
  // arrives where that screen went rather than nowhere.
558
- if (route === "privacy") route = signedIn ? "account" : "signin";
569
+ if (route === "privacy") route = signedIn ? "profile" : "signin";
559
570
  window.history.replaceState(null, "", "#/" + route);
560
- // My account is a signed-in screen and keeps the app's chrome. Recovery and
571
+ // Profile is a signed-in screen and keeps the app's chrome. Recovery and
561
572
  // verification are reached without a session, so they stand alone.
562
573
  const standaloneAccount =
563
- accountRoutes.includes(route) && !(route === "account" && signedIn);
574
+ accountRoutes.includes(route) && !(route === "profile" && signedIn);
564
575
  document.body.classList.toggle(
565
576
  "signin-view",
566
577
  route === "signin" || standaloneAccount,
@@ -579,11 +590,11 @@ function render() {
579
590
  renderAccount(route);
580
591
  return;
581
592
  }
582
- if (route === "account") {
593
+ if (route === "profile") {
583
594
  // The two ways out, last and together: back to the app, or out of this
584
595
  // session. Leaving the app itself is a different decision and stays where
585
596
  // the things it erases are listed.
586
- root.innerHTML = `<section class="card content-account">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail})}${privacyBlock()}<div class="account-actions"><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button><button id="exit">Sign out</button></div></section>`;
597
+ root.innerHTML = `<section class="content-account">${profileSummary()}<div class="card profile-body">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail}, {compact: true})}${privacyBlock()}</div></section>`;
587
598
  renderNav("account");
588
599
  wireAccount("account");
589
600
  wireSignOut();
@@ -609,18 +620,10 @@ function render() {
609
620
  root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
610
621
  <div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
611
622
  ${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>` : ""}
623
+ <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
624
  <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
625
  ${badgeStrip(config.badges)}</div></section>`;
615
626
  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
627
  element("anonymous")?.addEventListener("click", () => {
625
628
  if (!config.allowAnonymous) return;
626
629
  anonymous = true;
@@ -647,13 +650,14 @@ function render() {
647
650
  // form's place rather than sitting under it: the credentials were accepted,
648
651
  // and what is left is a decision about this app.
649
652
  if (pendingAgreement && element("signin")) {
650
- element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement);
653
+ element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement, `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
651
654
  bindAgreementScreen(element<HTMLFormElement>("signin"));
652
655
  }
653
656
  // The app's own form, for whoever came to type a password. Folded away rather
654
657
  // than removed: the door above it is the one to take, and the person who
655
658
  // wants this one is one click from it.
656
659
  if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
660
+ if (!pendingAgreement && element("signin")) bindPasswordReveal(element("signin")!);
657
661
  element("use-email")?.addEventListener("click", () => {
658
662
  emailEntryOpen = !emailEntryOpen;
659
663
  showEmailEntry(emailEntryOpen, true);
@@ -817,14 +821,12 @@ function completeSignIn() {
817
821
  // every app must not be mintable by one that collects its own passwords.
818
822
  if (isFidjItself) {
819
823
  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
- });
824
+ const transfer = await request("/me/oidc/session-transfer", "POST");
825
+ // The provider cookie belongs to the API origin. A cross-site fetch
826
+ // cannot reliably replace it, so let that origin answer once as the
827
+ // top-level page, consume a one-use ticket, then send us back here.
828
+ window.location.assign(transfer.location);
829
+ return;
828
830
  } catch {
829
831
  // Being signed in here still worked. The person is inside Fidj; what
830
832
  // they lose is being recognised by the next app without typing again,
@@ -970,14 +972,24 @@ function privacyBlock() {
970
972
  type Interaction = {
971
973
  prompt: string;
972
974
  csrf: string;
975
+ // The address this interaction is waiting on, when it is waiting rather than
976
+ // asking. Empty on every other screen.
977
+ awaiting?: string;
973
978
  app: { id: string; title: string; description: string };
974
979
  scopes: string[];
975
980
  termsUri: string;
976
981
  privacyUri: string;
982
+ // The agreement's own text, so it can be read here rather than in another tab.
983
+ agreement?: {version: string; text: string} | null;
977
984
  action: string;
978
985
  };
979
986
  let interactionId = "";
980
987
  let interactionError = "";
988
+ // Whether the link was just sent again, or pressed Continue before opening it.
989
+ // They are about the wait, not about the person, so they travel in the address
990
+ // where the address itself does not.
991
+ let interactionResent = false;
992
+ let interactionNotYet = false;
981
993
  let interaction: Interaction | null = null;
982
994
  let interactionFailed = false;
983
995
 
@@ -1026,6 +1038,8 @@ function readInteraction() {
1026
1038
  if (!uid) return false;
1027
1039
  interactionId = uid;
1028
1040
  interactionError = parameters.get("error") || "";
1041
+ interactionResent = parameters.get("resent") === "1";
1042
+ interactionNotYet = parameters.get("notyet") === "1";
1029
1043
  // The id stays in the address while the screen is up. Taking it out looked
1030
1044
  // tidier and made the screen a trap: the address became "#/signin", so going
1031
1045
  // back to "#/signin" changed nothing, the document never reloaded, and the
@@ -1049,6 +1063,15 @@ async function loadInteraction() {
1049
1063
  }
1050
1064
 
1051
1065
 
1066
+ // What the wait has to say beyond the address, if anything.
1067
+ function interactionWaitNotice() {
1068
+ if (interactionNotYet)
1069
+ return '<p class="fineprint">The link has not been opened yet. Open it, then press Continue again.</p>';
1070
+ if (interactionResent)
1071
+ return '<p class="fineprint">The link was sent again. Only the newest one works.</p>';
1072
+ return "";
1073
+ }
1074
+
1052
1075
  function interactionScreen() {
1053
1076
  const details = interaction!;
1054
1077
  const asking = escape(details.app.title);
@@ -1067,8 +1090,24 @@ function interactionScreen() {
1067
1090
  // sees your password" about itself is nonsense in the same family as offering
1068
1091
  // to sign in with Fidj on Fidj.
1069
1092
  const itself = details.app.id === config.appId;
1070
- const body =
1071
- details.prompt === "login"
1093
+ // The wait, when this interaction is waiting on an address rather than asking
1094
+ // for anything. It is not a refusal and must not be drawn as one: the account
1095
+ // exists, the link was sent, and what is left is opening it. The address is
1096
+ // read from the interaction's own context rather than from the URL, so
1097
+ // nothing about the person travels in an address bar.
1098
+ const body = details.awaiting
1099
+ ? `<h2>Check your email</h2>
1100
+ <p class="signin-lead">Your account is created. Waiting for you to open the link sent to <strong>${escape(details.awaiting)}</strong>.</p>
1101
+ ${returnNotice(itself ? "Fidj" : asking)}
1102
+ <form method="post" action="${escape(action)}" id="interaction">
1103
+ <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1104
+ <p class="fineprint">The link may take a minute, and it sometimes lands in spam. Open it, then come back here.</p>
1105
+ ${interactionWaitNotice()}
1106
+ <button class="primary" type="submit" name="action" value="continue">Continue</button>
1107
+ <button class="secondary" type="submit" name="action" value="resend">Send the link again</button>
1108
+ <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1109
+ </form>`
1110
+ : details.prompt === "login"
1072
1111
  ? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
1073
1112
  <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
1113
  ${returnNotice(itself ? "Fidj" : asking)}
@@ -1092,7 +1131,8 @@ function interactionScreen() {
1092
1131
  .join("")}</ul>
1093
1132
  <form method="post" action="${escape(action)}" id="interaction">
1094
1133
  <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>
1134
+ <label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>${escape(agreementModel(details.app.title, {}).checkboxLabel)}</span></label>
1135
+ ${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
1136
  ${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
1137
  <button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
1098
1138
  <button class="quiet" type="submit" id="not-me" name="action" value="switch" formnovalidate>Not you? Sign in with another account</button>
@@ -1106,14 +1146,7 @@ function interactionScreen() {
1106
1146
  <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
1147
  ${badgeStrip(config.badges)}</section>`;
1108
1148
 
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
- });
1149
+ if (element("interaction")) bindPasswordReveal(element("interaction")!);
1117
1150
  // Being recognised is the point, and a dead end when the person is not who
1118
1151
  // Fidj thinks — a shared computer, a second account, somebody else's tab. So
1119
1152
  // the screen that recognises them asks again on request. It goes back to the
@@ -1141,6 +1174,7 @@ function renderAccount(route: string) {
1141
1174
  <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
1175
  </div>
1143
1176
  <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>`;
1177
+ bindPasswordReveal(root);
1144
1178
  wireAccount(route);
1145
1179
  }
1146
1180
 
@@ -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() {
@@ -213,7 +213,7 @@ function render() {
213
213
  const canWrite = session?.roles.some((role) =>
214
214
  ["Owner", "Editor"].includes(role),
215
215
  );
216
- root.innerHTML = `<header class="topbar"><a class="brand" href="/"><span class="app-symbol">✳</span>${escape(settings.title)}</a><a class="fidj-brand" href="${escape(settings.dashboardUrl)}" target="_blank" rel="noopener"><img src="/fidj-logo.png" alt="Fidj">Privacy with Fidj ↗</a></header>
216
+ root.innerHTML = `<header class="topbar"><a class="brand" href="/"><span class="app-symbol">✳</span><span class="brand-name">${escape(settings.title)}</span></a><a class="fidj-brand" href="${escape(settings.dashboardUrl)}" target="_blank" rel="noopener"><img src="/fidj-logo.png" alt="Fidj">Privacy with Fidj ↗</a></header>
217
217
  <main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
218
218
  ${
219
219
  !session
@@ -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.14.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": {