@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,8 +1,8 @@
1
- import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn, showEmailEntry, type SigninShape} from "./service-agreement";
2
- import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "./provider-window";
1
+ import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, type SigninShape} from "@ofidj/entry";
2
+ import {acceptedAgreement, agreementScreen, bindAgreementScreen, bindPasswordReveal, providerEntry, showEmailEntry, showVersionBadge} from "@ofidj/entry/dom";
3
+ import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "@ofidj/entry/window";
3
4
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
4
- import "./style.css";
5
- import { showVersionBadge } from "./version";
5
+ import "@ofidj/entry/style.css";
6
6
 
7
7
  type Session = { username: string; roles: string[] };
8
8
  type Note = { id: string; title: string; body: string; createdAt: string };
@@ -39,7 +39,9 @@ let busy = false;
39
39
  let leaving = false;
40
40
  let signInEmail = "";
41
41
  let signInPassword = "";
42
- let signInAgreementAccepted = false;
42
+ // The agreement this app is owed, once the API has said so. There is no
43
+ // account creation here, so there is no verification wait to hold either.
44
+ let pendingAgreement: {version: string; text: string} | null = null;
43
45
  // Whether the person asked for the app's own form: the entry is rebuilt on every
44
46
  // render, and a refused sign-in is a render, so it has to be remembered or the
45
47
  // form folds away under the complaint about it.
