@ofidj/generator-fidj 3.7.5 → 3.8.1

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.
@@ -1,4 +1,5 @@
1
- import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn, signInHint} from "./service-agreement";
1
+ import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn, signInHint, showEmailEntry, type SigninShape} from "./service-agreement";
2
+ import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "./provider-window";
2
3
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
3
4
  import config from "../app.config.json";
4
5
  import "./style.css";
@@ -29,6 +30,15 @@ let leaving = false;
29
30
  let signInEmail = "";
30
31
  let signInPassword = "";
31
32
  let signInAgreementAccepted = false;
33
+ // Who is signed in, as this app's own membership answers it. The bar names them
34
+ // and the account screen says it again where it can be checked; the ID token of
35
+ // a code flow carries only a subject, so this comes from the membership.
36
+ let accountEmail = "";
37
+ // Whether the person asked for the app's own form. The entry is rebuilt on every
38
+ // render, and a refused sign-in is a render: without this, pressing Continue
39
+ // with the agreement unchecked folded the form away and left the complaint
40
+ // floating above a door the person could no longer see.
41
+ let emailEntryOpen = false;
32
42
  const accountRoutes = ["forgot", "reset", "verify", "account"];
33
43
  let linkToken = "";
34
44
  let verificationConfirmed = false;
@@ -74,21 +84,49 @@ function banner() {
74
84
  return `<p role="${role}" class="${kind}">${escape(message)}${finish}</p>`;
75
85
  }
76
86
 
