@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
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
package/bin/create-fidj.cjs
CHANGED
|
@@ -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,
|
package/generators/app/index.js
CHANGED
|
@@ -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,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 "
|
|
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
|
-
|
|
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
|
-
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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, {
|
|
@@ -325,10 +306,52 @@ const RECOGNITION_ASKED = "fidj.oidc.recognition-asked";
|
|
|
325
306
|
// for. Asking there sent an anonymous visitor to the provider and back, and what
|
|
326
307
|
// they had asked to read never rendered.
|
|
327
308
|
const PUBLIC_ROUTE = "pub";
|
|
309
|
+
// Whether the question is still outstanding. Drawing the credential form and
|
|
310
|
+
// then navigating to the provider loses whatever somebody had begun typing, so
|
|
311
|
+
// the entry waits instead — the answer takes one redirect, not a wait worth a
|
|
312
|
+
// spinner's worth of ceremony, and it says what it is doing.
|
|
313
|
+
let recognising = false;
|
|
314
|
+
function mightBeRecognised() {
|
|
315
|
+
if (!oidc || !isFidjItself) return false;
|
|
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;
|
|
330
|
+
if (oidc.signedOutHere()) return false;
|
|
331
|
+
try {
|
|
332
|
+
return sessionStorage.getItem(RECOGNITION_ASKED) !== "true";
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
328
338
|
async function askWhetherFidjKnowsThisBrowser() {
|
|
329
339
|
if (!oidc || !isFidjItself || sdk.isLoggedIn() || oidc.signedOutHere())
|
|
330
340
|
return false;
|
|
331
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;
|
|
332
355
|
// Once per document: the answer comes back as a redirect to this same page,
|
|
333
356
|
// so without this a refusal would ask again, and again.
|
|
334
357
|
try {
|
|
@@ -411,11 +434,6 @@ function navigate(route: string) {
|
|
|
411
434
|
if (route !== currentRoute()) window.history.pushState(null, "", "#/" + route);
|
|
412
435
|
if (!busy) render();
|
|
413
436
|
}
|
|
414
|
-
// The credential fields, in one place because the entry now shows them beside
|
|
415
|
-
// the Fidj door rather than instead of it.
|
|
416
|
-
function credentialFields() {
|
|
417
|
-
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>`;
|
|
418
|
-
}
|
|
419
437
|
|
|
420
438
|
function moduleRoute() {
|
|
421
439
|
const route = window.location.hash.slice(2).split("?")[0];
|
|
@@ -565,7 +583,7 @@ function render() {
|
|
|
565
583
|
// The two ways out, last and together: back to the app, or out of this
|
|
566
584
|
// session. Leaving the app itself is a different decision and stays where
|
|
567
585
|
// the things it erases are listed.
|
|
568
|
-
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>`;
|
|
569
587
|
renderNav("account");
|
|
570
588
|
wireAccount("account");
|
|
571
589
|
wireSignOut();
|
|
@@ -582,14 +600,18 @@ function render() {
|
|
|
582
600
|
renderNav("content");
|
|
583
601
|
return;
|
|
584
602
|
}
|
|
603
|
+
if (recognising) {
|
|
604
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-form"><p role="status">Checking whether you are already signed in to Fidj…</p></div></section>`;
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
585
607
|
// Only the entry reaches here now: the privacy screen and the account
|
|
586
608
|
// screen became one, and that one is drawn above.
|
|
587
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}"
|
|
609
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
|
|
588
610
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
589
|
-
${highlights
|
|
590
|
-
<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
|
|
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>` : ""}
|
|
591
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>
|
|
592
|
-
${badges
|
|
614
|
+
${badgeStrip(config.badges)}</div></section>`;
|
|
593
615
|
wireNav();
|
|
594
616
|
element("reveal")?.addEventListener("click", () => {
|
|
595
617
|
const field = element<HTMLInputElement>("password");
|
|
@@ -615,14 +637,23 @@ function render() {
|
|
|
615
637
|
element("signin")!.innerHTML = providerEntry(
|
|
616
638
|
config.title,
|
|
617
639
|
config.appId,
|
|
618
|
-
config.signin === "button"
|
|
640
|
+
config.signin === "button"
|
|
641
|
+
? ""
|
|
642
|
+
: credentialFields({email: signInEmail, password: signInPassword}),
|
|
619
643
|
isFidjItself,
|
|
620
644
|
config.signin as SigninShape,
|
|
621
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
|
+
}
|
|
622
653
|
// The app's own form, for whoever came to type a password. Folded away rather
|
|
623
654
|
// than removed: the door above it is the one to take, and the person who
|
|
624
655
|
// wants this one is one click from it.
|
|
625
|
-
if (emailEntryOpen) showEmailEntry(true);
|
|
656
|
+
if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
|
|
626
657
|
element("use-email")?.addEventListener("click", () => {
|
|
627
658
|
emailEntryOpen = !emailEntryOpen;
|
|
628
659
|
showEmailEntry(emailEntryOpen, true);
|
|
@@ -642,31 +673,61 @@ function render() {
|
|
|
642
673
|
}
|
|
643
674
|
render();
|
|
644
675
|
});
|
|
645
|
-
|
|
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
|
+
);
|
|
646
703
|
element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
|
|
647
704
|
event.preventDefault();
|
|
648
705
|
const email = element<HTMLInputElement>("email")?.value || "";
|
|
649
706
|
const password = element<HTMLInputElement>("password")?.value || "";
|
|
650
|
-
const
|
|
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
|
+
}
|
|
651
725
|
signInEmail = email;
|
|
652
726
|
signInPassword = password;
|
|
653
|
-
signInAgreementAccepted = agreement?.checked === true;
|
|
654
|
-
const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
|
|
655
|
-
const submitter = event.submitter as HTMLButtonElement | null;
|
|
656
727
|
const signup = submitter?.name === "signup";
|
|
657
728
|
// Which door was used. The Fidj one leaves for the provider; the credential
|
|
658
729
|
// one signs in here, which is why it is the app's own form and not Fidj's.
|
|
659
730
|
const throughFidj = submitter?.name === "entry" && submitter.value === "fidj";
|
|
660
|
-
// Only the credential door is gated here. The Fidj one is about to be asked
|
|
661
|
-
// the same question on the screen that names this app, where the answer is
|
|
662
|
-
// recorded with its version — so asking first cost a second click and kept
|
|
663
|
-
// nothing.
|
|
664
|
-
if (!throughFidj && !acceptance) {
|
|
665
|
-
failed = true;
|
|
666
|
-
message = "Please accept the service agreement before continuing.";
|
|
667
|
-
render();
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
731
|
if (oidc && throughFidj) {
|
|
671
732
|
// "Continue as <them>" promises to carry on as that person, and handed
|
|
672
733
|
// them over to an empty email field — so the promise cost a second
|
|
@@ -684,11 +745,69 @@ function render() {
|
|
|
684
745
|
if (oidc && (!email || !password)) {
|
|
685
746
|
throw new Error("Enter your email and password, or sign in with Fidj.");
|
|
686
747
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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.");
|
|
691
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 () => {
|
|
692
811
|
// Fidj collects the credential on its own page rather than sending itself
|
|
693
812
|
// through its own door, and that sign-in created no session the provider
|
|
694
813
|
// could see: the first app opened afterwards asked for the password again,
|
|
@@ -714,12 +833,11 @@ function render() {
|
|
|
714
833
|
}
|
|
715
834
|
await refresh();
|
|
716
835
|
anonymous = false;
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
});
|
|
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
|
+
})();
|
|
723
841
|
}
|
|
724
842
|
|
|
725
843
|
// Everything the account screen lets a person do about what this app holds:
|
|
@@ -809,8 +927,8 @@ function wirePrivacy() {
|
|
|
809
927
|
});
|
|
810
928
|
}
|
|
811
929
|
|
|
812
|
-
|
|
813
|
-
|
|
930
|
+
|
|
931
|
+
|
|
814
932
|
// What this app holds about the person signed in, and what they can do
|
|
815
933
|
// about it. It used to be a screen of its own called My privacy, one tab
|
|
816
934
|
// away from the account it was about — so a person looking for their own
|
|
@@ -838,17 +956,6 @@ function privacyBlock() {
|
|
|
838
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>`;
|
|
839
957
|
}
|
|
840
958
|
|
|
841
|
-
function accountForm(route: string) {
|
|
842
|
-
return route === "forgot"
|
|
843
|
-
? `<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>`
|
|
844
|
-
: route === "reset"
|
|
845
|
-
? `<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>'}`
|
|
846
|
-
: route === "verify"
|
|
847
|
-
? `<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>"}`
|
|
848
|
-
: `<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>`;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
|
|
852
959
|
// ------------------------------------------------ signing in, for the provider
|
|
853
960
|
//
|
|
854
961
|
// When Fidj's provider needs a person to identify themselves it hands them to a
|
|
@@ -941,13 +1048,6 @@ async function loadInteraction() {
|
|
|
941
1048
|
interaction = (await response.json()) as Interaction;
|
|
942
1049
|
}
|
|
943
1050
|
|
|
944
|
-
// A window that opened on its own, over the page somebody was on, owes them the
|
|
945
|
-
// way out before it asks for anything. Naming the app they came from is also the
|
|
946
|
-
// only thing on this screen that they can check against what they were doing a
|
|
947
|
-
// second ago — which is exactly what a page asking for a password should offer.
|
|
948
|
-
function returnNotice(asking: string) {
|
|
949
|
-
return `<p class="signin-return" role="note">When you are done, this window closes and takes you back to ${escape(asking)}.</p>`;
|
|
950
|
-
}
|
|
951
1051
|
|
|
952
1052
|
function interactionScreen() {
|
|
953
1053
|
const details = interaction!;
|
|
@@ -999,12 +1099,12 @@ function interactionScreen() {
|
|
|
999
1099
|
<button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
|
|
1000
1100
|
</form>`;
|
|
1001
1101
|
|
|
1002
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"
|
|
1102
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
|
|
1003
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>
|
|
1004
|
-
${highlights
|
|
1104
|
+
${highlightCells(config.highlights)}</div>
|
|
1005
1105
|
<div class="signin-form"><div>${body}</div>
|
|
1006
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>
|
|
1007
|
-
${badges
|
|
1107
|
+
${badgeStrip(config.badges)}</section>`;
|
|
1008
1108
|
|
|
1009
1109
|
element("reveal")?.addEventListener("click", () => {
|
|
1010
1110
|
const field = element<HTMLInputElement>("password");
|
|
@@ -1037,10 +1137,10 @@ function interactionScreen() {
|
|
|
1037
1137
|
}
|
|
1038
1138
|
|
|
1039
1139
|
function renderAccount(route: string) {
|
|
1040
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"
|
|
1140
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
|
|
1041
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>
|
|
1042
1142
|
</div>
|
|
1043
|
-
<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>`;
|
|
1044
1144
|
wireAccount(route);
|
|
1045
1145
|
}
|
|
1046
1146
|
|
|
@@ -1119,6 +1219,7 @@ if (relayProviderAnswer()) {
|
|
|
1119
1219
|
|
|
1120
1220
|
function boot() {
|
|
1121
1221
|
window.addEventListener("hashchange", render);
|
|
1222
|
+
recognising = mightBeRecognised();
|
|
1122
1223
|
render();
|
|
1123
1224
|
if (readInteraction()) {
|
|
1124
1225
|
render();
|
|
@@ -1167,6 +1268,7 @@ function boot() {
|
|
|
1167
1268
|
apiEndpoint: config.apiEndpoint,
|
|
1168
1269
|
prod: !config.localDemo,
|
|
1169
1270
|
});
|
|
1271
|
+
recognising = recognising && !sdk.isLoggedIn();
|
|
1170
1272
|
if (sdk.isLoggedIn()) {
|
|
1171
1273
|
await refresh();
|
|
1172
1274
|
if (!moduleRoute() && !accountRoutes.includes(currentRoute()))
|
|
@@ -1174,6 +1276,8 @@ function boot() {
|
|
|
1174
1276
|
return;
|
|
1175
1277
|
}
|
|
1176
1278
|
// Nothing held here. Fidj may still know this browser from an app.
|
|
1177
|
-
await askWhetherFidjKnowsThisBrowser();
|
|
1279
|
+
if (await askWhetherFidjKnowsThisBrowser()) return;
|
|
1280
|
+
recognising = false;
|
|
1281
|
+
render();
|
|
1178
1282
|
});
|
|
1179
1283
|
}
|