@@ -77,7 +79,7 @@ async function api(path: string, method = "GET", data?: unknown) {
77
79
  // The credential fields, in one place because the entry shows them beside the
78
80
  // Fidj door rather than instead of it.
79
81
  function credentialFields() {
80
- return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username"><label for="password">Password</label><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password"><button class="primary" type="submit" name="entry" value="credentials">Continue</button>`;
82
+ return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username"><label for="password">Password</label><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password"><button type="button" aria-controls="password">Show</button></div><button class="primary" type="submit" name="entry" value="credentials">Continue</button>`;
81
83
  }
82
84
 
83
85
  async function load() {
@@ -183,6 +185,29 @@ async function action(task: () => Promise<void>) {
183
185
  render();
184
186
  }
185
187
  }
188
+ // The API decides whether this is a session or the agreement it has not
189
+ // recorded for this app. Returns true when it answered with a screen.
190
+ async function refusedBeforeSignIn(
191
+ email: string,
192
+ password: string,
193
+ acceptance?: {termsAccepted: boolean; termsVersion: string},
194
+ ): Promise<boolean> {
195
+ try {
196
+ await sdk.login(email, password, {autoSignup: false, ...acceptance});
197
+ pendingAgreement = null;
198
+ return false;
199
+ } catch (reason) {
200
+ if (agreementRequired(reason)) {
201
+ pendingAgreement = agreementFromRefusal(reason);
202
+ if (!pendingAgreement) {
203
+ throw new Error("We cannot reach Fidj right now. Please try again.");
204
+ }
205
+ return true;
206
+ }
207
+ throw new Error(signInErrorMessage(reason));
208
+ }
209
+ }
210
+
186
211
  function render() {
187
212
  document.title = settings.title + " · Fidj";
188
213
  const canWrite = session?.roles.some((role) =>
@@ -192,7 +217,7 @@ function render() {
192
217
  <main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
193
218
  ${
194
219
  !session
195
- ? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2>${oidc ? providerEntry(settings.title, settings.appId, settings.signin === "button" ? "" : credentialFields(), false, settings.signin || "button") : `<p>Sign in with your Fidj account.</p>${credentialFields()}${agreementMarkup()}<button class="primary" type="submit">Continue</button>${settings.localDemo ? `<div class="demo"><strong>Try the local example</strong><p>Alex owns the app. Maya and Sam start with the Free role.</p><button type="button" data-demo="alex">Alex · owner</button><button type="button" data-demo="maya">Maya · member</button><button type="button" data-demo="sam">Sam · member</button></div>` : ""}`}</form></section>`
220
+ ? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2>${oidc ? providerEntry(settings.title, settings.appId, settings.signin === "button" ? "" : credentialFields(), false, settings.signin || "button") : `<p>Sign in with your Fidj account.</p>${credentialFields()}<button class="primary" type="submit">Sign in</button>${settings.localDemo ? `<div class="demo"><strong>Try the local example</strong><p>Alex owns the app. Maya and Sam start with the Free role.</p><button type="button" data-demo="alex">Alex · owner</button><button type="button" data-demo="maya">Maya · member</button><button type="button" data-demo="sam">Sam · member</button></div>` : ""}`}</form></section>`
196
221
  : `
197
222
  <div class="page-heading"><div><p class="eyebrow">YOUR WORKSPACE</p><h1>A place to think.</h1><p>${escape(session.username)} <span class="roles">${session.roles.map(escape).join(" · ") || "No assigned roles"}</span></p></div><button id="signout">Sign out</button></div>
198
223
  <nav><button id="workspace-tab" class="${view === "workspace" ? "selected" : ""}">My notes</button><button id="privacy-tab" class="${view === "privacy" ? "selected" : ""}">My privacy</button><button id="refresh">Refresh access</button></nav>
@@ -214,31 +239,36 @@ function render() {
214
239
  }`
215
240
  }
216
241
  <footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
217
- void bindAgreement(el<HTMLFormElement>("signin"), settings.title, settings.apiEndpoint, settings.appId, signInAgreementAccepted);
242
+ // The agreement takes the form's place once the API says this app is owed one.
243
+ if (pendingAgreement && el("signin")) {
244
+ el("signin")!.innerHTML = agreementScreen(settings.title, pendingAgreement, `${settings.apiEndpoint}/apps/${encodeURIComponent(settings.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
245
+ bindAgreementScreen(el<HTMLFormElement>("signin"));
246
+ }
218
247
  // The app's own form, folded away under the Fidj door rather than beside it.
219
- if (emailEntryOpen) showEmailEntry(true);
248
+ if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
249
+ if (!pendingAgreement && el("signin")) bindPasswordReveal(el("signin")!);
220
250
  el("use-email")?.addEventListener("click", () => {
221
251
  emailEntryOpen = !emailEntryOpen;
222
252
  showEmailEntry(emailEntryOpen, true);
223
253
  });
224
254
  el<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
225
255
  event.preventDefault();
256
+ // Answering the agreement screen: the credentials were accepted already and
257
+ // are not re-read from a form that no longer shows them.
258
+ if (pendingAgreement) {
259
+ const accepted = acceptedAgreement(event.currentTarget as HTMLFormElement);
260
+ if (!accepted) return;
261
+ void action(async () => {
262
+ if (await refusedBeforeSignIn(signInEmail, signInPassword, accepted))
263
+ return;
264
+ await load();
265
+ });
266
+ return;
267
+ }
226
268
  const email = el<HTMLInputElement>("email")?.value || "";
227
269
  const password = el<HTMLInputElement>("password")?.value || "";
228
270
  signInEmail = email;
229
271
  signInPassword = password;
230
- signInAgreementAccepted =
231
- el<HTMLInputElement>("service-agreement")?.checked === true;
232
- const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
233
- // Only a credential entry is gated here. When Fidj is the door, it asks for
234
- // this app's agreement a moment later, on the screen that names the app,
235
- // and records the acceptance with its version — so asking first cost a
236
- // second click and kept nothing.
237
- if (!oidc && !acceptance) {
238
- error = "Please accept the service agreement before continuing.";
239
- render();
240
- return;
241
- }
242
272
  // The Fidj door opens a window of its own, from inside this click: opening
243
273
  // it after the authorization URL has been fetched is what a popup blocker
244
274
  // refuses, because the browser no longer attributes it to the press.
@@ -247,11 +277,7 @@ function render() {
247
277
  return;
248
278
  }
249
279
  void action(async () => {
250
- try {
251
- await sdk.login(email, password, { autoSignup: false, ...acceptance });
252
- } catch (reason) {
253
- throw new Error(signInErrorMessage(reason));
254
- }
280
+ if (await refusedBeforeSignIn(email, password)) return;
255
281
  await load();
256
282
  });
257
283
  });
package/lib/scaffold.cjs CHANGED
@@ -81,7 +81,8 @@ function scaffold(destination, options) {
81
81
  );
82
82
  if (
83
83
  api.protocol === "http:" &&
84
- !["localhost", "127.0.0.1", "[::1]"].includes(api.hostname)
84
+ !["localhost", "127.0.0.1", "[::1]"].includes(api.hostname) &&
85
+ !api.hostname.endsWith(".localhost")
85
86
  )
86
87
  throw new Error("Use HTTPS for a remote API.");
87
88
  if (options.oidcIssuer) {
@@ -176,6 +177,18 @@ function scaffold(destination, options) {
176
177
  releaseVersion = sdk.version;
177
178
  pkg.dependencies["@ofidj/node"] = "file:" + sdkPath;
178
179
  }
180
+ // The entry — the sign-in and account screens, the agreement and the design
181
+ // system — is a package now rather than a copy in this template, so a local
182
+ // build of it is pointed at the same way the SDK is.
183
+ if (options.entryPath) {
184
+ const entryPath = path.resolve(options.entryPath);
185
+ const entry = JSON.parse(
186
+ fs.readFileSync(path.join(entryPath, "package.json"), "utf8"),
187
+ );
188
+ if (entry.name !== "@ofidj/entry")
189
+ throw new Error("Entry path must contain a built @ofidj/entry package.");
190
+ pkg.dependencies["@ofidj/entry"] = "file:" + entryPath;
191
+ }
179
192
  fs.writeFileSync(
180
193
  path.join(dir, "package.json"),
181
194
  JSON.stringify(pkg, null, 2) + "\n",
@@ -186,7 +199,7 @@ function scaffold(destination, options) {
186
199
  const issuerSetting = options.oidcIssuer ? `FIDJ_OIDC_ISSUER=${options.oidcIssuer}\n` : "";
187
200
  const settings = `FIDJ_APP_ID=${options.appId}\nFIDJ_API_ENDPOINT=${api.href.replace(/\/$/, "")}\n${issuerSetting}FIDJ_DASHBOARD_URL=https://fidj.ovh\nAPP_TITLE=${title}\nAPP_VERSION=${releaseVersion}\nFIDJ_SIGNIN=${signin}\nPORT=8200\nHOST=127.0.0.1\nLOCAL_DEMO=false\nFIDJ_DATA_DIR=../.fidj-data/${name}\nFIDJ_PRIVACY_ADAPTER_KEY=\n`;
188
201
  const localApi = options.local
189
- ? "http://localhost:3201/v3"
202
+ ? process.env.FIDJ_LOCAL_API_ENDPOINT || "http://localhost:3201/v3"
190
203
  : api.href.replace(/\/$/, "");
191
204
  const configuration = {
192
205
  appId: options.appId,
@@ -197,7 +210,9 @@ function scaffold(destination, options) {
197
210
  ? new URL("/oidc", localApi).href
198
211
  : null,
199
212
  apiEndpoint: localApi,
200
- dashboardUrl: options.local ? "http://localhost:4200" : dashboardFor(api),
213
+ dashboardUrl: options.local
214
+ ? process.env.FIDJ_LOCAL_DASHBOARD_URL || "http://localhost:4200"
215
+ : dashboardFor(api),
201
216
  title,
202
217
  releaseVersion,
203
218
  localDemo: Boolean(options.local),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "3.10.2",
3
+ "version": "3.13.0",
4
4
  "description": "Generate a TypeScript app with Fidj sign-in, live server-side roles and per-app privacy.",
5
5
  "homepage": "https://fidj.ovh",
6
6
  "bugs": {
@@ -1,92 +0,0 @@
1
- /* The design system's typefaces, self-hosted.
2
- *
3
- * A generated site has to stay statically hostable, and an app that sells
4
- * privacy should not hand a third party a request log on every sign-in. Latin
5
- * and latin-ext only; other scripts fall back to the stack in tokens.css.
6
- *
7
- * The url()s are runtime paths relative to the built main.css, not bundle
8
- * inputs — the files ride along in public/ and esbuild leaves them external.
9
- * A consumer that serves the same tokens from somewhere else (fidj-app mounts
10
- * its console under /module/) writes its own @font-face block instead.
11
- */
12
-
13
- @font-face {
14
- font-family: "IBM Plex Sans";
15
- font-style: normal;
16
- font-weight: 400 600;
17
- font-display: swap;
18
- src: url("fonts/IBMPlexSans-400_600-latin.woff2") format("woff2");
19
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
20
- U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
21
- U+2212, U+2215, U+FEFF, U+FFFD;
22
- }
23
- @font-face {
24
- font-family: "IBM Plex Sans";
25
- font-style: normal;
26
- font-weight: 400 600;
27
- font-display: swap;
28
- src: url("fonts/IBMPlexSans-400_600-latin-ext.woff2") format("woff2");
29
- unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
30
- U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
31
- U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
32
- }
33
- @font-face {
34
- font-family: "IBM Plex Mono";
35
- font-style: normal;
36
- font-weight: 400;
37
- font-display: swap;
38
- src: url("fonts/IBMPlexMono-400-latin.woff2") format("woff2");
39
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
40
- U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
41
- U+2212, U+2215, U+FEFF, U+FFFD;
42
- }
43
- @font-face {
44
- font-family: "IBM Plex Mono";
45
- font-style: normal;
46
- font-weight: 400;
47
- font-display: swap;
48
- src: url("fonts/IBMPlexMono-400-latin-ext.woff2") format("woff2");
49
- unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
50
- U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
51
- U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
52
- }
53
- @font-face {
54
- font-family: "IBM Plex Mono";
55
- font-style: normal;
56
- font-weight: 500;
57
- font-display: swap;
58
- src: url("fonts/IBMPlexMono-500-latin.woff2") format("woff2");
59
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
60
- U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
61
- U+2212, U+2215, U+FEFF, U+FFFD;
62
- }
63
- @font-face {
64
- font-family: "IBM Plex Mono";
65
- font-style: normal;
66
- font-weight: 500;
67
- font-display: swap;
68
- src: url("fonts/IBMPlexMono-500-latin-ext.woff2") format("woff2");
69
- unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
70
- U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
71
- U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
72
- }
73
- @font-face {
74
- font-family: "Instrument Serif";
75
- font-style: normal;
76
- font-weight: 400;
77
- font-display: swap;
78
- src: url("fonts/InstrumentSerif-400-latin.woff2") format("woff2");
79
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
80
- U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
81
- U+2212, U+2215, U+FEFF, U+FFFD;
82
- }
83
- @font-face {
84
- font-family: "Instrument Serif";
85
- font-style: normal;
86
- font-weight: 400;
87
- font-display: swap;
88
- src: url("fonts/InstrumentSerif-400-latin-ext.woff2") format("woff2");
89
- unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
90
- U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
91
- U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
92
- }
@@ -1,177 +0,0 @@
1
- // Signing in happens in a window of its own.
2
- //
3
- // Fidj's screens are served from another origin — Fidj's front end, or the API
4
- // itself, depending on the deployment — and that origin refuses to be framed,
5
- // so no dialog drawn inside this page can hold them. A window can, and it keeps
6
- // the page the person was already on: they come back to what they were reading,
7
- // not to a shell that reloaded underneath them and forgot where they were.
8
- //
9
- // The window is opened empty and synchronously, before anything is awaited. A
10
- // window opened after an await is a window the browser no longer connects to
11
- // the click that asked for it, and every popup blocker refuses it — so the
12
- // authorization URL, which takes a discovery round-trip to build, is fetched
13
- // afterwards and navigated into a window that already exists.
14
-
15
- // One window per page, not one per browser.
16
- //
17
- // Naming it is what lets a second press reuse the window instead of stacking
18
- // another one on top, and that much is wanted. Sharing the name across tabs is
19
- // not: the answer comes back to whichever page opened the window, so a second
20
- // tab reusing the first tab's window would hand that tab's sign-in to a page
21
- // that never asked, and leave the one that did waiting forever. Stable for this
22
- // document, unique to it.
23
- const WINDOW_NAME = "fidj-signin-" + Math.random().toString(36).slice(2, 10);
24
- const CALLBACK = "fidj:oidc-callback";
25
-
26
- // Comfortable rather than minimal: the provider asks for a password, states who
27
- // is asking and where the person is going back to, and a cramped window turns
28
- // that into scrolling. Clamped to the screen so a small display still gets a
29
- // window it can show whole, and centred on the display the opener is on.
30
- function features() {
31
- const width = Math.min(560, Math.max(320, window.screen.availWidth - 80));
32
- const height = Math.min(760, Math.max(480, window.screen.availHeight - 80));
33
- const left = Math.round(
34
- (window.screenX || 0) + Math.max(0, (window.outerWidth - width) / 2),
35
- );
36
- const top = Math.round(
37
- (window.screenY || 0) + Math.max(0, (window.outerHeight - height) / 3),
38
- );
39
- return `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
40
- }
41
-
42
- export type ProviderWindow = {
43
- // Send the opened window to the provider. Separate from opening it because
44
- // the URL is not known yet at the moment the click has to be honoured.
45
- show(url: string): void;
46
- // Still open, and still the one being waited for.
47
- isOpen(): boolean;
48
- // Bring it back in front. A window can be behind the page that opened it, on
49
- // another desktop, or minimised, and from the page that looks exactly like
50
- // nothing having happened — so the page needs to be able to fetch it.
51
- focus(): void;
52
- // Close it: the provider could not be reached and there is nothing for the
53
- // window to show, or the person asked to stop.
54
- giveUp(): void;
55
- // The callback the provider handed to that window, or null when the person
56
- // closed it without finishing. Closing is a decision, not a failure: it
57
- // resolves so the entry simply reappears, with nothing said.
58
- answer(): Promise<URL | null>;
59
- };
60
-
61
- export function openProviderWindow(): ProviderWindow | null {
62
- let opened: Window | null = null;
63
- try {
64
- opened = window.open("", WINDOW_NAME, features());
65
- } catch {
66
- opened = null;
67
- }
68
- // Blocked, or a browser that refuses named windows. The caller falls back to
69
- // leaving this page, which is the flow this one replaced.
70
- if (!opened) return null;
71
- try {
72
- opened.document.write(
73
- '<!doctype html><meta charset="utf-8"><title>Signing in with Fidj</title><body style="margin:0;font:15px/1.5 system-ui;color:#4a4540;display:grid;place-items:center;height:100vh">Opening Fidj…</body>',
74
- );
75
- opened.document.close();
76
- } catch {}
77
- return {
78
- show(url: string) {
79
- // `replace`, so the blank placeholder is not a history entry the person
80
- // can press Back into.
81
- try {
82
- opened!.location.replace(url);
83
- } catch {
84
- opened!.location.href = url;
85
- }
86
- },
87
- isOpen() {
88
- try {
89
- return !opened!.closed;
90
- } catch {
91
- return false;
92
- }
93
- },
94
- focus() {
95
- try {
96
- opened!.focus();
97
- } catch {}
98
- },
99
- giveUp() {
100
- try {
101
- opened!.close();
102
- } catch {}
103
- },
104
- answer() {
105
- return new Promise<URL | null>((resolve) => {
106
- let done = false;
107
- const finish = (result: URL | null) => {
108
- if (done) return;
109
- done = true;
110
- window.removeEventListener("message", onMessage);
111
- window.clearInterval(watch);
112
- try {
113
- opened!.close();
114
- } catch {}
115
- resolve(result);
116
- };
117
- const onMessage = (event: MessageEvent) => {
118
- // Only this origin, and only the window this entry opened: the
119
- // callback carries a single-use authorization code, and anything else
120
- // posting into this page is not part of this conversation.
121
- if (event.origin !== window.location.origin) return;
122
- if (event.source !== opened) return;
123
- const data = event.data as { fidj?: string; href?: string } | null;
124
- if (!data || data.fidj !== CALLBACK || typeof data.href !== "string")
125
- return;
126
- let callback: URL;
127
- try {
128
- callback = new URL(data.href);
129
- } catch {
130
- return;
131
- }
132
- if (callback.origin !== window.location.origin) return;
133
- finish(callback);
134
- };
135
- window.addEventListener("message", onMessage);
136
- // A window closed by hand answers nothing, and nothing is what this
137
- // page would wait for forever otherwise. `closed` is the only signal a
138
- // cross-origin window gives, so it is polled.
139
- const watch = window.setInterval(() => {
140
- if (opened!.closed) finish(null);
141
- }, 400);
142
- });
143
- },
144
- };
145
- }
146
-
147
- // The other half, running in that window: the provider has just answered into
148
- // it, and its only remaining job is to hand the answer back and get out of the
149
- // way. It never completes the sign-in itself — the transaction that has to
150
- // match the answer was written by the page that opened it, and session storage
151
- // is copied into a new window, not shared with it.
152
- //
153
- // Answering `true` means the hand-off was made and this window asked to close;
154
- // the caller shows one line and, if the browser refuses to close a window it
155
- // did not see scripted open, finishes the sign-in here after all.
156
- export function relayProviderAnswer(): boolean {
157
- let opener: Window | null = null;
158
- try {
159
- opener = window.opener as Window | null;
160
- } catch {
161
- return false;
162
- }
163
- if (!opener || opener === window || opener.closed) return false;
164
- if (!new URL(window.location.href).searchParams.has("state")) return false;
165
- try {
166
- opener.postMessage(
167
- { fidj: CALLBACK, href: window.location.href },
168
- window.location.origin,
169
- );
170
- } catch {
171
- return false;
172
- }
173
- try {
174
- window.close();
175
- } catch {}
176
- return true;
177
- }