@ofidj/generator-fidj 3.15.1 → 3.17.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.15.1",
19
- "@ofidj/node": "^3.15.0"
18
+ "@ofidj/entry": "^3.17.0",
19
+ "@ofidj/node": "^3.17.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
@@ -1,5 +1,5 @@
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, passkeySupported, passkeyAssertion, walletDoor} from "@ofidj/entry/dom";
1
+ import {signInErrorMessage, formatDate, optionalPurposes, 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";
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.
@@ -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();
@@ -913,9 +921,11 @@ function wirePrivacy() {
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,43 @@ 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 my app data</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">Confirm leaving this app</button><button id="cancel-leave">Keep my membership</button>' : '<button id="leave" class="danger">Leave this app</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 acceptedVersion = String((consent as Record<string, unknown>).termsVersion || "");
1005
+ const agreementHref = `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(acceptedVersion)}`;
1006
+ const agreementRow = consent.terms
1007
+ ? `<div class="member-row"><div><strong>Service agreement</strong>${acceptedVersion ? `<a class="basis" href="${escape(agreementHref)}" target="_blank" rel="noopener noreferrer">Contract · agreement ${escape(acceptedVersion)} ↗</a>` : '<span class="basis">Contract</span>'}</div><small class="nosw">Part of the service. To stop it, leave the app.</small></div>`
1008
+ : '<div class="member-row"><div><strong>Service agreement</strong><small>Not accepted yet — accept it or leave the app.</small></div><button id="terms" class="primary">Accept</button></div>';
1009
+ const choices = optionalPurposes
1010
+ .map(
1011
+ (purpose) =>
1012
+ `<label class="member-row" for="purpose-${purpose.key}"><div><strong>${escape(purpose.title)}</strong><small>${escape(purpose.description)}</small><span class="basis consent">Consent</span></div><span class="switch"><input type="checkbox" role="switch" id="purpose-${purpose.key}" data-purpose="${purpose.key}" ${consent[purpose.key] ? "checked" : ""}><span class="switch-state" aria-hidden="true">${consent[purpose.key] ? "On" : "Off"}</span></span></label>`,
1013
+ )
1014
+ .join("");
1015
+ const entries = history.length
1016
+ ? history
1017
+ .slice()
1018
+ .reverse()
1019
+ .map(
1020
+ (entry) =>
1021
+ `<p>${escape(formatDate(entry.changedAt, "datetime"))} · ${escape(entry.type)} ${entry.granted ? "given" : "withdrawn"}</p>`,
1022
+ )
1023
+ .join("")
1024
+ : "<p>No changes yet.</p>";
1025
+ const leave = roles.includes("Owner")
1026
+ ? "<small>You own this app: hand it over or delete it on Fidj before leaving.</small>"
1027
+ : leaving
1028
+ ? '<p>Your membership and what this app holds for you will be erased. Your other apps remain available.</p><button id="confirm-leave" class="danger">Leave &amp; erase</button><button id="cancel-leave">Keep my membership</button>'
1029
+ : '<button id="leave" class="danger">Leave &amp; erase</button>';
1030
+ return `<h2>Your membership</h2><div class="member-rows">${agreementRow}${choices}</div>
1031
+ <details class="member-history"><summary>History</summary>${entries}</details>
1032
+ <div class="member-actions"><button id="export">Export</button>${leave}</div>
1033
+ <p class="leaving"><a href="${escape(config.dashboardUrl)}/#/my/gdpr" target="_blank" rel="noopener noreferrer">Open Fidj to manage every app you use ↗</a></p>`;
1008
1034
  }
1009
1035
 
1010
1036
  // ------------------------------------------------ signing in, for the provider
@@ -1028,8 +1054,11 @@ type Interaction = {
1028
1054
  scopes: string[];
1029
1055
  termsUri: string;
1030
1056
  privacyUri: string;
1031
- // The agreement's own text, so it can be read here rather than in another tab.
1032
1057
  agreement?: {version: string; text: string} | null;
1058
+ // Who the consent screen recognises, as the provider names them.
1059
+ recognisedEmail?: string;
1060
+ // The passkey door's challenge, on the login step.
1061
+ passkey?: {ticket: string; options: unknown};
1033
1062
  action: string;
1034
1063
  };
1035
1064
  let interactionId = "";
@@ -1123,10 +1152,6 @@ function interactionWaitNotice() {
1123
1152
 
1124
1153
  function interactionScreen() {
1125
1154
  const details = interaction!;
1126
- const asking = escape(details.app.title);
1127
- const notice = interactionError
1128
- ? `<p role="alert" class="error">${escape(refusals[interactionError] || refusals.refused)}</p>`
1129
- : "";
1130
1155
  const action = new URL(details.action, config.apiEndpoint).href;
1131
1156
  // A refusal comes back as a redirect, so the typed address would be lost —
1132
1157
  // and retyping an address is the part a person gets wrong twice. It is kept
@@ -1135,67 +1160,33 @@ function interactionScreen() {
1135
1160
  try {
1136
1161
  typed = sessionStorage.getItem("fidj.interaction.email") || "";
1137
1162
  } catch {}
1138
- // Fidj signing into Fidj: saying "the account behind fidj" and "fidj never
1139
- // sees your password" about itself is nonsense in the same family as offering
1140
- // to sign in with Fidj on Fidj.
1141
- const itself = details.app.id === config.appId;
1142
- // The wait, when this interaction is waiting on an address rather than asking
1143
- // for anything. It is not a refusal and must not be drawn as one: the account
1144
- // exists, the link was sent, and what is left is opening it. The address is
1145
- // read from the interaction's own context rather than from the URL, so
1146
- // nothing about the person travels in an address bar.
1147
- const body = details.awaiting
1148
- ? `<h2>Check your email</h2>
1149
- <p class="signin-lead">Your account is created. Waiting for you to open the link sent to <strong>${escape(details.awaiting)}</strong>.</p>
1150
- ${returnNotice(itself ? "Fidj" : asking)}
1151
- <form method="post" action="${escape(action)}" id="interaction">
1152
- <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1153
- <p class="fineprint">The link may take a minute, and it sometimes lands in spam. Open it, then come back here.</p>
1154
- ${interactionWaitNotice()}
1155
- <button class="primary" type="submit" name="action" value="continue">Continue</button>
1156
- <button class="secondary" type="submit" name="action" value="resend">Send the link again</button>
1157
- <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1158
- </form>`
1159
- : details.prompt === "login"
1160
- ? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
1161
- <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>
1162
- ${returnNotice(itself ? "Fidj" : asking)}
1163
- ${notice}
1164
- <form method="post" action="${escape(action)}" id="interaction">
1165
- <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1166
- <label for="email">Email</label><input id="email" name="email" type="email" value="${escape(typed)}" autocomplete="username" required>
1167
- <div class="field-head"><label for="password">Password</label><a href="${escape(config.dashboardUrl)}/#/forgot">Forgot?</a></div>
1168
- <div class="password-field"><input id="password" name="password" type="password" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div>
1169
- <button class="primary" type="submit" name="action" value="continue">Sign in</button>
1170
- <button class="secondary" type="submit" name="action" value="signup">Create a Fidj account</button>
1171
- <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1172
- </form>`
1173
- : `<h2>${itself ? "Continue to Fidj" : "Continue to " + asking}</h2>
1174
- <p class="signin-lead">${itself ? "Fidj is asking for the information below. Optional privacy choices stay separate for every app, including this one." : `${asking} is asking for the information below. Optional privacy choices stay separate, and you can change them in Fidj at any time.`}</p>
1175
- ${returnNotice(itself ? "Fidj" : asking)}
1176
- ${notice}
1177
- <ul class="scope-list">${details.scopes
1178
- .filter((scope) => scopeMeaning[scope])
1179
- .map((scope) => `<li>${escape(scopeMeaning[scope])}</li>`)
1180
- .join("")}</ul>
1181
- <form method="post" action="${escape(action)}" id="interaction">
1182
- <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1183
- <label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>${escape(agreementModel(details.app.title, {}).checkboxLabel)}</span></label>
1184
- ${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>` : ""}
1185
- ${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>` : ""}
1186
- <button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
1187
- <button class="quiet" type="submit" id="not-me" name="action" value="switch" formnovalidate>Not you? Sign in with another account</button>
1188
- <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1189
- </form>`;
1190
-
1191
- root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
1192
- <div class="signin-identity"><h1>Your identity.<br>Your choices.</h1><p class="signin-description">One account across every app that uses Fidj, and a separate set of choices for each one.</p></div>
1193
- ${highlightCells(config.highlights)}</div>
1194
- <div class="signin-form"><div>${body}</div>
1195
- <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>
1196
- ${badgeStrip(config.badges)}</section>`;
1197
-
1198
- if (element("interaction")) bindPasswordReveal(element("interaction")!);
1163
+ // The provider's own screen, drawn here: the same markup and the same scoped
1164
+ // styles as the page it serves itself. This window was opened by an app for
1165
+ // one question, so nothing of this front end's own sign-in page — masthead,
1166
+ // promises, "What Fidj is" — is drawn around it. The wait is not a refusal
1167
+ // and is not drawn as one: its address comes from the interaction's context,
1168
+ // never from the URL.
1169
+ const mode = details.awaiting ? "waiting" : details.prompt === "login" ? "login" : "consent";
1170
+ root.innerHTML = `<style>${oidcInteractionStyles}</style>${oidcInteractionMarkup({
1171
+ mode,
1172
+ appTitle: details.app.title,
1173
+ action,
1174
+ csrf: details.csrf,
1175
+ notice: interactionError ? refusals[interactionError] || refusals.refused : undefined,
1176
+ email: typed,
1177
+ forgotHref: `${config.dashboardUrl}/#/forgot`,
1178
+ waitingEmail: details.awaiting,
1179
+ resent: interactionResent,
1180
+ notYet: interactionNotYet,
1181
+ scopes: details.scopes.filter((scope) => scopeMeaning[scope]).map((scope) => scopeMeaning[scope]),
1182
+ agreement: details.agreement || undefined,
1183
+ // The agreement is a document read in the browser, like the privacy notice.
1184
+ agreementHref: details.termsUri || undefined,
1185
+ recognisedEmail: details.recognisedEmail || undefined,
1186
+ passkey: details.passkey,
1187
+ logoSrc: "./fidj-logo.png",
1188
+ })}`;
1189
+ bindOidcInteraction(root);
1199
1190
  // Being recognised is the point, and a dead end when the person is not who
1200
1191
  // Fidj thinks — a shared computer, a second account, somebody else's tab. So
1201
1192
  // the screen that recognises them asks again on request. It goes back to the
@@ -1203,7 +1194,7 @@ function interactionScreen() {
1203
1194
  // session doing the recognising: starting a fresh request with prompt=login
1204
1195
  // from here asked once and left that session standing, so the back button,
1205
1196
  // a reload, or the app's door again met the same face.
1206
- element("not-me")?.addEventListener("click", () => {
1197
+ root.querySelector('button[value="switch"]')?.addEventListener("click", () => {
1207
1198
  forgetSignIn(config.appId);
1208
1199
  try {
1209
1200
  sessionStorage.removeItem("fidj.interaction.email");
@@ -1,4 +1,4 @@
1
- import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, type SigninShape} from "@ofidj/entry";
1
+ import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, formatDate, type SigninShape} from "@ofidj/entry";
2
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";
@@ -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 my data</button></article><article class="card"><h2>Consent history</h2>${
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="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
228
  privacy?.history.length
229
229
  ? privacy.history
230
230
  .slice()
231
231
  .reverse()
232
232
  .map(
233
233
  (entry) =>
234
- `<p><strong>${escape(entry.type)}</strong> · ${entry.granted ? "Accepted" : "Withdrawn"}<br><small>${escape(new Date(entry.changedAt).toLocaleString())}</small></p>`,
234
+ `<p><strong>${escape(entry.type)}</strong> · ${entry.granted ? "Accepted" : "Withdrawn"}<br><small>${escape(formatDate(entry.changedAt, "datetime"))}</small></p>`,
235
235
  )
236
236
  .join("")
237
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">Confirm leaving and erase</button><button id="cancel-leave">Keep my membership</button></div>' : '<button id="leave" class="danger">Leave and erase my app data</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>`
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>`
239
239
  }`
240
240
  }
241
241
  <footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "3.15.1",
3
+ "version": "3.17.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": {