@ofidj/generator-fidj 3.10.2 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
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";
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
+ import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "@ofidj/entry/window";
3
4
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
4
5
  import config from "../app.config.json";
5
- import "./style.css";
6
- import { showVersionBadge } from "./version";
6
+ import "@ofidj/entry/style.css";
7
7
 
8
8
  const sdk = new FidjNodeService();
9
9
  const oidc = config.oidcIssuer ? new FidjOidcClient({issuer: config.oidcIssuer, clientId: config.appId, redirectUri: window.location.origin + window.location.pathname, apiEndpoint: config.apiEndpoint, storage: sessionStorage}) : null;
@@ -29,7 +29,13 @@ let busy = false;
29
29
  let leaving = false;
30
30
  let signInEmail = "";
31
31
  let signInPassword = "";
32
- let signInAgreementAccepted = false;
32
+ // The agreement this app is owed, once the API has said so, and the address a
33
+ // just-created account is waiting on. Only one of them is ever set.
34
+ let pendingAgreement: {version: string; text: string} | null = null;
35
+ let awaitingVerification = "";
36
+ let verificationResent = false;
37
+ let verificationNotice = "";
38
+ let stopWatchingVerification: (() => void) | null = null;
33
39
  // Who is signed in, as this app's own membership answers it. The bar names them
34
40
  // and the account screen says it again where it can be checked; the ID token of
35
41
  // a code flow carries only a subject, so this comes from the membership.
@@ -51,23 +57,8 @@ function currentRoute() {
51
57
  }
52
58
  return route;
53
59
  }
54
- const escape = (value: unknown) =>
55
- String(value ?? "").replace(
56
- /[&<>"']/g,
57
- (char) =>
58
- ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
59
- char
60
- ]!,
61
- );
62
60
  const element = <T extends HTMLElement>(id: string) =>
63
61
  document.getElementById(id) as T | null;
64
- function badges() {
65
- const entries: string[] = config.badges;
66
- if (!entries?.length) return "";
67
- return `<footer class="signin-badges">${entries
68
- .map((entry) => `<span>${escape(entry)}</span>`)
69
- .join("")}</footer>`;
70
- }
71
62
  // Signing out of this app revokes this app's access and nothing else: the Fidj
72
63
  // session survives, which is what makes the next app free to enter. That is a
73
64
  // good design and a bad surprise, so the notice says what actually happened and
