@ofidj/generator-fidj 3.10.2 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -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/server/index.ts +2 -1
- package/generators/app/templates/typescript/src/content.ts +227 -119
- package/generators/app/templates/typescript/src/main.ts +52 -26
- package/lib/scaffold.cjs +18 -3
- 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,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import {agreementModel, signInErrorMessage, agreementRequired, agreementFromRefusal, verificationPending, pollVerification, rememberSignIn, forgetSignIn, signInHint, type SigninShape} from "@ofidj/entry";
|
|
2
|
+
import {agreementScreen, bindAgreementScreen, bindPasswordReveal, 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
|
|
@@ -132,7 +123,10 @@ function wireSignOut() {
|
|
|
132
123
|
() =>
|
|
133
124
|
void action(async () => {
|
|
134
125
|
const wasSignedIn = signedIn;
|
|
135
|
-
if (signedIn)
|
|
126
|
+
if (signedIn) {
|
|
127
|
+
if (oidc?.hasSession()) await oidc.logout();
|
|
128
|
+
else await sdk.logout(true);
|
|
129
|
+
}
|
|
136
130
|
forgetSignIn(config.appId);
|
|
137
131
|
signedIn = false;
|
|
138
132
|
anonymous = false;
|
|
@@ -145,16 +139,6 @@ function wireSignOut() {
|
|
|
145
139
|
);
|
|
146
140
|
}
|
|
147
141
|
|
|
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
142
|
async function request(path: string, method = "GET", data?: unknown) {
|
|
159
143
|
const token = await sdk.fidjGetIdToken();
|
|
160
144
|
const response = await fetch(config.apiEndpoint + path, {
|
|
@@ -333,6 +317,19 @@ let recognising = false;
|
|
|
333
317
|
function mightBeRecognised() {
|
|
334
318
|
if (!oidc || !isFidjItself) return false;
|
|
335
319
|
if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
|
|
320
|
+
// Not on a screen somebody was sent to by a link.
|
|
321
|
+
//
|
|
322
|
+
// Asking the provider is a navigation: it leaves this address and comes back
|
|
323
|
+
// at the redirect URI, without the hash the link carried. On these routes the
|
|
324
|
+
// hash is the point — it holds a token that works once — so asking spent the
|
|
325
|
+
// link and dropped the person on the entry, with nothing to show for having
|
|
326
|
+
// opened their mail.
|
|
327
|
+
//
|
|
328
|
+
// It hid well. Every other way of reaching these screens comes from a page
|
|
329
|
+
// that already asked once and recorded it below, and the ask is once per
|
|
330
|
+
// document; only a tab that has never seen Fidj fires it here, which is
|
|
331
|
+
// exactly the tab a mail client opens and the one nothing exercised.
|
|
332
|
+
if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
|
|
336
333
|
if (oidc.signedOutHere()) return false;
|
|
337
334
|
try {
|
|
338
335
|
return sessionStorage.getItem(RECOGNITION_ASKED) !== "true";
|
|
@@ -345,6 +342,19 @@ async function askWhetherFidjKnowsThisBrowser() {
|
|
|
345
342
|
if (!oidc || !isFidjItself || sdk.isLoggedIn() || oidc.signedOutHere())
|
|
346
343
|
return false;
|
|
347
344
|
if ((moduleRoute() || "").split("/")[0] === PUBLIC_ROUTE) return false;
|
|
345
|
+
// Not on a screen somebody was sent to by a link.
|
|
346
|
+
//
|
|
347
|
+
// Asking the provider is a navigation: it leaves this address and comes back
|
|
348
|
+
// at the redirect URI, without the hash the link carried. On these routes the
|
|
349
|
+
// hash is the point — it holds a token that works once — so asking spent the
|
|
350
|
+
// link and dropped the person on the entry, with nothing to show for having
|
|
351
|
+
// opened their mail.
|
|
352
|
+
//
|
|
353
|
+
// It hid well. Every other way of reaching these screens comes from a page
|
|
354
|
+
// that already asked once and recorded it below, and the ask is once per
|
|
355
|
+
// document; only a tab that has never seen Fidj fires it here, which is
|
|
356
|
+
// exactly the tab a mail client opens and the one nothing exercised.
|
|
357
|
+
if (["forgot", "reset", "verify"].includes(currentRoute())) return false;
|
|
348
358
|
// Once per document: the answer comes back as a redirect to this same page,
|
|
349
359
|
// so without this a refusal would ask again, and again.
|
|
350
360
|
try {
|
|
@@ -427,11 +437,6 @@ function navigate(route: string) {
|
|
|
427
437
|
if (route !== currentRoute()) window.history.pushState(null, "", "#/" + route);
|
|
428
438
|
if (!busy) render();
|
|
429
439
|
}
|
|
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
440
|
|
|
436
441
|
function moduleRoute() {
|
|
437
442
|
const route = window.location.hash.slice(2).split("?")[0];
|
|
@@ -495,6 +500,8 @@ function render() {
|
|
|
495
500
|
if (interactionId && !addressedInteraction()) {
|
|
496
501
|
interactionId = "";
|
|
497
502
|
interactionError = "";
|
|
503
|
+
interactionResent = false;
|
|
504
|
+
interactionNotYet = false;
|
|
498
505
|
interaction = null;
|
|
499
506
|
interactionFailed = false;
|
|
500
507
|
}
|
|
@@ -581,7 +588,7 @@ function render() {
|
|
|
581
588
|
// The two ways out, last and together: back to the app, or out of this
|
|
582
589
|
// session. Leaving the app itself is a different decision and stays where
|
|
583
590
|
// 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>`;
|
|
591
|
+
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
592
|
renderNav("account");
|
|
586
593
|
wireAccount("account");
|
|
587
594
|
wireSignOut();
|
|
@@ -604,21 +611,13 @@ function render() {
|
|
|
604
611
|
}
|
|
605
612
|
// Only the entry reaches here now: the privacy screen and the account
|
|
606
613
|
// 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"}"
|
|
614
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
|
|
608
615
|
<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"
|
|
616
|
+
${highlightCells(config.highlights)}</div>
|
|
617
|
+
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin">${credentialFields({email: signInEmail, password: signInPassword})}</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
618
|
<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
|
|
619
|
+
${badgeStrip(config.badges)}</div></section>`;
|
|
613
620
|
wireNav();
|
|
614
|
-
element("reveal")?.addEventListener("click", () => {
|
|
615
|
-
const field = element<HTMLInputElement>("password");
|
|
616
|
-
const button = element("reveal");
|
|
617
|
-
if (!field || !button) return;
|
|
618
|
-
const hidden = field.type === "password";
|
|
619
|
-
field.type = hidden ? "text" : "password";
|
|
620
|
-
button.textContent = hidden ? "Hide" : "Show";
|
|
621
|
-
});
|
|
622
621
|
element("anonymous")?.addEventListener("click", () => {
|
|
623
622
|
if (!config.allowAnonymous) return;
|
|
624
623
|
anonymous = true;
|
|
@@ -635,14 +634,24 @@ function render() {
|
|
|
635
634
|
element("signin")!.innerHTML = providerEntry(
|
|
636
635
|
config.title,
|
|
637
636
|
config.appId,
|
|
638
|
-
config.signin === "button"
|
|
637
|
+
config.signin === "button"
|
|
638
|
+
? ""
|
|
639
|
+
: credentialFields({email: signInEmail, password: signInPassword}),
|
|
639
640
|
isFidjItself,
|
|
640
641
|
config.signin as SigninShape,
|
|
641
642
|
);
|
|
643
|
+
// The agreement, when the API has said this app is owed one. It takes the
|
|
644
|
+
// form's place rather than sitting under it: the credentials were accepted,
|
|
645
|
+
// and what is left is a decision about this app.
|
|
646
|
+
if (pendingAgreement && element("signin")) {
|
|
647
|
+
element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement, `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
|
|
648
|
+
bindAgreementScreen(element<HTMLFormElement>("signin"));
|
|
649
|
+
}
|
|
642
650
|
// The app's own form, for whoever came to type a password. Folded away rather
|
|
643
651
|
// than removed: the door above it is the one to take, and the person who
|
|
644
652
|
// wants this one is one click from it.
|
|
645
|
-
if (emailEntryOpen) showEmailEntry(true);
|
|
653
|
+
if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
|
|
654
|
+
if (!pendingAgreement && element("signin")) bindPasswordReveal(element("signin")!);
|
|
646
655
|
element("use-email")?.addEventListener("click", () => {
|
|
647
656
|
emailEntryOpen = !emailEntryOpen;
|
|
648
657
|
showEmailEntry(emailEntryOpen, true);
|
|
@@ -662,31 +671,61 @@ function render() {
|
|
|
662
671
|
}
|
|
663
672
|
render();
|
|
664
673
|
});
|
|
665
|
-
|
|
674
|
+
// The wait a just-created account owes its address, under the form that
|
|
675
|
+
// created it — not a screen, because nobody was taken anywhere.
|
|
676
|
+
if (awaitingVerification)
|
|
677
|
+
element("signin")?.insertAdjacentHTML(
|
|
678
|
+
"beforeend",
|
|
679
|
+
verificationWait({
|
|
680
|
+
email: awaitingVerification,
|
|
681
|
+
resent: verificationResent,
|
|
682
|
+
error: verificationNotice,
|
|
683
|
+
}),
|
|
684
|
+
);
|
|
685
|
+
element("resend-verification")?.addEventListener("click", () =>
|
|
686
|
+
void action(async () => {
|
|
687
|
+
verificationResent = false;
|
|
688
|
+
verificationNotice = "";
|
|
689
|
+
try {
|
|
690
|
+
await sdk.resendVerification();
|
|
691
|
+
verificationResent = true;
|
|
692
|
+
} catch {
|
|
693
|
+
// No session yet — the account was created and never signed in, which
|
|
694
|
+
// is the whole point of this screen. Creating it again with the same
|
|
695
|
+
// address is what sends another link.
|
|
696
|
+
verificationNotice =
|
|
697
|
+
"Press Create an account again to send another link.";
|
|
698
|
+
}
|
|
699
|
+
}),
|
|
700
|
+
);
|
|
666
701
|
element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
|
|
667
702
|
event.preventDefault();
|
|
668
703
|
const email = element<HTMLInputElement>("email")?.value || "";
|
|
669
704
|
const password = element<HTMLInputElement>("password")?.value || "";
|
|
670
|
-
const
|
|
705
|
+
const submitter = event.submitter as HTMLButtonElement | null;
|
|
706
|
+
// Answering the agreement screen. The credentials are the ones already
|
|
707
|
+
// accepted, so they are not re-read from a form that no longer shows them.
|
|
708
|
+
if (pendingAgreement) {
|
|
709
|
+
const acceptance = acceptedAgreement(
|
|
710
|
+
event.currentTarget as HTMLFormElement,
|
|
711
|
+
);
|
|
712
|
+
if (!acceptance) return;
|
|
713
|
+
submitter?.setAttribute("data-busy", "true");
|
|
714
|
+
void action(async () => {
|
|
715
|
+
if (
|
|
716
|
+
await refusedBeforeSignIn(signInEmail, signInPassword, false, acceptance)
|
|
717
|
+
)
|
|
718
|
+
return;
|
|
719
|
+
await completeSignIn();
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
671
723
|
signInEmail = email;
|
|
672
724
|
signInPassword = password;
|
|
673
|
-
signInAgreementAccepted = agreement?.checked === true;
|
|
674
|
-
const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
|
|
675
|
-
const submitter = event.submitter as HTMLButtonElement | null;
|
|
676
725
|
const signup = submitter?.name === "signup";
|
|
677
726
|
// Which door was used. The Fidj one leaves for the provider; the credential
|
|
678
727
|
// one signs in here, which is why it is the app's own form and not Fidj's.
|
|
679
728
|
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
729
|
if (oidc && throughFidj) {
|
|
691
730
|
// "Continue as <them>" promises to carry on as that person, and handed
|
|
692
731
|
// them over to an empty email field — so the promise cost a second
|
|
@@ -704,11 +743,69 @@ function render() {
|
|
|
704
743
|
if (oidc && (!email || !password)) {
|
|
705
744
|
throw new Error("Enter your email and password, or sign in with Fidj.");
|
|
706
745
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
746
|
+
if (await refusedBeforeSignIn(email, password, signup)) return;
|
|
747
|
+
await completeSignIn();
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// The API decides what the entry owes somebody next: a session, the agreement it
|
|
753
|
+
// has not recorded for this app, or a wait on an address nobody has proved they
|
|
754
|
+
// own. Returns true when it answered with a screen rather than a session.
|
|
755
|
+
async function refusedBeforeSignIn(
|
|
756
|
+
email: string,
|
|
757
|
+
password: string,
|
|
758
|
+
signup: boolean,
|
|
759
|
+
acceptance?: {termsAccepted: boolean; termsVersion: string},
|
|
760
|
+
): Promise<boolean> {
|
|
761
|
+
try {
|
|
762
|
+
await sdk.login(email, password, {autoSignup: signup, ...acceptance});
|
|
763
|
+
pendingAgreement = null;
|
|
764
|
+
awaitingVerification = "";
|
|
765
|
+
return false;
|
|
766
|
+
} catch (error) {
|
|
767
|
+
const created = verificationPending(error);
|
|
768
|
+
if (created) {
|
|
769
|
+
awaitingVerification = created.email || email;
|
|
770
|
+
verificationResent = false;
|
|
771
|
+
verificationNotice = "";
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
if (agreementRequired(error)) {
|
|
775
|
+
pendingAgreement = agreementFromRefusal(error) || (await readAgreement());
|
|
776
|
+
if (!pendingAgreement) {
|
|
777
|
+
throw new Error("We cannot reach Fidj right now. Please try again.");
|
|
711
778
|
}
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
throw new Error(signInErrorMessage(error));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// An API that refused without saying which agreement it wanted. Older ones
|
|
786
|
+
// cannot say, so the app is asked directly; it is the same agreement unless the
|
|
787
|
+
// owner published between the two calls.
|
|
788
|
+
async function readAgreement() {
|
|
789
|
+
try {
|
|
790
|
+
const response = await fetch(
|
|
791
|
+
`${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}`,
|
|
792
|
+
{signal: AbortSignal.timeout(10000)},
|
|
793
|
+
);
|
|
794
|
+
if (!response.ok) return null;
|
|
795
|
+
const agreement = (await response.json()).app?.agreement;
|
|
796
|
+
return typeof agreement?.version === "string" &&
|
|
797
|
+
agreement.version &&
|
|
798
|
+
typeof agreement?.text === "string" &&
|
|
799
|
+
agreement.text
|
|
800
|
+
? {version: agreement.version, text: agreement.text}
|
|
801
|
+
: null;
|
|
802
|
+
} catch {
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function completeSignIn() {
|
|
808
|
+
return (async () => {
|
|
712
809
|
// Fidj collects the credential on its own page rather than sending itself
|
|
713
810
|
// through its own door, and that sign-in created no session the provider
|
|
714
811
|
// could see: the first app opened afterwards asked for the password again,
|
|
@@ -718,14 +815,12 @@ function render() {
|
|
|
718
815
|
// every app must not be mintable by one that collects its own passwords.
|
|
719
816
|
if (isFidjItself) {
|
|
720
817
|
try {
|
|
721
|
-
await
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
withCredentials: true,
|
|
728
|
-
});
|
|
818
|
+
const transfer = await request("/me/oidc/session-transfer", "POST");
|
|
819
|
+
// The provider cookie belongs to the API origin. A cross-site fetch
|
|
820
|
+
// cannot reliably replace it, so let that origin answer once as the
|
|
821
|
+
// top-level page, consume a one-use ticket, then send us back here.
|
|
822
|
+
window.location.assign(transfer.location);
|
|
823
|
+
return;
|
|
729
824
|
} catch {
|
|
730
825
|
// Being signed in here still worked. The person is inside Fidj; what
|
|
731
826
|
// they lose is being recognised by the next app without typing again,
|
|
@@ -734,12 +829,11 @@ function render() {
|
|
|
734
829
|
}
|
|
735
830
|
await refresh();
|
|
736
831
|
anonymous = false;
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
});
|
|
832
|
+
// A new account belongs where a returning one lands: inside the app.
|
|
833
|
+
// Sending it to the account card instead dropped people who had just
|
|
834
|
+
// signed up on the shell, one click short of the app they came for.
|
|
835
|
+
navigate("content");
|
|
836
|
+
})();
|
|
743
837
|
}
|
|
744
838
|
|
|
745
839
|
// Everything the account screen lets a person do about what this app holds:
|
|
@@ -829,8 +923,8 @@ function wirePrivacy() {
|
|
|
829
923
|
});
|
|
830
924
|
}
|
|
831
925
|
|
|
832
|
-
|
|
833
|
-
|
|
926
|
+
|
|
927
|
+
|
|
834
928
|
// What this app holds about the person signed in, and what they can do
|
|
835
929
|
// about it. It used to be a screen of its own called My privacy, one tab
|
|
836
930
|
// away from the account it was about — so a person looking for their own
|
|
@@ -858,17 +952,6 @@ function privacyBlock() {
|
|
|
858
952
|
<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
953
|
}
|
|
860
954
|
|
|
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
955
|
// ------------------------------------------------ signing in, for the provider
|
|
873
956
|
//
|
|
874
957
|
// When Fidj's provider needs a person to identify themselves it hands them to a
|
|
@@ -883,14 +966,24 @@ function accountForm(route: string) {
|
|
|
883
966
|
type Interaction = {
|
|
884
967
|
prompt: string;
|
|
885
968
|
csrf: string;
|
|
969
|
+
// The address this interaction is waiting on, when it is waiting rather than
|
|
970
|
+
// asking. Empty on every other screen.
|
|
971
|
+
awaiting?: string;
|
|
886
972
|
app: { id: string; title: string; description: string };
|
|
887
973
|
scopes: string[];
|
|
888
974
|
termsUri: string;
|
|
889
975
|
privacyUri: string;
|
|
976
|
+
// The agreement's own text, so it can be read here rather than in another tab.
|
|
977
|
+
agreement?: {version: string; text: string} | null;
|
|
890
978
|
action: string;
|
|
891
979
|
};
|
|
892
980
|
let interactionId = "";
|
|
893
981
|
let interactionError = "";
|
|
982
|
+
// Whether the link was just sent again, or pressed Continue before opening it.
|
|
983
|
+
// They are about the wait, not about the person, so they travel in the address
|
|
984
|
+
// where the address itself does not.
|
|
985
|
+
let interactionResent = false;
|
|
986
|
+
let interactionNotYet = false;
|
|
894
987
|
let interaction: Interaction | null = null;
|
|
895
988
|
let interactionFailed = false;
|
|
896
989
|
|
|
@@ -939,6 +1032,8 @@ function readInteraction() {
|
|
|
939
1032
|
if (!uid) return false;
|
|
940
1033
|
interactionId = uid;
|
|
941
1034
|
interactionError = parameters.get("error") || "";
|
|
1035
|
+
interactionResent = parameters.get("resent") === "1";
|
|
1036
|
+
interactionNotYet = parameters.get("notyet") === "1";
|
|
942
1037
|
// The id stays in the address while the screen is up. Taking it out looked
|
|
943
1038
|
// tidier and made the screen a trap: the address became "#/signin", so going
|
|
944
1039
|
// back to "#/signin" changed nothing, the document never reloaded, and the
|
|
@@ -961,12 +1056,14 @@ async function loadInteraction() {
|
|
|
961
1056
|
interaction = (await response.json()) as Interaction;
|
|
962
1057
|
}
|
|
963
1058
|
|
|
964
|
-
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1059
|
+
|
|
1060
|
+
// What the wait has to say beyond the address, if anything.
|
|
1061
|
+
function interactionWaitNotice() {
|
|
1062
|
+
if (interactionNotYet)
|
|
1063
|
+
return '<p class="fineprint">The link has not been opened yet. Open it, then press Continue again.</p>';
|
|
1064
|
+
if (interactionResent)
|
|
1065
|
+
return '<p class="fineprint">The link was sent again. Only the newest one works.</p>';
|
|
1066
|
+
return "";
|
|
970
1067
|
}
|
|
971
1068
|
|
|
972
1069
|
function interactionScreen() {
|
|
@@ -987,8 +1084,24 @@ function interactionScreen() {
|
|
|
987
1084
|
// sees your password" about itself is nonsense in the same family as offering
|
|
988
1085
|
// to sign in with Fidj on Fidj.
|
|
989
1086
|
const itself = details.app.id === config.appId;
|
|
990
|
-
|
|
991
|
-
|
|
1087
|
+
// The wait, when this interaction is waiting on an address rather than asking
|
|
1088
|
+
// for anything. It is not a refusal and must not be drawn as one: the account
|
|
1089
|
+
// exists, the link was sent, and what is left is opening it. The address is
|
|
1090
|
+
// read from the interaction's own context rather than from the URL, so
|
|
1091
|
+
// nothing about the person travels in an address bar.
|
|
1092
|
+
const body = details.awaiting
|
|
1093
|
+
? `<h2>Check your email</h2>
|
|
1094
|
+
<p class="signin-lead">Your account is created. Waiting for you to open the link sent to <strong>${escape(details.awaiting)}</strong>.</p>
|
|
1095
|
+
${returnNotice(itself ? "Fidj" : asking)}
|
|
1096
|
+
<form method="post" action="${escape(action)}" id="interaction">
|
|
1097
|
+
<input type="hidden" name="csrf" value="${escape(details.csrf)}">
|
|
1098
|
+
<p class="fineprint">The link may take a minute, and it sometimes lands in spam. Open it, then come back here.</p>
|
|
1099
|
+
${interactionWaitNotice()}
|
|
1100
|
+
<button class="primary" type="submit" name="action" value="continue">Continue</button>
|
|
1101
|
+
<button class="secondary" type="submit" name="action" value="resend">Send the link again</button>
|
|
1102
|
+
<button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
|
|
1103
|
+
</form>`
|
|
1104
|
+
: details.prompt === "login"
|
|
992
1105
|
? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
|
|
993
1106
|
<p class="signin-lead">${itself ? "One account across every app that uses Fidj, and a separate set of choices for each one." : `This is Fidj, the account behind ${asking}. One account, and separate choices for every app that uses it — ${asking} never sees your password.`}</p>
|
|
994
1107
|
${returnNotice(itself ? "Fidj" : asking)}
|
|
@@ -1012,28 +1125,22 @@ function interactionScreen() {
|
|
|
1012
1125
|
.join("")}</ul>
|
|
1013
1126
|
<form method="post" action="${escape(action)}" id="interaction">
|
|
1014
1127
|
<input type="hidden" name="csrf" value="${escape(details.csrf)}">
|
|
1015
|
-
<label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span
|
|
1128
|
+
<label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>${escape(agreementModel(details.app.title, {}).checkboxLabel)}</span></label>
|
|
1129
|
+
${details.agreement ? `<details class="agreement"><summary>Read service agreement</summary><p class="fineprint">Version ${escape(details.agreement.version)}</p><p>${escape(details.agreement.text)}</p></details><p class="fineprint">Required to sign in. Optional data choices stay separate.</p>` : ""}
|
|
1016
1130
|
${details.termsUri ? `<p class="fineprint"><a href="${escape(details.termsUri)}" target="_blank" rel="noopener noreferrer">Service agreement</a>${details.privacyUri ? ` · <a href="${escape(details.privacyUri)}" target="_blank" rel="noopener noreferrer">Privacy notice</a>` : ""}</p>` : ""}
|
|
1017
1131
|
<button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
|
|
1018
1132
|
<button class="quiet" type="submit" id="not-me" name="action" value="switch" formnovalidate>Not you? Sign in with another account</button>
|
|
1019
1133
|
<button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
|
|
1020
1134
|
</form>`;
|
|
1021
1135
|
|
|
1022
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"
|
|
1136
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
|
|
1023
1137
|
<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
|
|
1138
|
+
${highlightCells(config.highlights)}</div>
|
|
1025
1139
|
<div class="signin-form"><div>${body}</div>
|
|
1026
1140
|
<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
|
|
1141
|
+
${badgeStrip(config.badges)}</section>`;
|
|
1028
1142
|
|
|
1029
|
-
element("
|
|
1030
|
-
const field = element<HTMLInputElement>("password");
|
|
1031
|
-
const button = element("reveal");
|
|
1032
|
-
if (!field || !button) return;
|
|
1033
|
-
const hidden = field.type === "password";
|
|
1034
|
-
field.type = hidden ? "text" : "password";
|
|
1035
|
-
button.textContent = hidden ? "Hide" : "Show";
|
|
1036
|
-
});
|
|
1143
|
+
if (element("interaction")) bindPasswordReveal(element("interaction")!);
|
|
1037
1144
|
// Being recognised is the point, and a dead end when the person is not who
|
|
1038
1145
|
// Fidj thinks — a shared computer, a second account, somebody else's tab. So
|
|
1039
1146
|
// the screen that recognises them asks again on request. It goes back to the
|
|
@@ -1057,10 +1164,11 @@ function interactionScreen() {
|
|
|
1057
1164
|
}
|
|
1058
1165
|
|
|
1059
1166
|
function renderAccount(route: string) {
|
|
1060
|
-
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"
|
|
1167
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain">${masthead(config.logo, config.title)}
|
|
1061
1168
|
<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
1169
|
</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>`;
|
|
1170
|
+
<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>`;
|
|
1171
|
+
bindPasswordReveal(root);
|
|
1064
1172
|
wireAccount(route);
|
|
1065
1173
|
}
|
|
1066
1174
|
|