@ofidj/generator-fidj 3.10.2 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,19 +51,28 @@ one.
51
51
  - `--logo <image>` and `--favicon <image>`: the app's own marks, copied into `public/brand/`. The logo sits beside the app name at the top of the sign-in panel; the favicon goes in the browser tab. Both fall back to the Fidj mark, so neither is ever blank. Accepts `.png`, `.svg`, `.gif`, `.jpg`, `.webp` or `.ico` under 512KB — an animated GIF works as a logo, and as a favicon in the browsers that animate one.
52
52
  - `--api-endpoint`: select the API (default: hosted sandbox).
53
53
  - `--sdk-path` or `FIDJ_SDK_DIR`: use a built local SDK during coordinated development.
54
+ - `--entry-path` or `FIDJ_ENTRY_DIR`: same, for a built local `@ofidj/entry` — the sign-in and account screens, the agreement and the design system. Point it at that package's `dist`, as with the SDK.
54
55
  - `--local` or `FIDJ_LOCAL=true`: use the loopback API/console. Test credentials stay in the validation guide, outside the content app UI. Supply the matching local app ID; `FIDJ_APP_ID` can override it.
55
56
  - `--replace`: regenerate only a destination containing `.fidj-generated`; unmarked projects are protected.
56
57
 
57
- The generated shell paints from one design system: `src/tokens.css` holds every
58
- colour, family and radius, and `src/style.css` may not introduce a literal of
59
- its own. Fonts are self-hosted under `public/fonts` and served from the build
60
- manifest, so a generated site stays statically hostable and makes no
61
- third-party request on sign-in. fidj-app consumes the same tokens, which is why
62
- a style change lands here first.
58
+ **The entry is a dependency, not a copy.** `@ofidj/entry` carries the sign-in
59
+ and account screens, the service agreement, the provider window, the version
60
+ badge and the design system; the generated app imports them the way it imports
61
+ the SDK. They used to be files in this template, which meant the only way to
62
+ ship a fix was to regenerate every app — and meant Fidj's own console had to be
63
+ generated to reach them. A style change now lands in that package, and this
64
+ template is what remains genuinely the generator's: the shells that hold the
65
+ screens, the content app and the Notes app.
66
+
67
+ The design system is `@ofidj/entry`'s `tokens.css` — every colour, family and
68
+ radius — and its `style.css`, which may not introduce a literal of its own.
69
+ Fonts are self-hosted under `public/fonts` and served from the build manifest,
70
+ so a generated site stays statically hostable and makes no third-party request
71
+ on sign-in.
63
72
 
64
73
  Generation writes `.env.example`, `.env` and public `app.config.json`. Static configuration is embedded at build time: regenerate/rebuild when changing endpoints. Notes server configuration is read at runtime. Do not place secrets in any public configuration or content input.
65
74
 
66
- For unpublished coordinated changes, build the sibling SDK and pass `--sdk-path` with its absolute `dist` path. Committed templates use registry dependency `@ofidj/node ^3.7.3`; a registry-only install is not validated until coordinated versions are published.
75
+ For unpublished coordinated changes, build the sibling SDK and entry packages and pass `--sdk-path` and `--entry-path` with their absolute `dist` paths. Committed templates name registry ranges for both; a registry-only install is not validated until coordinated versions are published.
67
76
 
68
77
  Every generated app carries a fixed bottom-right badge naming the Fidj it runs:
69
78
  `fidj@<version>`, taken from the SDK it was generated with — the `--sdk-path`
@@ -106,10 +115,11 @@ The generated content app opens on `/#/signin`. Sign in, or choose **Enter anony
106
115
  is the one control on the screen wearing `--fidj-accent`, because it is the one
107
116
  that belongs to Fidj rather than to the app — and it is the path where the app
108
117
  never sees a password. An app generated with `--signin both` keeps its own
109
- email-and-password form under an *Inline form* disclosure, with its service
110
- agreement: opening it folds the Fidj door away, because the two are alternatives
111
- rather than a list. That agreement gates the app's own door and nothing else, so
112
- an agreement that fails to load never shuts the Fidj door.
118
+ email-and-password form under an *Inline form* disclosure: opening it folds the
119
+ Fidj door away, because the two are alternatives rather than a list. Whichever
120
+ door is taken runs the same two screens — credentials, with the verification
121
+ wait appearing beneath them on the create path, and then the agreement — so the
122
+ button is a shortcut to the flow, not a different one.
113
123
 
114
124
  Pressing it opens Fidj in a browser window of its own rather than navigating
