@ofidj/generator-fidj 3.10.1 → 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 +41 -16
- package/bin/create-fidj.cjs +2 -0
- package/generators/app/index.js +2 -0
- package/generators/app/templates/typescript/package.json +2 -1
- package/generators/app/templates/typescript/src/content.ts +197 -93
- package/generators/app/templates/typescript/src/main.ts +50 -25
- package/lib/scaffold.cjs +12 -0
- package/package.json +1 -1
- package/generators/app/templates/typescript/src/fonts.css +0 -92
- package/generators/app/templates/typescript/src/provider-window.ts +0 -177
- package/generators/app/templates/typescript/src/service-agreement.ts +0 -203
- package/generators/app/templates/typescript/src/style.css +0 -1157
- package/generators/app/templates/typescript/src/tokens.css +0 -61
- package/generators/app/templates/typescript/src/version.ts +0 -19
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, type SigninShape} from "@ofidj/entry";
|
|
2
|
+
import {acceptedAgreement, agreementScreen, bindAgreementScreen, 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 "
|
|
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
|
-
|
|
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.
|
|
@@ -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()}
|
|
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,35 @@ function render() {
|
|
|
214
239
|
}`
|
|
215
240
|
}
|
|
216
241
|
<footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
|
|
217
|
-
|
|
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);
|
|
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);
|
|
220
249
|
el("use-email")?.addEventListener("click", () => {
|
|
221
250
|
emailEntryOpen = !emailEntryOpen;
|
|
222
251
|
showEmailEntry(emailEntryOpen, true);
|
|
223
252
|
});
|
|
224
253
|
el<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
|
|
225
254
|
event.preventDefault();
|
|
255
|
+
// Answering the agreement screen: the credentials were accepted already and
|
|
256
|
+
// are not re-read from a form that no longer shows them.
|
|
257
|
+
if (pendingAgreement) {
|
|
258
|
+
const accepted = acceptedAgreement(event.currentTarget as HTMLFormElement);
|
|
259
|
+
if (!accepted) return;
|
|
260
|
+
void action(async () => {
|
|
261
|
+
if (await refusedBeforeSignIn(signInEmail, signInPassword, accepted))
|
|
262
|
+
return;
|
|
263
|
+
await load();
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
226
267
|
const email = el<HTMLInputElement>("email")?.value || "";
|
|
227
268
|
const password = el<HTMLInputElement>("password")?.value || "";
|
|
228
269
|
signInEmail = email;
|
|
229
270
|
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
271
|
// The Fidj door opens a window of its own, from inside this click: opening
|
|
243
272
|
// it after the authorization URL has been fetched is what a popup blocker
|
|
244
273
|
// refuses, because the browser no longer attributes it to the press.
|
|
@@ -247,11 +276,7 @@ function render() {
|
|
|
247
276
|
return;
|
|
248
277
|
}
|
|
249
278
|
void action(async () => {
|
|
250
|
-
|
|
251
|
-
await sdk.login(email, password, { autoSignup: false, ...acceptance });
|
|
252
|
-
} catch (reason) {
|
|
253
|
-
throw new Error(signInErrorMessage(reason));
|
|
254
|
-
}
|
|
279
|
+
if (await refusedBeforeSignIn(email, password)) return;
|
|
255
280
|
await load();
|
|
256
281
|
});
|
|
257
282
|
});
|
package/lib/scaffold.cjs
CHANGED
|
@@ -176,6 +176,18 @@ function scaffold(destination, options) {
|
|
|
176
176
|
releaseVersion = sdk.version;
|
|
177
177
|
pkg.dependencies["@ofidj/node"] = "file:" + sdkPath;
|
|
178
178
|
}
|
|
179
|
+
// The entry — the sign-in and account screens, the agreement and the design
|
|
180
|
+
// system — is a package now rather than a copy in this template, so a local
|
|
181
|
+
// build of it is pointed at the same way the SDK is.
|
|
182
|
+
if (options.entryPath) {
|
|
183
|
+
const entryPath = path.resolve(options.entryPath);
|
|
184
|
+
const entry = JSON.parse(
|
|
185
|
+
fs.readFileSync(path.join(entryPath, "package.json"), "utf8"),
|
|
186
|
+
);
|
|
187
|
+
if (entry.name !== "@ofidj/entry")
|
|
188
|
+
throw new Error("Entry path must contain a built @ofidj/entry package.");
|
|
189
|
+
pkg.dependencies["@ofidj/entry"] = "file:" + entryPath;
|
|
190
|
+
}
|
|
179
191
|
fs.writeFileSync(
|
|
180
192
|
path.join(dir, "package.json"),
|
|
181
193
|
JSON.stringify(pkg, null, 2) + "\n",
|
package/package.json
CHANGED
|
@@ -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
|
-
}
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
export function agreementMarkup() {
|
|
2
|
-
return `<div class="signin-agreement"><label class="agreement-choice"><input id="service-agreement" type="checkbox" aria-required="true" disabled><span id="agreement-label">I accept the service agreement for this app.</span></label><button type="button" id="read-agreement" disabled>Read service agreement</button><p id="agreement-status" class="fineprint" role="status">Loading service agreement…</p><button type="button" id="retry-agreement" hidden>Retry</button></div><dialog id="agreement-dialog" aria-labelledby="agreement-heading"><h2 id="agreement-heading">Service agreement</h2><p id="agreement-version"></p><p id="agreement-text"></p><button type="button" id="close-agreement">Close agreement</button></dialog>`;
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
export function acceptedAgreement(form: HTMLFormElement) {
|
|
6
|
-
const checkbox = form.querySelector<HTMLInputElement>("#service-agreement");
|
|
7
|
-
if (!checkbox?.checked || checkbox.disabled || !checkbox.dataset.version) return null;
|
|
8
|
-
return { termsAccepted: true, termsVersion: checkbox.dataset.version };
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function signInErrorMessage(error: unknown) {
|
|
12
|
-
const detail = error as {code?: number; reason?: unknown; message?: unknown};
|
|
13
|
-
const reason = typeof detail?.reason === "string" ? detail.reason : typeof detail?.message === "string" ? detail.message : "";
|
|
14
|
-
if (detail?.code === 429) return "Too many attempts. Please wait before trying again.";
|
|
15
|
-
if (reason === "unknown-user") return "We could not sign in to this account. Check the email and password.";
|
|
16
|
-
if (reason === "already exists - inconsistent request") return "An account already uses this email. Check the password or sign in instead.";
|
|
17
|
-
if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|network/i.test(reason)) return "We cannot reach Fidj right now. Please try again.";
|
|
18
|
-
return "We could not sign in to this account. Please try again.";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function bindAgreement(form: HTMLFormElement | null, title: string, endpoint: string, appId: string, checked = false) {
|
|
22
|
-
if (!form) return;
|
|
23
|
-
// An app whose only door is Fidj carries no agreement block: Fidj asks for
|
|
24
|
-
// this app's agreement on the screen that names it, and records the version.
|
|
25
|
-
const checkbox = form.querySelector<HTMLInputElement>("#service-agreement");
|
|
26
|
-
if (!checkbox) return;
|
|
27
|
-
const read = form.querySelector<HTMLButtonElement>("#read-agreement")!;
|
|
28
|
-
const dialog = form.querySelector<HTMLDialogElement>("#agreement-dialog")!;
|
|
29
|
-
const status = form.querySelector<HTMLElement>("#agreement-status")!;
|
|
30
|
-
const retry = form.querySelector<HTMLButtonElement>("#retry-agreement")!;
|
|
31
|
-
// What this agreement gates is the app's own door, and only that one. The
|
|
32
|
-
// Fidj door is about to be asked the same question on the screen that names
|
|
33
|
-
// the app, where the answer is recorded with its version — so an agreement
|
|
34
|
-
// that is still loading, or that could not be loaded at all, must not be what
|
|
35
|
-
// stands between a person and the door that does not need it.
|
|
36
|
-
const gated = form.querySelector("#email-entry") || form;
|
|
37
|
-
const submitButtons = gated.querySelectorAll<HTMLButtonElement>('button[type="submit"]');
|
|
38
|
-
const update = () => submitButtons.forEach(button => { button.disabled = checkbox.disabled; });
|
|
39
|
-
form.querySelector("#agreement-label")!.textContent = `I accept the service agreement for ${title}.`;
|
|
40
|
-
update();
|
|
41
|
-
checkbox.addEventListener("change", update);
|
|
42
|
-
read.addEventListener("click", () => dialog.showModal());
|
|
43
|
-
form.querySelector("#close-agreement")!.addEventListener("click", () => dialog.close());
|
|
44
|
-
const load = async () => {
|
|
45
|
-
retry.hidden = true;
|
|
46
|
-
status.textContent = "Loading service agreement…";
|
|
47
|
-
try {
|
|
48
|
-
const response = await fetch(`${endpoint}/apps/${encodeURIComponent(appId)}`, {signal: AbortSignal.timeout(10000)});
|
|
49
|
-
if (!response.ok) throw new Error(response.status === 404 ? "missing" : "unreachable");
|
|
50
|
-
const agreement = (await response.json()).app?.agreement;
|
|
51
|
-
if (!agreement || typeof agreement.version !== "string" || !agreement.version || typeof agreement.text !== "string" || !agreement.text) throw new Error("missing");
|
|
52
|
-
if (!form.isConnected) return;
|
|
53
|
-
checkbox.dataset.version = agreement.version;
|
|
54
|
-
checkbox.disabled = false;
|
|
55
|
-
checkbox.checked = checked;
|
|
56
|
-
read.disabled = false;
|
|
57
|
-
dialog.querySelector("#agreement-version")!.textContent = `Version ${agreement.version}`;
|
|
58
|
-
dialog.querySelector("#agreement-text")!.textContent = agreement.text;
|
|
59
|
-
status.textContent = "Required to sign in. Optional data choices stay separate.";
|
|
60
|
-
update();
|
|
61
|
-
} catch (error) {
|
|
62
|
-
if (!form.isConnected) return;
|
|
63
|
-
status.textContent = error instanceof Error && error.message === "missing"
|
|
64
|
-
? "This app has no service agreement available."
|
|
65
|
-
: "We cannot reach Fidj right now. Try again.";
|
|
66
|
-
retry.hidden = false;
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
retry.addEventListener("click", load);
|
|
70
|
-
await load();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// The entry every app that delegates to the provider renders, in one place
|
|
74
|
-
// because both app shapes render it and they had drifted apart.
|
|
75
|
-
//
|
|
76
|
-
// "Continue with Fidj" alone borrows the grammar of an optional social login —
|
|
77
|
-
// that button always sits next to an email and a password — so on an app whose
|
|
78
|
-
// accounts *are* Fidj accounts, the missing form reads as something broken. The
|
|
79
|
-
// explanation therefore comes before the button, not as reassurance after it,
|
|
80
|
-
// and nothing presupposes an account the person may not have yet.
|
|
81
|
-
const hintKey = (appId: string) => "fidj.entry." + appId;
|
|
82
|
-
|
|
83
|
-
export function signInHint(appId: string) {
|
|
84
|
-
try {
|
|
85
|
-
return localStorage.getItem(hintKey(appId)) || "";
|
|
86
|
-
} catch {
|
|
87
|
-
return "";
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Remembered on this app's own origin, about this app's own member: no
|
|
92
|
-
// cross-site question is asked, and none is answered. Signing out forgets, so a
|
|
93
|
-
// shared browser does not show the next person an address.
|
|
94
|
-
export function rememberSignIn(appId: string, label: string) {
|
|
95
|
-
try {
|
|
96
|
-
if (label) localStorage.setItem(hintKey(appId), label);
|
|
97
|
-
} catch {}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function forgetSignIn(appId: string) {
|
|
101
|
-
try {
|
|
102
|
-
localStorage.removeItem(hintKey(appId));
|
|
103
|
-
} catch {}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Both doors, but not side by side. An app that delegates to Fidj still has
|
|
107
|
-
// people who would rather type an address and a password than be sent
|
|
108
|
-
// somewhere, so the credential form stays — folded away under a line of text
|
|
109
|
-
// rather than standing in front of the door most people should take. Offering
|
|
110
|
-
// the two as equals asked every arrival to choose between them, and the one
|
|
111
|
-
// that looked like an ordinary login won by looking ordinary.
|
|
112
|
-
//
|
|
113
|
-
// The trade-off is stated where it is made: on the credential path the app's
|
|
114
|
-
// own page handles the Fidj password, so it is the app — not only Fidj — that
|
|
115
|
-
// must be trusted with it. The Fidj entry above it never is, which is why it is
|
|
116
|
-
// the one wearing the accent.
|
|
117
|
-
export type SigninShape = "button" | "inline" | "both";
|
|
118
|
-
|
|
119
|
-
export function providerEntry(
|
|
120
|
-
title: string,
|
|
121
|
-
appId: string,
|
|
122
|
-
credentials: string,
|
|
123
|
-
isFidjItself = false,
|
|
124
|
-
shape: SigninShape = "button",
|
|
125
|
-
) {
|
|
126
|
-
const escapeText = (value: unknown) =>
|
|
127
|
-
String(value ?? "").replace(
|
|
128
|
-
/[&<>"']/g,
|
|
129
|
-
(char) =>
|
|
130
|
-
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
131
|
-
char
|
|
132
|
-
]!,
|
|
133
|
-
);
|
|
134
|
-
const hint = signInHint(appId);
|
|
135
|
-
const both = shape === "both" && Boolean(credentials);
|
|
136
|
-
// The app's own form and nothing else. Nothing is folded away because there is
|
|
137
|
-
// no second way in to fold it under, and the trade-off is stated plainly:
|
|
138
|
-
// on this path it is this site that holds the password.
|
|
139
|
-
if (shape === "inline")
|
|
140
|
-
return (
|
|
141
|
-
`<p class="signin-lead">${
|
|
142
|
-
isFidjItself
|
|
143
|
-
? "One account across every app that uses Fidj, and a separate set of choices for each one."
|
|
144
|
-
: `${escapeText(title)} accounts are Fidj accounts. Sign in below — ${escapeText(title)} handles your password itself on this page.`
|
|
145
|
-
}</p>` +
|
|
146
|
-
agreementMarkup() +
|
|
147
|
-
credentials
|
|
148
|
-
);
|
|
149
|
-
const lead = hint
|
|
150
|
-
? `<p class="signin-lead">You signed in here with Fidj before. ${escapeText(title)} accounts are Fidj accounts — continue as yourself, or use another.</p>`
|
|
151
|
-
: both
|
|
152
|
-
? `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. Fidj asks in a window of its own, so this site never sees your password.</p>`
|
|
153
|
-
: isFidjItself
|
|
154
|
-
? `<p class="signin-lead">One account across every app that uses Fidj, and a separate set of choices for each one. Fidj asks in a window of its own; this page stays where it is.</p>`
|
|
155
|
-
: `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. You will sign in — or create yours — in a Fidj window, so this site never sees your password.</p>`;
|
|
156
|
-
// The accent is the Fidj mark, and this is the one control on the screen that
|
|
157
|
-
// is Fidj's rather than the app's. It is also the door the app would rather
|
|
158
|
-
// people took, and those two happen to want the same thing.
|
|
159
|
-
const fidj = hint
|
|
160
|
-
? `<button class="primary fidj-entry" type="submit" name="entry" value="fidj">Continue as ${escapeText(hint)}</button><button type="button" id="forget-hint" class="quiet">Use a different account</button>`
|
|
161
|
-
: `<button class="primary fidj-entry" type="submit" name="entry" value="fidj">${isFidjItself ? "Sign in" : "Sign in with Fidj"}</button>`;
|
|
162
|
-
// Fidj is the only door: it collects the agreement itself, a moment later, on
|
|
163
|
-
// the screen that names the app — and records it with its version. Collecting
|
|
164
|
-
// it here first recorded nothing and asked the same question twice.
|
|
165
|
-
if (shape !== "both" || !both) return lead + fidj;
|
|
166
|
-
// Both doors. The app's own one is a second thought for the person who wants
|
|
167
|
-
// it, which is what a disclosure is for: it costs one click and takes nothing
|
|
168
|
-
// away, where a second button of equal weight cost everybody a decision.
|
|
169
|
-
return (
|
|
170
|
-
lead +
|
|
171
|
-
fidj +
|
|
172
|
-
`<div class="signin-alternate"><button type="button" id="use-email" class="signin-toggle" aria-expanded="false" aria-controls="email-entry"><span class="signin-toggle-label">Inline form<span class="caret" aria-hidden="true"></span></span></button>
|
|
173
|
-
<div id="email-entry" hidden>${agreementMarkup()}${credentials}</div></div>`
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Opening the app's own form takes the space the Fidj door was using.
|
|
178
|
-
//
|
|
179
|
-
// The two are alternatives, not a list, and watching one fold away as the other
|
|
180
|
-
// arrives is what says so — where a form appearing underneath a button that is
|
|
181
|
-
// still there reads as "and also". The toggle stays put through both states, so
|
|
182
|
-
// it ends up labelling whichever one is on screen: a caret pointing down at a
|
|
183
|
-
// form that is not here yet, and up at the one it would put away.
|
|
184
|
-
//
|
|
185
|
-
// Shared, because both app shapes render the same entry.
|
|
186
|
-
export function showEmailEntry(open: boolean, focus = false) {
|
|
187
|
-
const fields = document.getElementById("email-entry");
|
|
188
|
-
const toggle = document.getElementById("use-email");
|
|
189
|
-
if (!fields || !toggle) return;
|
|
190
|
-
toggle.setAttribute("aria-expanded", String(open));
|
|
191
|
-
fields.hidden = !open;
|
|
192
|
-
const door = document.querySelector<HTMLButtonElement>(".fidj-entry");
|
|
193
|
-
if (door) {
|
|
194
|
-
door.classList.toggle("is-folded", open);
|
|
195
|
-
// Out of the tab order and out of the accessibility tree the moment it
|
|
196
|
-
// starts leaving: something mid-fold is not something to press, and an
|
|
197
|
-
// animation is not what decides that.
|
|
198
|
-
if (open) door.setAttribute("aria-hidden", "true");
|
|
199
|
-
else door.removeAttribute("aria-hidden");
|
|
200
|
-
door.tabIndex = open ? -1 : 0;
|
|
201
|
-
}
|
|
202
|
-
if (open && focus) document.getElementById("email")?.focus();
|
|
203
|
-
}
|