77
- // One navigation bar for every in-app screen, so signing out stays one click
78
- // away wherever you are. The sign-in entry and the pre-authentication account
79
- // screens are full-bleed and carry none.
80
- function appNav(current: "content" | "privacy" | "account") {
87
+ // One navigation, in the app's own bar: past its mark and its name, past a
88
+ // rule, the way Fidj's console carries its own. It used to sit in the page
89
+ // under a bar that held only a name, which read as two headers for one app.
90
+ //
91
+ // Two tabs, because what this app holds about somebody and what they can do
92
+ // about it are one subject. Split across "My privacy" and "My account" it asked
93
+ // a person to look in two places for one answer — and the account they were
94
+ // looking for was the one named right here, which is why the tab says it.
95
+ //
96
+ // Signing out is not a place, so it is not a tab: it is a thing you do to an
97
+ // account, and it lives on that account's screen.
98
+ function appNav(current: "content" | "account") {
81
99
  const tab = (id: string, label: string, selected: boolean) =>
82
100
  `<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
83
- return `<nav class="content-nav" aria-label="App navigation">${tab("content-tab", "Content", current === "content")}${tab("privacy-tab", signedIn ? "My privacy" : "Sign in", current === "privacy")}${signedIn ? tab("account-tab", "My account", current === "account") : ""}${tab("exit", signedIn ? "Sign out" : "Back to sign in", false)}</nav>`;
101
+ const account = signedIn
102
+ ? tab(
103
+ "account-tab",
104
+ accountEmail ? `Account (${escape(accountEmail)})` : "Account",
105
+ current === "account",
106
+ )
107
+ : tab("account-tab", "Sign in", false);
108
+ return tab("content-tab", "Content", current === "content") + account;
109
+ }
110
+
111
+ // The bar belongs to the document, not to the screen being drawn: it survives
112
+ // every route change, so it is filled rather than rebuilt with the page.
113
+ function renderNav(current: "content" | "account") {
114
+ const nav = element("app-nav");
115
+ if (!nav) return;
116
+ nav.innerHTML = appNav(current);
117
+ nav.hidden = false;
118
+ wireNav();
84
119
  }
85
120
 
86
121
  function wireNav() {
87
122
  element("content-tab")?.addEventListener("click", () => navigate("content"));
88
- element("account-tab")?.addEventListener("click", () => navigate("account"));
89
- element("privacy-tab")?.addEventListener("click", () =>
90
- navigate(signedIn ? "privacy" : "signin"),
123
+ element("account-tab")?.addEventListener("click", () =>
124
+ navigate(signedIn ? "account" : "signin"),
91
125
  );
126
+ }
127
+
128
+ // Leaving this app's session, from the screen that is about this app's session.
129
+ function wireSignOut() {
92
130
  element("exit")?.addEventListener(
93
131
  "click",
94
132
  () =>
@@ -153,7 +191,8 @@ async function refresh() {
153
191
  // The ID token of a code flow carries only the subject, by design, so the
154
192
  // address the entry can offer next time comes from the membership the app
155
193
  // just read — not from a claim it does not have.
156
- rememberSignIn(config.appId, String(me?.poc?.email || me?.username || ""));
194
+ accountEmail = String(me?.poc?.email || me?.username || "");
195
+ rememberSignIn(config.appId, accountEmail);
157
196
  }
158
197
  async function action(task: () => Promise<void>) {
159
198
  if (busy) return;
@@ -164,7 +203,13 @@ async function action(task: () => Promise<void>) {
164
203
  .forEach((control) => {
165
204
  control.disabled = true;
166
205
  });
167
- const submit = root.querySelector<HTMLButtonElement>("button.primary");
206
+ // Whichever button was pressed, when the caller marked it. The entry has two
207
+ // submit buttons now and only one of them is `.primary` first in the
208
+ // document, so "Please wait…" kept landing on the Fidj door while the person
209
+ // watched the button they had actually pressed sit there saying nothing.
210
+ const submit =
211
+ root.querySelector<HTMLButtonElement>("button[data-busy]") ||
212
+ root.querySelector<HTMLButtonElement>("button.primary");
168
213
  if (submit) submit.textContent = "Please wait…";
169
214
  failed = false;
170
215
  if (initialized) {
@@ -211,6 +256,114 @@ async function action(task: () => Promise<void>) {
211
256
  render();
212
257
  }
213
258
  }
259
+ // Taking the Fidj door, from inside the click that asked for it.
260
+ //
261
+ // The window has to exist before the authorization URL is fetched — building it
262
+ // costs a discovery round-trip, and a window opened after an await is one the
263
+ // browser no longer attributes to the click, which every popup blocker refuses.
264
+ // So it is opened empty first and sent somewhere second.
265
+ //
266
+ // The page that opens it is the page that finishes the sign-in: the PKCE
267
+ // transaction lives in this window's session storage, and a new window is given
268
+ // a copy of that storage rather than a share of it. The window it opened only
269
+ // carries the provider's answer back.
270
+ // The window this page is waiting on, while it is waiting on it.
271
+ let waitingFor: ProviderWindow | null = null;
272
+
273
+ // What the entry says while that window is open.
274
+ //
275
+ // `action` disables the screen and writes "Please wait…", which is right when
276
+ // the waiting happens here. This waiting happens somewhere else — in a window
277
+ // that can be behind this page, minimised, or on another desktop — and a
278
+ // disabled sentence is then a dead end in front of the only two things worth
279
+ // offering: the way back to that window, and the way out of it.
280
+ function offerTheWindowBack(providerWindow: ProviderWindow) {
281
+ const waiting = root.querySelector<HTMLButtonElement>("button[data-busy]");
282
+ if (!waiting) return;
283
+ waiting.disabled = false;
284
+ waiting.classList.add("is-waiting");
285
+ // It says what is happening rather than what to do, because what to do is
286
+ // happening in the other window. Dropping the accent is the point: this is a
287
+ // state, not the way in. It stays pressable all the same — somebody who has
288
+ // lost that window behind this page needs precisely this to be pressable.
289
+ waiting.textContent = "Connecting with Fidj…";
290
+ waiting.title = "Bring the Fidj window back to the front";
291
+ // It is a submit button, and submitting would open a second window: the press
292
+ // is being borrowed, so its default has to go.
293
+ waiting.onclick = (event) => {
294
+ event.preventDefault();
295
+ providerWindow.focus();
296
+ };
297
+ const cancel = document.createElement("button");
298
+ cancel.type = "button";
299
+ cancel.id = "cancel-provider";
300
+ cancel.className = "quiet";
301
+ cancel.textContent = "Cancel";
302
+ // Closing it is the whole answer: the window resolves to nothing, and the
303
+ // entry is drawn again exactly as it was.
304
+ cancel.onclick = () => {
305
+ cancel.disabled = true;
306
+ providerWindow.giveUp();
307
+ };
308
+ waiting.insertAdjacentElement("afterend", cancel);
309
+ }
310
+
311
+ function signInThroughProvider(
312
+ trigger: HTMLElement | null,
313
+ options: { silent?: boolean; prompt?: string } = {},
314
+ ) {
315
+ if (!oidc) return;
316
+ // Already open: bring that one back rather than start a second conversation
317
+ // with the provider. Pressing the door again is what somebody does when the
318
+ // window is behind the page, and it means "where did it go", not "again".
319
+ if (waitingFor?.isOpen()) {
320
+ waitingFor.focus();
321
+ return;
322
+ }
323
+ const providerWindow = openProviderWindow();
324
+ trigger?.setAttribute("data-busy", "true");
325
+ void action(async () => {
326
+ let url: string;
327
+ try {
328
+ // Being recognised again is the one thing somebody who has just signed
329
+ // out did not ask for, and skipping this is what let a sign-out be undone
330
+ // by pressing the door again: the provider still knew the browser and
331
+ // answered with a code, no screen at all. Ending the provider session is
332
+ // what should make that impossible, and that call can be refused — so the
333
+ // door asks rather than assumes, until somebody signs in again.
334
+ url = await oidc.beginLogin(
335
+ options.prompt || options.silent || !oidc.signedOutHere()
336
+ ? options
337
+ : {...options, prompt: "login"},
338
+ );
339
+ } catch (error) {
340
+ providerWindow?.giveUp();
341
+ throw error;
342
+ }
343
+ // No window to put it in — blocked, or a browser that would not open one.
344
+ // Leaving this page is the flow this one replaced, and it still works.
345
+ if (!providerWindow) {
346
+ window.location.assign(url);
347
+ return;
348
+ }
349
+ providerWindow.show(url);
350
+ waitingFor = providerWindow;
351
+ offerTheWindowBack(providerWindow);
352
+ const callback = await providerWindow.answer().finally(() => {
353
+ waitingFor = null;
354
+ });
355
+ // Closed by hand. That is an answer too, and the entry simply comes back as
356
+ // it was — a person who changed their mind is not owed an error.
357
+ if (!callback) return;
358
+ await oidc.completeLogin(callback);
359
+ try {
360
+ sessionStorage.removeItem("fidj.interaction.email");
361
+ } catch {}
362
+ await refresh();
363
+ anonymous = false;
364
+ navigate("content");
365
+ });
366
+ }
214
367
  // A person moving between screens is making history, so push an entry: Back has
215
368
  // to return to the screen before, not to whatever preceded the application.
216
369
  // Replacing is right everywhere else in this file — stripping a single-use token
@@ -318,17 +471,22 @@ function render() {
318
471
  interactionScreen();
319
472
  return;
320
473
  }
321
- if (!initialized) {
322
- root.innerHTML = '<p role="status">Loading your session…</p>';
323
- return;
324
- }
325
474
  // Which of its routes need a session is the mounted app's business, not the
326
- // shell's: Fidj's own console serves /pub to anyone. So any address the shell
327
- // does not own is handed over as it stands.
475
+ // shell's: Fidj's own console serves /pub to anyone, and every app that needs
476
+ // one revalidates it for itself. So any address the shell does not own is
477
+ // handed over as it stands — before the shell asks the API who this is, not
478
+ // after. Waiting cost several seconds of "Loading your session…" on an address
479
+ // the shell was never going to draw: a token refresh and three round trips ran
480
+ // to completion before a quarter of a megabyte of application even began
481
+ // downloading. Nothing in that answer decides anything here.
328
482
  if (moduleRoute()) {
329
483
  startModule();
330
484
  return;
331
485
  }
486
+ if (!initialized) {
487
+ root.innerHTML = '<p role="status">Loading your session…</p>';
488
+ return;
489
+ }
332
490
  let route = currentRoute();
333
491
  if (
334
492
  !signedIn &&
@@ -338,7 +496,10 @@ function render() {
338
496
  route = "signin";
339
497
  else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
340
498
  route = "content";
341
- if (route === "privacy" && !signedIn) route = "signin";
499
+ // The privacy screen and the account screen became one. The address that
500
+ // named the first still means something to anyone who bookmarked it, so it
501
+ // arrives where that screen went rather than nowhere.
502
+ if (route === "privacy") route = signedIn ? "account" : "signin";
342
503
  window.history.replaceState(null, "", "#/" + route);
343
504
  // My account is a signed-in screen and keeps the app's chrome. Recovery and
344
505
  // verification are reached without a session, so they stand alone.
@@ -348,14 +509,29 @@ function render() {
348
509
  "signin-view",
349
510
  route === "signin" || standaloneAccount,
350
511
  );
512
+ // The bar belongs to the document and outlives the screen, so it has to be
513
+ // emptied rather than left standing: a sign-out that only redrew the page
514
+ // left the address of the person who had just gone still written across the
515
+ // top — hidden on the entry, and still in the document for anything that
516
+ // reads it. The screens that want it fill it again a moment later.
517
+ const bar = element("app-nav");
518
+ if (bar) {
519
+ bar.innerHTML = "";
520
+ bar.hidden = true;
521
+ }
351
522
  if (standaloneAccount) {
352
523
  renderAccount(route);
353
524
  return;
354
525
  }
355
526
  if (route === "account") {
356
- root.innerHTML = `${appNav("account")}<section class="card content-account">${banner()}${accountForm("account")}</section>`;
357
- wireNav();
527
+ // The two ways out, last and together: back to the app, or out of this
528
+ // session. Leaving the app itself is a different decision and stays where
529
+ // the things it erases are listed.
530
+ root.innerHTML = `<section class="card content-account">${banner()}${accountForm("account")}${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>`;
531
+ renderNav("account");
358
532
  wireAccount("account");
533
+ wireSignOut();
534
+ wirePrivacy();
359
535
  return;
360
536
  }
361
537
  if (route === "content" && config.moduleEntry) {
@@ -364,42 +540,18 @@ function render() {
364
540
  return;
365
541
  }
366
542
  if (route === "content") {
367
- root.innerHTML = `${appNav("content")}${element<HTMLTemplateElement>("public-content")!.innerHTML}`;
368
- wireNav();
543
+ root.innerHTML = element<HTMLTemplateElement>("public-content")!.innerHTML;
544
+ renderNav("content");
369
545
  return;
370
546
  }
371
- root.innerHTML = `${route === "signin" ? "" : appNav("privacy")}<section class="${route === "signin" ? "signin-shell" : "card content-account"}">
372
- ${route === "signin" ? "" : banner()}
373
- ${
374
- route === "signin"
375
- ? `<div class="signin-intro${config.highlights?.length ? "" : " is-plain"}"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
547
+ // Only the entry reaches here now: the privacy screen and the account
548
+ // screen became one, and that one is drawn above.
549
+ root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
376
550
  <div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
377
551
  ${highlights()}</div>
378
552
  <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>${agreementMarkup()}<button class="primary" type="submit">Continue</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>` : ""}
379
553
  <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>
380
- ${badges()}</div>`
381
- : `
382
- <h2>My privacy in ${escape(config.title)}</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
383
- <p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
384
- <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>
385
- ${["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("")}
386
- <h3>Consent history</h3>${
387
- history.length
388
- ? history
389
- .slice()
390
- .reverse()
391
- .map(
392
- (entry) =>
393
- `<p>${escape(entry.type)} · ${entry.granted ? "Accepted" : "Withdrawn"} · ${escape(entry.changedAt)}</p>`,
394
- )
395
- .join("")
396
- : "<p>No changes yet.</p>"
397
- }
398
- <button id="export">Export my app data</button>
399
- <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>
400
- ${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>'}
401
- <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>`
402
- }</section>`;
554
+ ${badges()}</div></section>`;
403
555
  wireNav();
404
556
  element("reveal")?.addEventListener("click", () => {
405
557
  const field = element<HTMLInputElement>("password");
@@ -425,42 +577,18 @@ function render() {
425
577
  element("signin")!.innerHTML = providerEntry(
426
578
  config.title,
427
579
  config.appId,
428
- config.ownCredentials ? credentialFields() : "",
580
+ config.signin === "button" ? "" : credentialFields(),
429
581
  isFidjItself,
582
+ config.signin as SigninShape,
430
583
  );
431
- // On Fidj itself there is nobody to hand the person to, so the credential
432
- // screen is fetched straight away: what they see is the form, or their name.
433
- // Once per document, and never once the provider has already answered — a
434
- // refusal or a cancellation must not bounce them round again.
435
- if (
436
- oidc &&
437
- isFidjItself &&
438
- // Only from the sign-in screen. Without this it fired on every render where
439
- // nobody was signed in — including the recovery screens, so a person opening
440
- // a password-reset link was handed a sign-in form instead of the reset they
441
- // had asked for, and could never finish.
442
- element("signin") &&
443
- !askedProvider &&
444
- !interactionId &&
445
- !signedIn &&
446
- // Nor when the shell has something to say. A password reset ends on this
447
- // screen with "your password has been changed"; leaving for the provider
448
- // would swallow the one confirmation the person was waiting for.
449
- !message
450
- ) {
451
- askedProvider = true;
452
- void (async () => {
453
- if (!(await providerRendersHere())) return;
454
- // Being recognised again is the one thing somebody who just signed out did
455
- // not ask for, and this shortcut is what made a sign-out undoable by a page
456
- // reload. Ending the provider session is what should make that impossible,
457
- // and that call can be refused — so here the screen asks rather than
458
- // assumes, until somebody signs in again.
459
- window.location.assign(
460
- await oidc.beginLogin(oidc.signedOutHere() ? { prompt: "login" } : {}),
461
- );
462
- })();
463
- }
584
+ // The app's own form, for whoever came to type a password. Folded away rather
585
+ // than removed: the door above it is the one to take, and the person who
586
+ // wants this one is one click from it.
587
+ if (emailEntryOpen) showEmailEntry(true);
588
+ element("use-email")?.addEventListener("click", () => {
589
+ emailEntryOpen = !emailEntryOpen;
590
+ showEmailEntry(emailEntryOpen, true);
591
+ });
464
592
 
465
593
  // Forgetting the address it remembered and handing the person back to a
466
594
  // session it never ended would recognise them again: the offer has to reach
@@ -468,12 +596,10 @@ function render() {
468
596
  // is how it says so, and the provider reads it as this person saying they are
469
597
  // not the one it knows — it ends that session when it hands the screen over,
470
598
  // so walking away from the form does not hand the old face back.
471
- element("forget-hint")?.addEventListener("click", () => {
599
+ element("forget-hint")?.addEventListener("click", (event) => {
472
600
  forgetSignIn(config.appId);
473
601
  if (oidc) {
474
- void (async () => {
475
- window.location.assign(await oidc.beginLogin({prompt: "login"}));
476
- })();
602
+ signInThroughProvider(event.currentTarget as HTMLElement, {prompt: "login"});
477
603
  return;
478
604
  }
479
605
  render();
@@ -503,19 +629,20 @@ function render() {
503
629
  render();
504
630
  return;
505
631
  }
632
+ if (oidc && throughFidj) {
633
+ // "Continue as <them>" promises to carry on as that person, and handed
634
+ // them over to an empty email field — so the promise cost a second
635
+ // typing of the address it had just shown. The screen prefills from
636
+ // this, and nothing was writing it.
637
+ try {
638
+ const remembered = signInHint(config.appId);
639
+ if (remembered) sessionStorage.setItem("fidj.interaction.email", remembered);
640
+ } catch {}
641
+ signInThroughProvider(submitter);
642
+ return;
643
+ }
644
+ submitter?.setAttribute("data-busy", "true");
506
645
  void action(async () => {
507
- if (oidc && throughFidj) {
508
- // "Continue as <them>" promises to carry on as that person, and handed
509
- // them over to an empty email field — so the promise cost a second
510
- // typing of the address it had just shown. The screen prefills from
511
- // this, and nothing was writing it.
512
- try {
513
- const remembered = signInHint(config.appId);
514
- if (remembered) sessionStorage.setItem("fidj.interaction.email", remembered);
515
- } catch {}
516
- window.location.assign(await oidc.beginLogin());
517
- return;
518
- }
519
646
  if (oidc && (!email || !password)) {
520
647
  throw new Error("Enter your email and password, or sign in with Fidj.");
521
648
  }
@@ -532,6 +659,12 @@ function render() {
532
659
  navigate("content");
533
660
  });
534
661
  });
662
+ }
663
+
664
+ // Everything the account screen lets a person do about what this app holds:
665
+ // the roles it reads, the agreement, the optional choices, the export and
666
+ // the way out of the app itself.
667
+ function wirePrivacy() {
535
668
  element("refresh")?.addEventListener("click", () => void action(refresh));
536
669
  element("signout")?.addEventListener(
537
670
  "click",
@@ -617,6 +750,33 @@ function render() {
617
750
 
618
751
  // The four account screens. My account is shown inside the app; the recovery
619
752
  // and verification ones are reached without a session and stand alone.
753
+ // What this app holds about the person signed in, and what they can do
754
+ // about it. It used to be a screen of its own called My privacy, one tab
755
+ // away from the account it was about — so a person looking for their own
756
+ // data had two places to try and no way to tell which.
757
+ function privacyBlock() {
758
+ return `<h2>What ${escape(config.title)} holds</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
759
+ <p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
760
+ <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>
761
+ ${["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("")}
762
+ <h3>Consent history</h3>${
763
+ history.length
764
+ ? history
765
+ .slice()
766
+ .reverse()
767
+ .map(
768
+ (entry) =>
769
+ `<p>${escape(entry.type)} · ${entry.granted ? "Accepted" : "Withdrawn"} · ${escape(entry.changedAt)}</p>`,
770
+ )
771
+ .join("")
772
+ : "<p>No changes yet.</p>"
773
+ }
774
+ <button id="export">Export my app data</button>
775
+ <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>
776
+ ${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>'}
777
+ <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>`;
778
+ }
779
+
620
780
  function accountForm(route: string) {
621
781
  return route === "forgot"
622
782
  ? `<h2>Reset your password</h2><p>We’ll email you a link to choose a new password for your shared Fidj account.</p><form id="recovery"><label for="recovery-email">Email address</label><input id="recovery-email" type="email" autocomplete="email" required><button class="primary">Send reset link</button></form>`
@@ -624,7 +784,7 @@ function accountForm(route: string) {
624
784
  ? `<h2>Choose a new password</h2><p>This changes your Fidj password across all your apps and signs out existing sessions.</p>${linkToken ? '<form id="recovery"><label for="new-password">New password</label><input id="new-password" type="password" autocomplete="new-password" minlength="12" required><label for="confirm-password">Confirm password</label><input id="confirm-password" type="password" autocomplete="new-password" minlength="12" required><p>Use at least 12 characters (up to 72 UTF-8 bytes).</p><button class="primary">Save new password</button></form>' : '<p>Request a new link if you no longer have an active reset link.</p><a href="#/forgot">Request a reset link</a>'}`
625
785
  : route === "verify"
626
786
  ? `<h2>${verificationConfirmed ? "Email verified" : "Verify your email"}</h2>${verificationConfirmed ? "<p>Your account is ready. Return to your app to continue.</p>" : "<p>Confirm that this email address belongs to you.</p>"}${verificationConfirmed ? "" : linkToken ? '<form id="recovery"><button class="primary">Confirm email address</button></form>' : "<p>Sign in to your account to request a new verification email.</p>"}`
627
- : `<h2>My Fidj account</h2><p>Your identity is shared across your apps. Privacy choices remain separate for each app.</p><p id="verification-status">${emailVerified ? "Your email address is verified." : "Your email is not verified yet."}</p><button id="check-verification">Refresh verification status</button>${emailVerified ? "" : '<button id="resend-verification">Send verification email</button>'}<p><a href="#/forgot">Reset my password</a></p><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button>`;
787
+ : `<h2>My Fidj account</h2><p class="account-identity">Signed in as <strong>${escape(accountEmail)}</strong></p><p>Your identity is shared across your apps. Privacy choices remain separate for each app.</p><p id="verification-status">${emailVerified ? "Your email address is verified." : "Your email is not verified yet."}</p><button id="check-verification">Refresh verification status</button>${emailVerified ? "" : '<button id="resend-verification">Send verification email</button>'}<p><a href="#/forgot">Reset my password</a></p>`;
628
788
  }
629
789
 
630
790
 
@@ -672,26 +832,13 @@ const refusals: Record<string, string> = {
672
832
  // Fidj's own front end, told apart by the one fact it already carries: the
673
833
  // dashboard it points people to is itself. "Sign in with Fidj" is the right
674
834
  // label on an app that is not Fidj; here it names a provider the person is
675
- // standing in, and hides the form behind a click that only fetches it.
676
- let askedProvider = false;
677
- // Whether the provider renders its sign-in on this front end. A deployment
678
- // decides that, so the shell asks rather than assumes: hopping to a provider
679
- // that answers with its own page would land the person on the API's origin,
680
- // which is the thing this is meant to avoid.
681
- let signinOnThisUi: boolean | null = null;
682
- async function providerRendersHere() {
683
- if (signinOnThisUi !== null) return signinOnThisUi;
684
- try {
685
- const response = await fetch(
686
- new URL("status", config.apiEndpoint.replace(/\/?$/, "/")).href,
687
- {signal: AbortSignal.timeout(5000)},
688
- );
689
- signinOnThisUi = response.ok && (await response.json()).signin === "fidj-ui";
690
- } catch {
691
- signinOnThisUi = false;
692
- }
693
- return signinOnThisUi;
694
- }
835
+ // standing in, so the door says only "Sign in".
836
+ //
837
+ // It used to hand the person straight to the provider on load, without waiting
838
+ // for a click, because the provider had a page and this screen did not. Now the
839
+ // provider opens in a window instead — and a window nobody asked for is a
840
+ // window the browser blocks. So Fidj's entry waits to be pressed, like every
841
+ // other app's, and the three doors finally behave the same way.
695
842
  const isFidjItself = (() => {
696
843
  try {
697
844
  return new URL(config.dashboardUrl).origin === window.location.origin;
@@ -733,6 +880,14 @@ async function loadInteraction() {
733
880
  interaction = (await response.json()) as Interaction;
734
881
  }
735
882
 
883
+ // A window that opened on its own, over the page somebody was on, owes them the
884
+ // way out before it asks for anything. Naming the app they came from is also the
885
+ // only thing on this screen that they can check against what they were doing a
886
+ // second ago — which is exactly what a page asking for a password should offer.
887
+ function returnNotice(asking: string) {
888
+ return `<p class="signin-return" role="note">When you are done, this window closes and takes you back to ${escape(asking)}.</p>`;
889
+ }
890
+
736
891
  function interactionScreen() {
737
892
  const details = interaction!;
738
893
  const asking = escape(details.app.title);
@@ -755,6 +910,7 @@ function interactionScreen() {
755
910
  details.prompt === "login"
756
911
  ? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
757
912
  <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>
913
+ ${returnNotice(itself ? "Fidj" : asking)}
758
914
  ${notice}
759
915
  <form method="post" action="${escape(action)}" id="interaction">
760
916
  <input type="hidden" name="csrf" value="${escape(details.csrf)}">
@@ -767,6 +923,7 @@ function interactionScreen() {
767
923
  </form>`
768
924
  : `<h2>${itself ? "Continue to Fidj" : "Continue to " + asking}</h2>
769
925
  <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>
926
+ ${returnNotice(itself ? "Fidj" : asking)}
770
927
  ${notice}
771
928
  <ul class="scope-list">${details.scopes
772
929
  .filter((scope) => scopeMeaning[scope])
@@ -883,38 +1040,56 @@ function wireAccount(route: string) {
883
1040
  });
884
1041
  });
885
1042
  }
886
- window.addEventListener("hashchange", render);
887
- render();
888
- if (readInteraction()) {
889
- render();
890
- void loadInteraction()
891
- .catch((error) => {
892
- interactionFailed = true;
893
- failed = true;
894
- message =
895
- error instanceof Error
896
- ? error.message
897
- : "This sign-in could not be loaded. Start again from the app.";
898
- })
899
- .finally(render);
1043
+ // This document is the window the entry opened, and the provider has just
1044
+ // answered into it. Its only job left is to hand that answer to the page that
1045
+ // opened it and get out of the way — never to complete the sign-in, because the
1046
+ // transaction the answer has to match belongs to that other page.
1047
+ if (relayProviderAnswer()) {
1048
+ root.innerHTML = '<p role="status">Signing you in…</p>';
1049
+ // A window the browser will not close is not a window that was scripted open,
1050
+ // so this is an ordinary return from the provider after all. Finish it here
1051
+ // rather than leave somebody looking at one sentence forever.
1052
+ window.setTimeout(() => {
1053
+ if (!window.closed) boot();
1054
+ }, 800);
1055
+ } else {
1056
+ boot();
900
1057
  }
901
- void action(async () => {
902
- if (interactionId) return;
903
- if (oidc && new URL(window.location.href).searchParams.has("state")) {
904
- const callback = new URL(window.location.href);
905
- window.history.replaceState(null, "", window.location.pathname + "#/content");
906
- await oidc.completeLogin(callback);
907
- try {
908
- sessionStorage.removeItem("fidj.interaction.email");
909
- } catch {}
1058
+
1059
+ function boot() {
1060
+ window.addEventListener("hashchange", render);
1061
+ render();
1062
+ if (readInteraction()) {
1063
+ render();
1064
+ void loadInteraction()
1065
+ .catch((error) => {
1066
+ interactionFailed = true;
1067
+ failed = true;
1068
+ message =
1069
+ error instanceof Error
1070
+ ? error.message
1071
+ : "This sign-in could not be loaded. Start again from the app.";
1072
+ })
1073
+ .finally(render);
910
1074
  }
911
- await sdk.init(config.appId, {
912
- apiEndpoint: config.apiEndpoint,
913
- prod: !config.localDemo,
1075
+ void action(async () => {
1076
+ if (interactionId) return;
1077
+ if (oidc && new URL(window.location.href).searchParams.has("state")) {
1078
+ const callback = new URL(window.location.href);
1079
+ window.history.replaceState(null, "", window.location.pathname + "#/content");
1080
+ await oidc.completeLogin(callback);
1081
+ try {
1082
+ sessionStorage.removeItem("fidj.interaction.email");
1083
+ } catch {}
1084
+ }
1085
+ await sdk.init(config.appId, {
1086
+ apiEndpoint: config.apiEndpoint,
1087
+ prod: !config.localDemo,
1088
+ });
1089
+ if (sdk.isLoggedIn()) {
1090
+ await refresh();
1091
+ if (!moduleRoute() && !accountRoutes.includes(currentRoute()))
1092
+ navigate("content");
1093
+ }
914
1094
  });
915
- if (sdk.isLoggedIn()) {
916
- await refresh();
917
- if (!moduleRoute() && !accountRoutes.includes(currentRoute()))
918
- navigate("content");
919
- }
920
- });
1095
+ }