115
125
  away. Fidj's screens are served from another origin and refuse to be framed, so
@@ -161,11 +171,26 @@ A compatible issuer, REST API and registered callback without a fragment are pre
161
171
 
162
172
  ## Required service agreement
163
173
 
164
- All generated sign-in and account-creation forms (content, composed Fidj and
165
- Notes) share an unchecked required agreement checkbox. Both submit buttons stay
166
- disabled until it is checked. Reading the agreement opens an in-app dialog;
167
- failed agreement loading keeps sign-in blocked. Anonymous entry, when enabled,
168
- is not a login and does not record agreement acceptance.
174
+ [The workspace README](../README.md#entry-one-flow-the-same-everywhere) defines
175
+ the flow every Fidj sign-in surface follows, and this generator owns the
176
+ implementation the others render. In short: the first screen asks for an email,
177
+ a password, **Sign in** and **Create an account**, and gates neither; creating an
178
+ account grows a verification wait beneath that same form rather than replacing
179
+ it; and the agreement is its own screen, shown whenever the version recorded for
180
+ this person and this app is not the current one, with its submit button
181
+ read-only until the box is ticked.
182
+
183
+ The agreement screen's markup, its loading and its disabled-button rule live in
184
+ `@ofidj/entry` — one implementation for the app's own form, the composed Fidj
185
+ console and the Fidj-hosted consent page of the beta OIDC provider — so an owner
186
+ who publishes a new version changes one thing and every surface asks again.
187
+ Anonymous entry, when enabled, is not a login and records no acceptance.
188
+
189
+ Both generated entries render this. The content app and the Notes starter lost
190
+ their checkbox beside the credentials and their local gate; each calls `login`,
191
+ and the API's `409 agreement_required` is what puts the agreement screen on
192
+ screen, carrying the version it compared against. `--signin button` delegates
193
+ the whole question to Fidj and never had one.
169
194
 
170
195
  The text and version come from the app's public API metadata, not copied generator
171
196
  settings. The API records acceptance before issuing the app token, preserves
@@ -9,6 +9,7 @@ try {
9
9
  "api-endpoint": { type: "string" },
10
10
  "oidc-issuer": { type: "string" },
11
11
  "sdk-path": { type: "string" },
12
+ "entry-path": { type: "string" },
12
13
  title: { type: "string" },
13
14
  welcome: { type: "string" },
14
15
  description: { type: "string" },
@@ -39,6 +40,7 @@ try {
39
40
  apiEndpoint: values["api-endpoint"],
40
41
  oidcIssuer: values["oidc-issuer"],
41
42
  sdkPath: values["sdk-path"] || process.env.FIDJ_SDK_DIR,
43
+ entryPath: values["entry-path"] || process.env.FIDJ_ENTRY_DIR,
42
44
  title: values.title,
43
45
  welcome: values.welcome,
44
46
  description: values.description,
@@ -9,6 +9,7 @@ const TEXT_OPTIONS = [
9
9
  "api-endpoint",
10
10
  "oidc-issuer",
11
11
  "sdk-path",
12
+ "entry-path",
12
13
  "title",
13
14
  "welcome",
14
15
  "description",
@@ -99,6 +100,7 @@ module.exports = class extends Generator {
99
100
  moduleEntry: this.options["module-entry"],
100
101
  domain: this.options.domain,
101
102
  sdkPath: this.options["sdk-path"] || process.env.FIDJ_SDK_DIR,
103
+ entryPath: this.options["entry-path"] || process.env.FIDJ_ENTRY_DIR,
102
104
  oidcIssuer: this.options["oidc-issuer"],
103
105
  local: this.options.local || process.env.FIDJ_LOCAL === "true",
104
106
  replace: this.options.replace,
@@ -15,7 +15,8 @@
15
15
  "privacy:rehearse": "npm run build && node --test test/server.test.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@ofidj/node": "^3.10.0"
18
+ "@ofidj/entry": "^3.11.0",
19
+ "@ofidj/node": "^3.11.0"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/node": "^22.0.0",
@@ -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 {signInErrorMessage, agreementRequired, agreementFromRefusal, verificationPending, pollVerification, rememberSignIn, forgetSignIn, signInHint, type SigninShape} from "@ofidj/entry";
2
+ import {agreementScreen, bindAgreementScreen, acceptedAgreement, verificationWait, providerEntry, showEmailEntry, showVersionBadge, escape, masthead, highlightCells, badgeStrip, credentialFields, accountForm, returnNotice} from "@ofidj/entry/dom";
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
@@ -145,16 +136,6 @@ function wireSignOut() {
145
136
  );
146
137
  }
147
138
 
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
139
  async function request(path: string, method = "GET", data?: unknown) {
159
140
  const token = await sdk.fidjGetIdToken();
160
141
  const response = await fetch(config.apiEndpoint + path, {
@@ -333,6 +314,19 @@ let recognising = false;
333
314
  function mightBeRecognised() {
334
315
  if (!oidc || !isFidjItself) return false;
335
316
  if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
317
+ // Not on a screen somebody was sent to by a link.
318
+ //
319
+ // Asking the provider is a navigation: it leaves this address and comes back
320
+ // at the redirect URI, without the hash the link carried. On these routes the
321
+ // hash is the point — it holds a token that works once — so asking spent the
322
+ // link and dropped the person on the entry, with nothing to show for having
323
+ // opened their mail.
324
+ //
325
+ // It hid well. Every other way of reaching these screens comes from a page
326
+ // that already asked once and recorded it below, and the ask is once per
327
+ // document; only a tab that has never seen Fidj fires it here, which is
328
+ // exactly the tab a mail client opens and the one nothing exercised.
329
+ if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
336
330
  if (oidc.signedOutHere()) return false;
337
331
  try {
338
332
  return sessionStorage.getItem(RECOGNITION_ASKED) !== "true";
@@ -345,6 +339,19 @@ async function askWhetherFidjKnowsThisBrowser() {
345
339
  if (!oidc || !isFidjItself || sdk.isLoggedIn() || oidc.signedOutHere())
346
340
  return false;
347
341
  if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
342
+ // Not on a screen somebody was sent to by a link.
343
+ //
344
+ // Asking the provider is a navigation: it leaves this address and comes back
345
+ // at the redirect URI, without the hash the link carried. On these routes the
346
+ // hash is the point — it holds a token that works once — so asking spent the
347
+ // link and dropped the person on the entry, with nothing to show for having
348
+ // opened their mail.
349
+ //
350
+ // It hid well. Every other way of reaching these screens comes from a page
351
+ // that already asked once and recorded it below, and the ask is once per
352
+ // document; only a tab that has never seen Fidj fires it here, which is
353
+ // exactly the tab a mail client opens and the one nothing exercised.
354
+ if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
348
355
  // Once per document: the answer comes back as a redirect to this same page,
349
356
  // so without this a refusal would ask again, and again.
350
357
  try {
@@ -427,11 +434,6 @@ function navigate(route: string) {
427
434
  if (route !== currentRoute()) window.history.pushState(null, "", "#/" + route);
428
435
  if (!busy) render();
429
436
  }
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
437
 
436
438
  function moduleRoute() {
437
439
  const route = window.location.hash.slice(2).split("?")[0];
@@ -581,7 +583,7 @@ function render() {
581
583
  // The two ways out, last and together: back to the app, or out of this
582
584
  // session. Leaving the app itself is a different decision and stays where
583
585
  // 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>`;
586
+ root.innerHTML = `<section class="card content-account">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail})}${privacyBlock()}<div class="account-actions"><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button><button id="exit">Sign out</button></div></section>`;
585
587
  renderNav("account");
586
588
  wireAccount("account");
587
589
  wireSignOut();
@@ -604,12 +606,12 @@ function render() {
604
606
  }
605
607
  // Only the entry reaches here now: the privacy screen and the account
606
608
  // 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>
609
+ root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
608
610
  <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>` : ""}
611
+ ${highlightCells(config.highlights)}</div>
612
+ <div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin"><label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" placeholder="you@company.com" autocomplete="username" required><div class="field-head"><label for="password">Password</label><a href="#/forgot">Forgot?</a></div><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" placeholder="••••••••••" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div><button class="primary" type="submit">Sign in</button><button class="secondary" type="submit" name="signup" value="true">Create an account</button></form>${config.allowAnonymous ? `<div class="signin-divider"><span>or explore first</span></div><button class="anonymous-entry" id="anonymous">Enter anonymously <span aria-hidden="true">→</span></button><p class="signin-footnote">No account needed to view the content.</p>` : ""}
611
613
  <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>`;
614
+ ${badgeStrip(config.badges)}</div></section>`;
613
615
  wireNav();
614
616
  element("reveal")?.addEventListener("click", () => {
615
617
  const field = element<HTMLInputElement>("password");
@@ -635,14 +637,23 @@ function render() {
635
637
  element("signin")!.innerHTML = providerEntry(
636
638
  config.title,
637
639
  config.appId,
638
- config.signin === "button" ? "" : credentialFields(),
640
+ config.signin === "button"
641
+ ? ""
642
+ : credentialFields({email: signInEmail, password: signInPassword}),
639
643
  isFidjItself,
640
644
  config.signin as SigninShape,
641
645
  );
646
+ // The agreement, when the API has said this app is owed one. It takes the
647
+ // form's place rather than sitting under it: the credentials were accepted,
648
+ // and what is left is a decision about this app.
649
+ if (pendingAgreement && element("signin")) {
650
+ element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement);
651
+ bindAgreementScreen(element<HTMLFormElement>("signin"));
652
+ }
642
653
  // The app's own form, for whoever came to type a password. Folded away rather
643
654
  // than removed: the door above it is the one to take, and the person who
644
655
  // wants this one is one click from it.
645
- if (emailEntryOpen) showEmailEntry(true);
656
+ if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
646
657
  element("use-email")?.addEventListener("click", () => {
647
658
  emailEntryOpen = !emailEntryOpen;
648
659
  showEmailEntry(emailEntryOpen, true);
@@ -662,31 +673,61 @@ function render() {
662
673
  }
663
674
  render();
664
675
  });
665
- void bindAgreement(element<HTMLFormElement>("signin"), config.title, config.apiEndpoint, config.appId, signInAgreementAccepted);
676
+ // The wait a just-created account owes its address, under the form that
677
+ // created it — not a screen, because nobody was taken anywhere.
678
+ if (awaitingVerification)
679
+ element("signin")?.insertAdjacentHTML(
680
+ "beforeend",
681
+ verificationWait({
682
+ email: awaitingVerification,
683
+ resent: verificationResent,
684
+ error: verificationNotice,
685
+ }),
686
+ );
687
+ element("resend-verification")?.addEventListener("click", () =>
688
+ void action(async () => {
689
+ verificationResent = false;
690
+ verificationNotice = "";
691
+ try {
692
+ await sdk.resendVerification();
693
+ verificationResent = true;
694
+ } catch {
695
+ // No session yet — the account was created and never signed in, which
696
+ // is the whole point of this screen. Creating it again with the same
697
+ // address is what sends another link.
698
+ verificationNotice =
699
+ "Press Create an account again to send another link.";
700
+ }
701
+ }),
702
+ );
666
703
  element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
667
704
  event.preventDefault();
668
705
  const email = element<HTMLInputElement>("email")?.value || "";
669
706
  const password = element<HTMLInputElement>("password")?.value || "";
670
- const agreement = element<HTMLInputElement>("service-agreement");
707
+ const submitter = event.submitter as HTMLButtonElement | null;
708
+ // Answering the agreement screen. The credentials are the ones already
709
+ // accepted, so they are not re-read from a form that no longer shows them.
710
+ if (pendingAgreement) {
711
+ const acceptance = acceptedAgreement(
712
+ event.currentTarget as HTMLFormElement,
713
+ );
714
+ if (!acceptance) return;
715
+ submitter?.setAttribute("data-busy", "true");
716
+ void action(async () => {
717
+ if (
718
+ await refusedBeforeSignIn(signInEmail, signInPassword, false, acceptance)
719
+ )
720
+ return;
721
+ await completeSignIn();
722
+ });
723
+ return;
724
+ }
671
725
  signInEmail = email;
672
726
  signInPassword = password;
673
- signInAgreementAccepted = agreement?.checked === true;
674
- const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
675
- const submitter = event.submitter as HTMLButtonElement | null;
676
727
  const signup = submitter?.name === "signup";
677
728
  // Which door was used. The Fidj one leaves for the provider; the credential
678
729
  // one signs in here, which is why it is the app's own form and not Fidj's.
679
730
  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
731
  if (oidc && throughFidj) {
691
732
  // "Continue as <them>" promises to carry on as that person, and handed
692
733
  // them over to an empty email field — so the promise cost a second
@@ -704,11 +745,69 @@ function render() {
704
745
  if (oidc && (!email || !password)) {
705
746
  throw new Error("Enter your email and password, or sign in with Fidj.");
706
747
  }
707
- try {
708
- await sdk.login(email, password, { autoSignup: signup, ...acceptance });
709
- } catch (error) {
710
- throw new Error(signInErrorMessage(error));
748
+ if (await refusedBeforeSignIn(email, password, signup)) return;
749
+ await completeSignIn();
750
+ });
751
+ });
752
+ }
753
+
754
+ // The API decides what the entry owes somebody next: a session, the agreement it
755
+ // has not recorded for this app, or a wait on an address nobody has proved they
756
+ // own. Returns true when it answered with a screen rather than a session.
757
+ async function refusedBeforeSignIn(
758
+ email: string,
759
+ password: string,
760
+ signup: boolean,
761
+ acceptance?: {termsAccepted: boolean; termsVersion: string},
762
+ ): Promise<boolean> {
763
+ try {
764
+ await sdk.login(email, password, {autoSignup: signup, ...acceptance});
765
+ pendingAgreement = null;
766
+ awaitingVerification = "";
767
+ return false;
768
+ } catch (error) {
769
+ const created = verificationPending(error);
770
+ if (created) {
771
+ awaitingVerification = created.email || email;
772
+ verificationResent = false;
773
+ verificationNotice = "";
774
+ return true;
775
+ }
776
+ if (agreementRequired(error)) {
777
+ pendingAgreement = agreementFromRefusal(error) || (await readAgreement());
778
+ if (!pendingAgreement) {
779
+ throw new Error("We cannot reach Fidj right now. Please try again.");
711
780
  }
781
+ return true;
782
+ }
783
+ throw new Error(signInErrorMessage(error));
784
+ }
785
+ }
786
+
787
+ // An API that refused without saying which agreement it wanted. Older ones
788
+ // cannot say, so the app is asked directly; it is the same agreement unless the
789
+ // owner published between the two calls.
790
+ async function readAgreement() {
791
+ try {
792
+ const response = await fetch(
793
+ `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}`,
794
+ {signal: AbortSignal.timeout(10000)},
795
+ );
796
+ if (!response.ok) return null;
797
+ const agreement = (await response.json()).app?.agreement;
798
+ return typeof agreement?.version === "string" &&
799
+ agreement.version &&
800
+ typeof agreement?.text === "string" &&
801
+ agreement.text
802
+ ? {version: agreement.version, text: agreement.text}
803
+ : null;
804
+ } catch {
805
+ return null;
806
+ }
807
+ }
808
+
809
+ function completeSignIn() {
810
+ return (async () => {
712
811
  // Fidj collects the credential on its own page rather than sending itself
713
812
  // through its own door, and that sign-in created no session the provider
714
813
  // could see: the first app opened afterwards asked for the password again,
@@ -734,12 +833,11 @@ function render() {
734
833
  }
735
834
  await refresh();
736
835
  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
- });
836
+ // A new account belongs where a returning one lands: inside the app.
837
+ // Sending it to the account card instead dropped people who had just
838
+ // signed up on the shell, one click short of the app they came for.
839
+ navigate("content");
840
+ })();
743
841
  }
744
842
 
745
843
  // Everything the account screen lets a person do about what this app holds:
@@ -829,8 +927,8 @@ function wirePrivacy() {
829
927
  });
830
928
  }
831
929
 
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.
930
+
931
+
834
932
  // What this app holds about the person signed in, and what they can do
835
933
  // about it. It used to be a screen of its own called My privacy, one tab
836
934
  // away from the account it was about — so a person looking for their own
@@ -858,17 +956,6 @@ function privacyBlock() {
858
956
  <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
957
  }
860
958
 
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
959
  // ------------------------------------------------ signing in, for the provider
873
960
  //
874
961
  // When Fidj's provider needs a person to identify themselves it hands them to a
@@ -961,13 +1048,6 @@ async function loadInteraction() {
961
1048
  interaction = (await response.json()) as Interaction;
962
1049
  }
963
1050
 
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>`;
970
- }
971
1051
 
972
1052
  function interactionScreen() {
973
1053
  const details = interaction!;
@@ -1019,12 +1099,12 @@ function interactionScreen() {
1019
1099
  <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
1020
1100
  </form>`;
1021
1101
 
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>
1102
+ root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
1023
1103
  <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>
1104
+ ${highlightCells(config.highlights)}</div>
1025
1105
  <div class="signin-form"><div>${body}</div>
1026
1106
  <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>`;
1107
+ ${badgeStrip(config.badges)}</section>`;
1028
1108
 
1029
1109
  element("reveal")?.addEventListener("click", () => {
1030
1110
  const field = element<HTMLInputElement>("password");
@@ -1057,10 +1137,10 @@ function interactionScreen() {
1057
1137
  }
1058
1138
 
1059
1139
  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>
1140
+ root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
1061
1141
  <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
1142
  </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>`;
1143
+ <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>`;
1064
1144
  wireAccount(route);
1065
1145
  }
1066
1146