@ofidj/generator-fidj 3.10.2 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +172 -92
- 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, {
|
|
@@ -333,6 +314,19 @@ let recognising = false;
|
|
|
333
314
|
function mightBeRecognised() {
|
|
334
315
|
if (!oidc || !isFidjItself) return false;
|
|
335
316
|
if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
|
|
317
|
+
// Not on a screen somebody was sent to by a link.
|
|
318
|
+
//
|
|
319
|
+
// Asking the provider is a navigation: it leaves this address and comes back
|
|
320
|
+
// at the redirect URI, without the hash the link carried. On these routes the
|
|
321
|
+
// hash is the point — it holds a token that works once — so asking spent the
|
|
322
|
+
// link and dropped the person on the entry, with nothing to show for having
|
|
323
|
+
// opened their mail.
|
|
324
|
+
//
|
|
325
|
+
// It hid well. Every other way of reaching these screens comes from a page
|
|
326
|
+
// that already asked once and recorded it below, and the ask is once per
|
|
327
|
+
// document; only a tab that has never seen Fidj fires it here, which is
|
|
328
|
+
// exactly the tab a mail client opens and the one nothing exercised.
|
|
329
|
+
if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
|
|
336
330
|
if (oidc.signedOutHere()) return false;
|
|
337
331
|
try {
|
|
338
332
|
return sessionStorage.getItem(RECOGNITION_ASKED) !== "true";
|
|
@@ -345,6 +339,19 @@ async function askWhetherFidjKnowsThisBrowser() {
|
|
|
345
339
|
if (!oidc || !isFidjItself || sdk.isLoggedIn() || oidc.signedOutHere())
|
|
346
340
|
return false;
|
|
347
341
|
if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
|
|
342
|
+
// Not on a screen somebody was sent to by a link.
|
|
343
|
+
//
|
|
344
|
+
// Asking the provider is a navigation: it leaves this address and comes back
|
|
345
|
+
// at the redirect URI, without the hash the link carried. On these routes the
|
|
346
|
+
// hash is the point — it holds a token that works once — so asking spent the
|
|
347
|
+
// link and dropped the person on the entry, with nothing to show for having
|
|
348
|
+
// opened their mail.
|
|
349
|
+
//
|
|
350
|
+
// It hid well. Every other way of reaching these screens comes from a page
|
|
351
|
+
// that already asked once and recorded it below, and the ask is once per
|
|
352
|
+
// document; only a tab that has never seen Fidj fires it here, which is
|
|
353
|
+
// exactly the tab a mail client opens and the one nothing exercised.
|
|
354
|
+
if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
|
|
348
355
|
// Once per document: the answer comes back as a redirect to this same page,
|
|
349
356
|
// so without this a refusal would ask again, and again.
|
|
350
357
|
try {
|
|
@@ -427,11 +434,6 @@ function navigate(route: string) {
|
|
|
427
434
|
if (route !== currentRoute()) window.history.pushState(null, "", "#/" + route);
|
|
428
435
|
if (!busy) render();
|
|
429
436
|
}
|
|
430
|
-
// The credential fields, in one place because the entry now shows them beside
|
|
431
|
-
// the Fidj door rather than instead of it.
|
|
432
|
-
function credentialFields() {
|
|
433
|
-
return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" placeholder="you@company.com" autocomplete="username"><div class="field-head"><label for="password">Password</label><a href="#/forgot">Forgot?</a></div><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" placeholder="••••••••••" autocomplete="current-password"><button type="button" id="reveal" aria-controls="password">Show</button></div><button class="primary" type="submit" name="entry" value="credentials">Continue</button><button class="secondary" type="submit" name="signup" value="true">Create an account</button>`;
|
|
434
|
-
}
|
|
435
437
|
|
|
436
438
|
function moduleRoute() {
|
|
437
439
|
const route = window.location.hash.slice(2).split("?")[0];
|
|
@@ -581,7 +583,7 @@ function render() {
|
|
|
581
583
|
// The two ways out, last and together: back to the app, or out of this
|
|
582
584
|
// session. Leaving the app itself is a different decision and stays where
|
|
583
585
|
// the things it erases are listed.
|
|
584
|
-
root.innerHTML = `<section class="card content-account">${banner()}${accountForm("account")}${privacyBlock()}<div class="account-actions"><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button><button id="exit">Sign out</button></div></section>`;
|
|
586
|
+
root.innerHTML = `<section class="card content-account">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail})}${privacyBlock()}<div class="account-actions"><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button><button id="exit">Sign out</button></div></section>`;
|
|
585
587
|
renderNav("account");
|
|
586
588
|
wireAccount("account");
|
|
587
589
|
wireSignOut();
|
|
@@ -604,12 +606,12 @@ function render() {
|
|
|
604
606
|
}
|
|
605
607
|
// Only the entry reaches here now: the privacy screen and the account
|
|
606
608
|
// screen became one, and that one is drawn above.
|
|
607
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}"
|
|
609
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
|
|
608
610
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
609
|
-
${highlights
|
|
610
|
-
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin"><label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" placeholder="you@company.com" autocomplete="username" required><div class="field-head"><label for="password">Password</label><a href="#/forgot">Forgot?</a></div><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" placeholder="••••••••••" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div
|
|
611
|
+
${highlightCells(config.highlights)}</div>
|
|
612
|
+
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin"><label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" placeholder="you@company.com" autocomplete="username" required><div class="field-head"><label for="password">Password</label><a href="#/forgot">Forgot?</a></div><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" placeholder="••••••••••" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div><button class="primary" type="submit">Sign in</button><button class="secondary" type="submit" name="signup" value="true">Create an account</button></form>${config.allowAnonymous ? `<div class="signin-divider"><span>or explore first</span></div><button class="anonymous-entry" id="anonymous">Enter anonymously <span aria-hidden="true">→</span></button><p class="signin-footnote">No account needed to view the content.</p>` : ""}
|
|
611
613
|
<div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>Your account, with Fidj</strong></p><p>Signing in creates one Fidj account you keep across every app that uses Fidj.</p><p>You choose what this app may store — and can export or erase it at any moment.</p></div></div>
|
|
612
|
-
${badges
|
|
614
|
+
${badgeStrip(config.badges)}</div></section>`;
|
|
613
615
|
wireNav();
|
|
614
616
|
element("reveal")?.addEventListener("click", () => {
|
|
615
617
|
const field = element<HTMLInputElement>("password");
|
|
@@ -635,14 +637,23 @@ function render() {
|
|
|
635
637
|
element("signin")!.innerHTML = providerEntry(
|
|
636
638
|
config.title,
|
|
637
639
|
config.appId,
|
|
638
|
-
config.signin === "button"
|
|
640
|
+
config.signin === "button"
|
|
641
|
+
? ""
|
|
642
|
+
: credentialFields({email: signInEmail, password: signInPassword}),
|
|
639
643
|
isFidjItself,
|
|
640
644
|
config.signin as SigninShape,
|
|
641
645
|
);
|
|
646
|
+
// The agreement, when the API has said this app is owed one. It takes the
|
|
647
|
+
// form's place rather than sitting under it: the credentials were accepted,
|
|
648
|
+
// and what is left is a decision about this app.
|
|
649
|
+
if (pendingAgreement && element("signin")) {
|
|
650
|
+
element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement);
|
|
651
|
+
bindAgreementScreen(element<HTMLFormElement>("signin"));
|
|
652
|
+
}
|
|
642
653
|
// The app's own form, for whoever came to type a password. Folded away rather
|
|
643
654
|
// than removed: the door above it is the one to take, and the person who
|
|
644
655
|
// wants this one is one click from it.
|
|
645
|
-
if (emailEntryOpen) showEmailEntry(true);
|
|
656
|
+
if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
|
|
646
657
|
element("use-email")?.addEventListener("click", () => {
|
|
647
658
|
emailEntryOpen = !emailEntryOpen;
|
|
648
659
|
showEmailEntry(emailEntryOpen, true);
|
|
@@ -662,31 +673,61 @@ function render() {
|
|
|
662
673
|
}
|
|
663
674
|
render();
|
|
664
675
|
});
|
|
665
|
-
|
|
676
|
+
// The wait a just-created account owes its address, under the form that
|
|
677
|
+
// created it — not a screen, because nobody was taken anywhere.
|
|
678
|
+
if (awaitingVerification)
|
|
679
|
+
element("signin")?.insertAdjacentHTML(
|
|
680
|
+
"beforeend",
|
|
681
|
+
verificationWait({
|
|
682
|
+
email: awaitingVerification,
|
|
683
|
+
resent: verificationResent,
|
|
684
|
+
error: verificationNotice,
|
|
685
|
+
}),
|
|
686
|
+
);
|
|
687
|
+
element("resend-verification")?.addEventListener("click", () =>
|
|
688
|
+
void action(async () => {
|
|
689
|
+
verificationResent = false;
|
|
690
|
+
verificationNotice = "";
|
|
691
|
+
try {
|
|
692
|
+
await sdk.resendVerification();
|
|
693
|
+
verificationResent = true;
|
|
694
|
+
} catch {
|
|
695
|
+
// No session yet — the account was created and never signed in, which
|
|
696
|
+
// is the whole point of this screen. Creating it again with the same
|
|
697
|
+
// address is what sends another link.
|
|
698
|
+
verificationNotice =
|
|
699
|
+
"Press Create an account again to send another link.";
|
|
700
|
+
}
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
666
703
|
element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
|
|
667
704
|
event.preventDefault();
|
|
668
705
|
const email = element<HTMLInputElement>("email")?.value || "";
|
|
669
706
|
const password = element<HTMLInputElement>("password")?.value || "";
|
|
670
|
-
const
|
|
707
|
+
const submitter = event.submitter as HTMLButtonElement | null;
|
|
708
|
+
// Answering the agreement screen. The credentials are the ones already
|
|
709
|
+
// accepted, so they are not re-read from a form that no longer shows them.
|
|
710
|
+
if (pendingAgreement) {
|
|
711
|
+
const acceptance = acceptedAgreement(
|
|
712
|
+
event.currentTarget as HTMLFormElement,
|
|
713
|
+
);
|
|
714
|
+
if (!acceptance) return;
|
|
715
|
+
submitter?.setAttribute("data-busy", "true");
|
|
716
|
+
void action(async () => {
|
|
717
|
+
if (
|
|
718
|
+
await refusedBeforeSignIn(signInEmail, signInPassword, false, acceptance)
|
|
719
|
+
)
|
|
720
|
+
return;
|
|
721
|
+
await completeSignIn();
|
|
722
|
+
});
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
671
725
|
signInEmail = email;
|
|
672
726
|
signInPassword = password;
|
|
673
|
-
signInAgreementAccepted = agreement?.checked === true;
|
|
674
|
-
const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
|
|
675
|
-
const submitter = event.submitter as HTMLButtonElement | null;
|
|
676
727
|
const signup = submitter?.name === "signup";
|
|
677
728
|
// Which door was used. The Fidj one leaves for the provider; the credential
|
|
678
729
|
// one signs in here, which is why it is the app's own form and not Fidj's.
|
|
679
730
|
const throughFidj = submitter?.name === "entry" && submitter.value === "fidj";
|
|
680
|
-
// Only the credential door is gated here. The Fidj one is about to be asked
|
|
681
|
-
// the same question on the screen that names this app, where the answer is
|
|
682
|
-
// recorded with its version — so asking first cost a second click and kept
|
|
683
|
-
// nothing.
|
|
684
|
-
if (!throughFidj && !acceptance) {
|
|
685
|
-
failed = true;
|
|
686
|
-
message = "Please accept the service agreement before continuing.";
|
|
687
|
-
render();
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
731
|
if (oidc && throughFidj) {
|
|
691
732
|
// "Continue as <them>" promises to carry on as that person, and handed
|
|
692
733
|
// them over to an empty email field — so the promise cost a second
|
|
@@ -704,11 +745,69 @@ function render() {
|
|
|
704
745
|
if (oidc && (!email || !password)) {
|
|
705
746
|
throw new Error("Enter your email and password, or sign in with Fidj.");
|
|
706
747
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
748
|
+
if (await refusedBeforeSignIn(email, password, signup)) return;
|
|
749
|
+
await completeSignIn();
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// The API decides what the entry owes somebody next: a session, the agreement it
|
|
755
|
+
// has not recorded for this app, or a wait on an address nobody has proved they
|
|
756
|
+
// own. Returns true when it answered with a screen rather than a session.
|
|
757
|
+
async function refusedBeforeSignIn(
|
|
758
|
+
email: string,
|
|
759
|
+
password: string,
|
|
760
|
+
signup: boolean,
|
|
761
|
+
acceptance?: {termsAccepted: boolean; termsVersion: string},
|
|
762
|
+
): Promise<boolean> {
|
|
763
|
+
try {
|
|
764
|
+
await sdk.login(email, password, {autoSignup: signup, ...acceptance});
|
|
765
|
+
pendingAgreement = null;
|
|
766
|
+
awaitingVerification = "";
|
|
767
|
+
return false;
|
|
768
|
+
} catch (error) {
|
|
769
|
+
const created = verificationPending(error);
|
|
770
|
+
if (created) {
|
|
771
|
+
awaitingVerification = created.email || email;
|
|
772
|
+
verificationResent = false;
|
|
773
|
+
verificationNotice = "";
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
if (agreementRequired(error)) {
|
|
777
|
+
pendingAgreement = agreementFromRefusal(error) || (await readAgreement());
|
|
778
|
+
if (!pendingAgreement) {
|
|
779
|
+
throw new Error("We cannot reach Fidj right now. Please try again.");
|
|
711
780
|
}
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
throw new Error(signInErrorMessage(error));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// An API that refused without saying which agreement it wanted. Older ones
|
|
788
|
+
// cannot say, so the app is asked directly; it is the same agreement unless the
|
|
789
|
+
// owner published between the two calls.
|
|
790
|
+
async function readAgreement() {
|
|
791
|
+
try {
|
|
792
|
+
const response = await fetch(
|
|
793
|
+
`${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}`,
|
|
794
|
+
{signal: AbortSignal.timeout(10000)},
|
|
795
|
+
);
|
|
796
|
+
if (!response.ok) return null;
|
|
797
|
+
const agreement = (await response.json()).app?.agreement;
|
|
798
|
+
return typeof agreement?.version === "string" &&
|
|
799
|
+
agreement.version &&
|
|
800
|
+
typeof agreement?.text === "string" &&
|
|
801
|
+
agreement.text
|
|
802
|
+
? {version: agreement.version, text: agreement.text}
|
|
803
|
+
: null;
|
|
804
|
+
} catch {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function completeSignIn() {
|
|
810
|
+
return (async () => {
|
|
712
811
|
// Fidj collects the credential on its own page rather than sending itself
|
|
713
812
|
// through its own door, and that sign-in created no session the provider
|
|
714
813
|
// could see: the first app opened afterwards asked for the password again,
|
|
@@ -734,12 +833,11 @@ function render() {
|
|
|
734
833
|
}
|
|
735
834
|
await refresh();
|
|
736
835
|
anonymous = false;
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
});
|
|
836
|
+
// A new account belongs where a returning one lands: inside the app.
|
|
837
|
+
// Sending it to the account card instead dropped people who had just
|
|
838
|
+
// signed up on the shell, one click short of the app they came for.
|
|
839
|
+
navigate("content");
|
|
840
|
+
})();
|
|
743
841
|
}
|
|
744
842
|
|
|
745
843
|
// Everything the account screen lets a person do about what this app holds:
|
|
@@ -829,8 +927,8 @@ function wirePrivacy() {
|
|
|
829
927
|
});
|
|
830
928
|
}
|
|
831
929
|
|
|
832
|
-
|
|
833
|
-
|
|
930
|
+
|
|
931
|
+
|
|
834
932
|
// What this app holds about the person signed in, and what they can do
|
|
835
933
|
// about it. It used to be a screen of its own called My privacy, one tab
|
|
836
934
|
// away from the account it was about — so a person looking for their own
|
|
@@ -858,17 +956,6 @@ function privacyBlock() {
|
|
|
858
956
|
<p class="leaving"><a href="${escape(config.dashboardUrl)}/#/my" target="_blank" rel="noopener">Open Fidj to manage every app you use ↗</a><br><small>Fidj is the account provider behind ${escape(config.title)}. This opens it in a new tab; you stay signed in here.</small></p>`;
|
|
859
957
|
}
|
|
860
958
|
|
|
861
|
-
function accountForm(route: string) {
|
|
862
|
-
return route === "forgot"
|
|
863
|
-
? `<h2>Reset your password</h2><p>We’ll email you a link to choose a new password for your shared Fidj account.</p><form id="recovery"><label for="recovery-email">Email address</label><input id="recovery-email" type="email" autocomplete="email" required><button class="primary">Send reset link</button></form>`
|
|
864
|
-
: route === "reset"
|
|
865
|
-
? `<h2>Choose a new password</h2><p>This changes your Fidj password across all your apps and signs out existing sessions.</p>${linkToken ? '<form id="recovery"><label for="new-password">New password</label><input id="new-password" type="password" autocomplete="new-password" minlength="12" required><label for="confirm-password">Confirm password</label><input id="confirm-password" type="password" autocomplete="new-password" minlength="12" required><p>Use at least 12 characters (up to 72 UTF-8 bytes).</p><button class="primary">Save new password</button></form>' : '<p>Request a new link if you no longer have an active reset link.</p><a href="#/forgot">Request a reset link</a>'}`
|
|
866
|
-
: route === "verify"
|
|
867
|
-
? `<h2>${verificationConfirmed ? "Email verified" : "Verify your email"}</h2>${verificationConfirmed ? "<p>Your account is ready. Return to your app to continue.</p>" : "<p>Confirm that this email address belongs to you.</p>"}${verificationConfirmed ? "" : linkToken ? '<form id="recovery"><button class="primary">Confirm email address</button></form>' : "<p>Sign in to your account to request a new verification email.</p>"}`
|
|
868
|
-
: `<h2>My Fidj account</h2><p class="account-identity">Signed in as <strong>${escape(accountEmail)}</strong></p><p>Your identity is shared across your apps. Privacy choices remain separate for each app.</p><p id="verification-status">${emailVerified ? "Your email address is verified." : "Your email is not verified yet."}</p><button id="check-verification">Refresh verification status</button>${emailVerified ? "" : '<button id="resend-verification">Send verification email</button>'}<p><a href="#/forgot">Reset my password</a></p>`;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
|
|
872
959
|
// ------------------------------------------------ signing in, for the provider
|
|
873
960
|
//
|
|
874
961
|
// When Fidj's provider needs a person to identify themselves it hands them to a
|
|
@@ -961,13 +1048,6 @@ async function loadInteraction() {
|
|
|
961
1048
|
interaction = (await response.json()) as Interaction;
|
|
962
1049
|
}
|
|
963
1050
|
|
|
964
|
-
// A window that opened on its own, over the page somebody was on, owes them the
|
|
965
|
-
// way out before it asks for anything. Naming the app they came from is also the
|
|
966
|
-
// only thing on this screen that they can check against what they were doing a
|
|
967
|
-
// second ago — which is exactly what a page asking for a password should offer.
|
|
968
|
-
function returnNotice(asking: string) {
|
|
969
|
-
return `<p class="signin-return" role="note">When you are done, this window closes and takes you back to ${escape(asking)}.</p>`;
|
|
970
|
-
}
|
|
971
1051
|
|
|
972
1052
|
function interactionScreen() {
|
|
973
1053
|
const details = interaction!;
|
|
@@ -1019,12 +1099,12 @@ function interactionScreen() {
|
|
|
1019
1099
|
<button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
|
|
1020
1100
|
</form>`;
|
|
1021
1101
|
|
|
1022
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"
|
|
1102
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
|
|
1023
1103
|
<div class="signin-identity"><h1>Your identity.<br>Your choices.</h1><p class="signin-description">One account across every app that uses Fidj, and a separate set of choices for each one.</p></div>
|
|
1024
|
-
${highlights
|
|
1104
|
+
${highlightCells(config.highlights)}</div>
|
|
1025
1105
|
<div class="signin-form"><div>${body}</div>
|
|
1026
1106
|
<div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>What Fidj is</strong></p><p>Fidj holds your account so each app does not have to. You can see every app you use, what it holds, and take it back — at any time.</p></div></div>
|
|
1027
|
-
${badges
|
|
1107
|
+
${badgeStrip(config.badges)}</section>`;
|
|
1028
1108
|
|
|
1029
1109
|
element("reveal")?.addEventListener("click", () => {
|
|
1030
1110
|
const field = element<HTMLInputElement>("password");
|
|
@@ -1057,10 +1137,10 @@ function interactionScreen() {
|
|
|
1057
1137
|
}
|
|
1058
1138
|
|
|
1059
1139
|
function renderAccount(route: string) {
|
|
1060
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"
|
|
1140
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
|
|
1061
1141
|
<div class="signin-identity"><h1>Your account.<br>Your control.</h1><p class="signin-description">Secure access to the apps you use, with one Fidj identity.</p></div>
|
|
1062
1142
|
</div>
|
|
1063
|
-
<div class="signin-form"><div>${banner()}${accountForm(route)}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
|
|
1143
|
+
<div class="signin-form"><div>${banner()}${accountForm(route, {linkToken, verificationConfirmed, emailVerified, accountEmail})}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
|
|
1064
1144
|
wireAccount(route);
|
|
1065
1145
|
}
|
|
1066
1146
|
|