@@ -132,7 +123,10 @@ function wireSignOut() {
132
123
  () =>
133
124
  void action(async () => {
134
125
  const wasSignedIn = signedIn;
135
- if (signedIn) await sdk.logout(true);
126
+ if (signedIn) {
127
+ if (oidc?.hasSession()) await oidc.logout();
128
+ else await sdk.logout(true);
129
+ }
136
130
  forgetSignIn(config.appId);
137
131
  signedIn = false;
138
132
  anonymous = false;
@@ -145,16 +139,6 @@ function wireSignOut() {
145
139
  );
146
140
  }
147
141
 
148
- function highlights() {
149
- const entries: Array<{ heading: string; body: string }> = config.highlights;
150
- if (!entries?.length) return "";
151
- return `<div class="signin-highlights">${entries
152
- .map(
153
- (entry, index) =>
154
- `<article><p class="eyebrow">${String(index + 1).padStart(2, "0")}</p><h2>${escape(entry.heading)}</h2><p>${escape(entry.body)}</p></article>`,
155
- )
156
- .join("")}</div>`;
157
- }
158
142
  async function request(path: string, method = "GET", data?: unknown) {
159
143
  const token = await sdk.fidjGetIdToken();
160
144
  const response = await fetch(config.apiEndpoint + path, {
@@ -333,6 +317,19 @@ let recognising = false;
333
317
  function mightBeRecognised() {
334
318
  if (!oidc || !isFidjItself) return false;
335
319
  if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
320
+ // Not on a screen somebody was sent to by a link.
321
+ //
322
+ // Asking the provider is a navigation: it leaves this address and comes back
323
+ // at the redirect URI, without the hash the link carried. On these routes the
324
+ // hash is the point — it holds a token that works once — so asking spent the
325
+ // link and dropped the person on the entry, with nothing to show for having
326
+ // opened their mail.
327
+ //
328
+ // It hid well. Every other way of reaching these screens comes from a page
329
+ // that already asked once and recorded it below, and the ask is once per
330
+ // document; only a tab that has never seen Fidj fires it here, which is
331
+ // exactly the tab a mail client opens and the one nothing exercised.
332
+ if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
336
333
  if (oidc.signedOutHere()) return false;
337
334
  try {
338
335
  return sessionStorage.getItem(RECOGNITION_ASKED) !== "true";
@@ -345,6 +342,19 @@ async function askWhetherFidjKnowsThisBrowser() {
345
342
  if (!oidc || !isFidjItself || sdk.isLoggedIn() || oidc.signedOutHere())
346
343
  return false;
347
344
  if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
345
+ // Not on a screen somebody was sent to by a link.
346
+ //
347
+ // Asking the provider is a navigation: it leaves this address and comes back
348
+ // at the redirect URI, without the hash the link carried. On these routes the
349
+ // hash is the point — it holds a token that works once — so asking spent the
350
+ // link and dropped the person on the entry, with nothing to show for having
351
+ // opened their mail.
352
+ //
353
+ // It hid well. Every other way of reaching these screens comes from a page
354
+ // that already asked once and recorded it below, and the ask is once per
355
+ // document; only a tab that has never seen Fidj fires it here, which is
356
+ // exactly the tab a mail client opens and the one nothing exercised.
357
+ if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
348
358
  // Once per document: the answer comes back as a redirect to this same page,
349
359
  // so without this a refusal would ask again, and again.
350
360
  try {
@@ -427,11 +437,6 @@ function navigate(route: string) {
427
437
  if (route !== currentRoute()) window.history.pushState(null, "", "#/" + route);
428
438
  if (!busy) render();
429
439
  }
430
- // The credential fields, in one place because the entry now shows them beside
431
- // the Fidj door rather than instead of it.
432
- function credentialFields() {
433
- return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" placeholder="you@company.com" autocomplete="username"><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"><button type="button" id="reveal" aria-controls="password">Show</button></div><button class="primary" type="submit" name="entry" value="credentials">Continue</button><button class="secondary" type="submit" name="signup" value="true">Create an account</button>`;
434
- }
435
440
 
436
441
  function moduleRoute() {
437
442
  const route = window.location.hash.slice(2).split("?")[0];
@@ -495,6 +500,8 @@ function render() {
495
500
  if (interactionId && !addressedInteraction()) {
496
501
  interactionId = "";
497
502
  interactionError = "";
503
+ interactionResent = false;
504
+ interactionNotYet = false;
498
505
  interaction = null;
499
506
  interactionFailed = false;
500
507
  }
@@ -581,7 +588,7 @@ function render() {
581
588
  // The two ways out, last and together: back to the app, or out of this
582
589
  // session. Leaving the app itself is a different decision and stays where
583
590
  // the things it erases are listed.
584
- 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>`;
591
+ 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>`;
585
592
  renderNav("account");
586
593
  wireAccount("account");
587
594
  wireSignOut();
@@ -604,21 +611,13 @@ function render() {
604
611
  }
605
612
  // Only the entry reaches here now: the privacy screen and the account
606
613
  // screen became one, and that one is drawn above.
607
- 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>
614
+ root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
608
615
  <div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
609
- ${highlights()}</div>
610
- <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>` : ""}
616
+ ${highlightCells(config.highlights)}</div>
617
+ <div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin">${credentialFields({email: signInEmail, password: signInPassword})}</form>${config.allowAnonymous ? `<div class="signin-divider"><span>or explore first</span></div><button class="anonymous-entry" id="anonymous">Enter anonymously <span aria-hidden="true">→</span></button><p class="signin-footnote">No account needed to view the content.</p>` : ""}
611
618
  <div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>Your account, with Fidj</strong></p><p>Signing in creates one Fidj account you keep across every app that uses Fidj.</p><p>You choose what this app may store — and can export or erase it at any moment.</p></div></div>
612
- ${badges()}</div></section>`;
619
+ ${badgeStrip(config.badges)}</div></section>`;
613
620
  wireNav();
614
- element("reveal")?.addEventListener("click", () => {
615
- const field = element<HTMLInputElement>("password");
616
- const button = element("reveal");
617
- if (!field || !button) return;
618
- const hidden = field.type === "password";
619
- field.type = hidden ? "text" : "password";
620
- button.textContent = hidden ? "Hide" : "Show";
621
- });
622
621
  element("anonymous")?.addEventListener("click", () => {
623
622
  if (!config.allowAnonymous) return;
624
623
  anonymous = true;
@@ -635,14 +634,24 @@ function render() {
635
634
  element("signin")!.innerHTML = providerEntry(
636
635
  config.title,
637
636
  config.appId,
638
- config.signin === "button" ? "" : credentialFields(),
637
+ config.signin === "button"
638
+ ? ""
639
+ : credentialFields({email: signInEmail, password: signInPassword}),
639
640
  isFidjItself,
640
641
  config.signin as SigninShape,
641
642
  );
643
+ // The agreement, when the API has said this app is owed one. It takes the
644
+ // form's place rather than sitting under it: the credentials were accepted,
645
+ // and what is left is a decision about this app.
646
+ if (pendingAgreement && element("signin")) {
647
+ element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement, `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
648
+ bindAgreementScreen(element<HTMLFormElement>("signin"));
649
+ }
642
650
  // The app's own form, for whoever came to type a password. Folded away rather
643
651
  // than removed: the door above it is the one to take, and the person who
644
652
  // wants this one is one click from it.
645
- if (emailEntryOpen) showEmailEntry(true);
653
+ if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
654
+ if (!pendingAgreement && element("signin")) bindPasswordReveal(element("signin")!);
646
655
  element("use-email")?.addEventListener("click", () => {
647
656
  emailEntryOpen = !emailEntryOpen;
648
657
  showEmailEntry(emailEntryOpen, true);
@@ -662,31 +671,61 @@ function render() {
662
671
  }
663
672
  render();
664
673
  });
665
- void bindAgreement(element<HTMLFormElement>("signin"), config.title, config.apiEndpoint, config.appId, signInAgreementAccepted);
674
+ // The wait a just-created account owes its address, under the form that
675
+ // created it — not a screen, because nobody was taken anywhere.
676
+ if (awaitingVerification)
677
+ element("signin")?.insertAdjacentHTML(
678
+ "beforeend",
679
+ verificationWait({
680
+ email: awaitingVerification,
681
+ resent: verificationResent,
682
+ error: verificationNotice,
683
+ }),
684
+ );
685
+ element("resend-verification")?.addEventListener("click", () =>
686
+ void action(async () => {
687
+ verificationResent = false;
688
+ verificationNotice = "";
689
+ try {
690
+ await sdk.resendVerification();
691
+ verificationResent = true;
692
+ } catch {
693
+ // No session yet — the account was created and never signed in, which
694
+ // is the whole point of this screen. Creating it again with the same
695
+ // address is what sends another link.
696
+ verificationNotice =
697
+ "Press Create an account again to send another link.";
698
+ }
699
+ }),
700
+ );
666
701
  element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
667
702
  event.preventDefault();
668
703
  const email = element<HTMLInputElement>("email")?.value || "";
669
704
  const password = element<HTMLInputElement>("password")?.value || "";
670
- const agreement = element<HTMLInputElement>("service-agreement");
705
+ const submitter = event.submitter as HTMLButtonElement | null;
706
+ // Answering the agreement screen. The credentials are the ones already
707
+ // accepted, so they are not re-read from a form that no longer shows them.
708
+ if (pendingAgreement) {
709
+ const acceptance = acceptedAgreement(
710
+ event.currentTarget as HTMLFormElement,
711
+ );
712
+ if (!acceptance) return;
713
+ submitter?.setAttribute("data-busy", "true");
714
+ void action(async () => {
715
+ if (
716
+ await refusedBeforeSignIn(signInEmail, signInPassword, false, acceptance)
717
+ )
718
+ return;
719
+ await completeSignIn();
720
+ });
721
+ return;
722
+ }
671
723
  signInEmail = email;
672
724
  signInPassword = password;
673
- signInAgreementAccepted = agreement?.checked === true;
674
- const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
675
- const submitter = event.submitter as HTMLButtonElement | null;
676
725
  const signup = submitter?.name === "signup";
677
726
  // Which door was used. The Fidj one leaves for the provider; the credential
678
727
  // one signs in here, which is why it is the app's own form and not Fidj's.
679
728
  const throughFidj = submitter?.name === "entry" && submitter.value === "fidj";
680
- // Only the credential door is gated here. The Fidj one is about to be asked
681
- // the same question on the screen that names this app, where the answer is
682
- // recorded with its version — so asking first cost a second click and kept
683
- // nothing.
684
- if (!throughFidj && !acceptance) {
685
- failed = true;
686
- message = "Please accept the service agreement before continuing.";
687
- render();
688
- return;
689
- }
690
729
  if (oidc && throughFidj) {
691
730
  // "Continue as <them>" promises to carry on as that person, and handed
692
731
  // them over to an empty email field — so the promise cost a second
@@ -704,11 +743,69 @@ function render() {
704
743
  if (oidc && (!email || !password)) {
705
744
  throw new Error("Enter your email and password, or sign in with Fidj.");
706
745
  }
707
- try {
708
- await sdk.login(email, password, { autoSignup: signup, ...acceptance });
709
- } catch (error) {
710
- throw new Error(signInErrorMessage(error));
746
+ if (await refusedBeforeSignIn(email, password, signup)) return;
747
+ await completeSignIn();
748
+ });
749
+ });
750
+ }
751
+
752
+ // The API decides what the entry owes somebody next: a session, the agreement it
753
+ // has not recorded for this app, or a wait on an address nobody has proved they
754
+ // own. Returns true when it answered with a screen rather than a session.
755
+ async function refusedBeforeSignIn(
756
+ email: string,
757
+ password: string,
758
+ signup: boolean,
759
+ acceptance?: {termsAccepted: boolean; termsVersion: string},
760
+ ): Promise<boolean> {
761
+ try {
762
+ await sdk.login(email, password, {autoSignup: signup, ...acceptance});
763
+ pendingAgreement = null;
764
+ awaitingVerification = "";
765
+ return false;
766
+ } catch (error) {
767
+ const created = verificationPending(error);
768
+ if (created) {
769
+ awaitingVerification = created.email || email;
770
+ verificationResent = false;
771
+ verificationNotice = "";
772
+ return true;
773
+ }
774
+ if (agreementRequired(error)) {
775
+ pendingAgreement = agreementFromRefusal(error) || (await readAgreement());
776
+ if (!pendingAgreement) {
777
+ throw new Error("We cannot reach Fidj right now. Please try again.");
711
778
  }
779
+ return true;
780
+ }
781
+ throw new Error(signInErrorMessage(error));
782
+ }
783
+ }
784
+
785
+ // An API that refused without saying which agreement it wanted. Older ones
786
+ // cannot say, so the app is asked directly; it is the same agreement unless the
787
+ // owner published between the two calls.
788
+ async function readAgreement() {
789
+ try {
790
+ const response = await fetch(
791
+ `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}`,
792
+ {signal: AbortSignal.timeout(10000)},
793
+ );
794
+ if (!response.ok) return null;
795
+ const agreement = (await response.json()).app?.agreement;
796
+ return typeof agreement?.version === "string" &&
797
+ agreement.version &&
798
+ typeof agreement?.text === "string" &&
799
+ agreement.text
800
+ ? {version: agreement.version, text: agreement.text}
801
+ : null;
802
+ } catch {
803
+ return null;
804
+ }
805
+ }
806
+
807
+ function completeSignIn() {
808
+ return (async () => {
712
809
  // Fidj collects the credential on its own page rather than sending itself
713
810
  // through its own door, and that sign-in created no session the provider
714
811
  // could see: the first app opened afterwards asked for the password again,
@@ -718,14 +815,12 @@ function render() {
718
815
  // every app must not be mintable by one that collects its own passwords.
719
816
  if (isFidjItself) {
720
817
  try {
721
- await sdk.sendOnEndpoint({
722
- verb: "POST",
723
- key: "me",
724
- relativePath: "oidc/session",
725
- // The whole point of the call is the cookie it comes back with, and
726
- // a cross-origin response's Set-Cookie is dropped without this.
727
- withCredentials: true,
728
- });
818
+ const transfer = await request("/me/oidc/session-transfer", "POST");
819
+ // The provider cookie belongs to the API origin. A cross-site fetch
820
+ // cannot reliably replace it, so let that origin answer once as the
821
+ // top-level page, consume a one-use ticket, then send us back here.
822
+ window.location.assign(transfer.location);
823
+ return;
729
824
  } catch {
730
825
  // Being signed in here still worked. The person is inside Fidj; what
731
826
  // they lose is being recognised by the next app without typing again,
@@ -734,12 +829,11 @@ function render() {
734
829
  }
735
830
  await refresh();
736
831
  anonymous = false;
737
- // A new account belongs where a returning one lands: inside the app.
738
- // Sending it to the account card instead dropped people who had just
739
- // signed up on the shell, one click short of the app they came for.
740
- navigate("content");
741
- });
742
- });
832
+ // A new account belongs where a returning one lands: inside the app.
833
+ // Sending it to the account card instead dropped people who had just
834
+ // signed up on the shell, one click short of the app they came for.
835
+ navigate("content");
836
+ })();
743
837
  }
744
838
 
745
839
  // Everything the account screen lets a person do about what this app holds:
@@ -829,8 +923,8 @@ function wirePrivacy() {
829
923
  });
830
924
  }
831
925
 
832
- // The four account screens. My account is shown inside the app; the recovery
833
- // and verification ones are reached without a session and stand alone.
926
+
927
+
834
928
  // What this app holds about the person signed in, and what they can do
835
929
  // about it. It used to be a screen of its own called My privacy, one tab
836
930
  // away from the account it was about — so a person looking for their own
@@ -858,17 +952,6 @@ function privacyBlock() {
858
952
  <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>`;
859
953
  }
860
954
 
861
- function accountForm(route: string) {
862
- return route === "forgot"
863
- ? `<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>`
864
- : route === "reset"
865
- ? `<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>'}`
866
- : route === "verify"
867
- ? `<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>"}`
868
- : `<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>`;
869
- }
870
-
871
-
872
955
  // ------------------------------------------------ signing in, for the provider
873
956
  //
874
957
  // When Fidj's provider needs a person to identify themselves it hands them to a
@@ -883,14 +966,24 @@ function accountForm(route: string) {
883
966
  type Interaction = {
884
967
  prompt: string;
885
968
  csrf: string;
969
+ // The address this interaction is waiting on, when it is waiting rather than
970
+ // asking. Empty on every other screen.
971
+ awaiting?: string;
886
972
  app: { id: string; title: string; description: string };
887
973
  scopes: string[];
888
974
  termsUri: string;
889
975
  privacyUri: string;
976
+ // The agreement's own text, so it can be read here rather than in another tab.
977
+ agreement?: {version: string; text: string} | null;
890
978
  action: string;
891
979
  };
892
980
  let interactionId = "";
893
981
  let interactionError = "";
982
+ // Whether the link was just sent again, or pressed Continue before opening it.
983
+ // They are about the wait, not about the person, so they travel in the address
984
+ // where the address itself does not.
985
+ let interactionResent = false;
986
+ let interactionNotYet = false;
894
987
  let interaction: Interaction | null = null;
895
988
  let interactionFailed = false;
896
989
 
@@ -939,6 +1032,8 @@ function readInteraction() {
939
1032
  if (!uid) return false;
940
1033
  interactionId = uid;
941
1034
  interactionError = parameters.get("error") || "";
1035
+ interactionResent = parameters.get("resent") === "1";
1036
+ interactionNotYet = parameters.get("notyet") === "1";
942
1037
  // The id stays in the address while the screen is up. Taking it out looked
943
1038
  // tidier and made the screen a trap: the address became "#/signin", so going
944
1039
  // back to "#/signin" changed nothing, the document never reloaded, and the
@@ -961,12 +1056,14 @@ async function loadInteraction() {
961
1056
  interaction = (await response.json()) as Interaction;
962
1057
  }
963
1058
 
964
- // A window that opened on its own, over the page somebody was on, owes them the
965
- // way out before it asks for anything. Naming the app they came from is also the
966
- // only thing on this screen that they can check against what they were doing a
967
- // second ago — which is exactly what a page asking for a password should offer.
968
- function returnNotice(asking: string) {
969
- return `<p class="signin-return" role="note">When you are done, this window closes and takes you back to ${escape(asking)}.</p>`;
1059
+
1060
+ // What the wait has to say beyond the address, if anything.
1061
+ function interactionWaitNotice() {
1062
+ if (interactionNotYet)
1063
+ return '<p class="fineprint">The link has not been opened yet. Open it, then press Continue again.</p>';
1064
+ if (interactionResent)
1065
+ return '<p class="fineprint">The link was sent again. Only the newest one works.</p>';
1066
+ return "";
970
1067
  }
971
1068
 
972
1069
  function interactionScreen() {
@@ -987,8 +1084,24 @@ function interactionScreen() {
987
1084
  // sees your password" about itself is nonsense in the same family as offering
988
1085
  // to sign in with Fidj on Fidj.
989
1086
  const itself = details.app.id === config.appId;
990
- const body =
991
- details.prompt === "login"
1087
+ // The wait, when this interaction is waiting on an address rather than asking
1088
+ // for anything. It is not a refusal and must not be drawn as one: the account
1089
+ // exists, the link was sent, and what is left is opening it. The address is
1090
+ // read from the interaction's own context rather than from the URL, so
1091
+ // nothing about the person travels in an address bar.
1092
+ const body = details.awaiting
1093
+ ? `<h2>Check your email</h2>
1094
+ <p class="signin-lead">Your account is created. Waiting for you to open the link sent to <strong>${escape(details.awaiting)}</strong>.</p>
1095
+ ${returnNotice(itself ? "Fidj" : asking)}
1096
+ <form method="post" action="${escape(action)}" id="interaction">
1097
+ <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1098
+ <p class="fineprint">The link may take a minute, and it sometimes lands in spam. Open it, then come back here.</p>
1099
+ ${interactionWaitNotice()}
1100
+ <button class="primary" type="submit" name="action" value="continue">Continue</button>
1101
+ <button class="secondary" type="submit" name="action" value="resend">Send the link again</button>
1102
+ <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1103
+ </form>`
1104
+ : details.prompt === "login"
992
1105
  ? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
993
1106
  <p class="signin-lead">${itself ? "One account across every app that uses Fidj, and a separate set of choices for each one." : `This is Fidj, the account behind ${asking}. One account, and separate choices for every app that uses it — ${asking} never sees your password.`}</p>
994
1107
  ${returnNotice(itself ? "Fidj" : asking)}
@@ -1012,28 +1125,22 @@ function interactionScreen() {
1012
1125
  .join("")}</ul>
1013
1126
  <form method="post" action="${escape(action)}" id="interaction">
1014
1127
  <input type="hidden" name="csrf" value="${escape(details.csrf)}">
1015
- <label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>I accept ${asking}'s service agreement.</span></label>
1128
+ <label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>${escape(agreementModel(details.app.title, {}).checkboxLabel)}</span></label>
1129
+ ${details.agreement ? `<details class="agreement"><summary>Read service agreement</summary><p class="fineprint">Version ${escape(details.agreement.version)}</p><p>${escape(details.agreement.text)}</p></details><p class="fineprint">Required to sign in. Optional data choices stay separate.</p>` : ""}
1016
1130
  ${details.termsUri ? `<p class="fineprint"><a href="${escape(details.termsUri)}" target="_blank" rel="noopener noreferrer">Service agreement</a>${details.privacyUri ? ` · <a href="${escape(details.privacyUri)}" target="_blank" rel="noopener noreferrer">Privacy notice</a>` : ""}</p>` : ""}
1017
1131
  <button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
1018
1132
  <button class="quiet" type="submit" id="not-me" name="action" value="switch" formnovalidate>Not you? Sign in with another account</button>
1019
1133
  <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1020
1134
  </form>`;
1021
1135
 
1022
- root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
1136
+ root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
1023
1137
  <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>
1024
- ${highlights()}</div>
1138
+ ${highlightCells(config.highlights)}</div>
1025
1139
  <div class="signin-form"><div>${body}</div>
1026
1140
  <div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>What Fidj is</strong></p><p>Fidj holds your account so each app does not have to. You can see every app you use, what it holds, and take it back — at any time.</p></div></div>
1027
- ${badges()}</section>`;
1141
+ ${badgeStrip(config.badges)}</section>`;
1028
1142
 
1029
- element("reveal")?.addEventListener("click", () => {
1030
- const field = element<HTMLInputElement>("password");
1031
- const button = element("reveal");
1032
- if (!field || !button) return;
1033
- const hidden = field.type === "password";
1034
- field.type = hidden ? "text" : "password";
1035
- button.textContent = hidden ? "Hide" : "Show";
1036
- });
1143
+ if (element("interaction")) bindPasswordReveal(element("interaction")!);
1037
1144
  // Being recognised is the point, and a dead end when the person is not who
1038
1145
  // Fidj thinks — a shared computer, a second account, somebody else's tab. So
1039
1146
  // the screen that recognises them asks again on request. It goes back to the
@@ -1057,10 +1164,11 @@ function interactionScreen() {
1057
1164
  }
1058
1165
 
1059
1166
  function renderAccount(route: string) {
1060
- root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
1167
+ root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
1061
1168
  <div class="signin-identity"><h1>Your account.<br>Your control.</h1><p class="signin-description">Secure access to the apps you use, with one Fidj identity.</p></div>
1062
1169
  </div>
1063
- <div class="signin-form"><div>${banner()}${accountForm(route)}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
1170
+ <div class="signin-form"><div>${banner()}${accountForm(route, {linkToken, verificationConfirmed, emailVerified, accountEmail})}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
1171
+ bindPasswordReveal(root);
1064
1172
  wireAccount(route);
1065
1173
  }
1066
1174