@ofidj/generator-fidj 3.7.5 → 3.8.1
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 +19 -1
- package/bin/create-fidj.cjs +3 -1
- package/generators/app/index.js +2 -0
- package/generators/app/templates/typescript/README.md +1 -1
- package/generators/app/templates/typescript/package.json +1 -1
- package/generators/app/templates/typescript/scripts/render-content.mjs +25 -2
- package/generators/app/templates/typescript/server/index.ts +6 -0
- package/generators/app/templates/typescript/src/content.ts +328 -153
- package/generators/app/templates/typescript/src/main.ts +112 -8
- package/generators/app/templates/typescript/src/provider-window.ts +177 -0
- package/generators/app/templates/typescript/src/service-agreement.ts +80 -23
- package/generators/app/templates/typescript/src/style.css +203 -5
- package/generators/app/templates/typescript/src/tokens.css +5 -0
- package/lib/scaffold.cjs +20 -8
- package/package.json +6 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn, signInHint} from "./service-agreement";
|
|
1
|
+
import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn, signInHint, showEmailEntry, type SigninShape} from "./service-agreement";
|
|
2
|
+
import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "./provider-window";
|
|
2
3
|
import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
|
|
3
4
|
import config from "../app.config.json";
|
|
4
5
|
import "./style.css";
|
|
@@ -29,6 +30,15 @@ let leaving = false;
|
|
|
29
30
|
let signInEmail = "";
|
|
30
31
|
let signInPassword = "";
|
|
31
32
|
let signInAgreementAccepted = false;
|
|
33
|
+
// Who is signed in, as this app's own membership answers it. The bar names them
|
|
34
|
+
// and the account screen says it again where it can be checked; the ID token of
|
|
35
|
+
// a code flow carries only a subject, so this comes from the membership.
|
|
36
|
+
let accountEmail = "";
|
|
37
|
+
// Whether the person asked for the app's own form. The entry is rebuilt on every
|
|
38
|
+
// render, and a refused sign-in is a render: without this, pressing Continue
|
|
39
|
+
// with the agreement unchecked folded the form away and left the complaint
|
|
40
|
+
// floating above a door the person could no longer see.
|
|
41
|
+
let emailEntryOpen = false;
|
|
32
42
|
const accountRoutes = ["forgot", "reset", "verify", "account"];
|
|
33
43
|
let linkToken = "";
|
|
34
44
|
let verificationConfirmed = false;
|
|
@@ -74,21 +84,49 @@ function banner() {
|
|
|
74
84
|
return `<p role="${role}" class="${kind}">${escape(message)}${finish}</p>`;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
// One navigation
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
87
|
+
// One navigation, in the app's own bar: past its mark and its name, past a
|
|
88
|
+
// rule, the way Fidj's console carries its own. It used to sit in the page
|
|
89
|
+
// under a bar that held only a name, which read as two headers for one app.
|
|
90
|
+
//
|
|
91
|
+
// Two tabs, because what this app holds about somebody and what they can do
|
|
92
|
+
// about it are one subject. Split across "My privacy" and "My account" it asked
|
|
93
|
+
// a person to look in two places for one answer — and the account they were
|
|
94
|
+
// looking for was the one named right here, which is why the tab says it.
|
|
95
|
+
//
|
|
96
|
+
// Signing out is not a place, so it is not a tab: it is a thing you do to an
|
|
97
|
+
// account, and it lives on that account's screen.
|
|
98
|
+
function appNav(current: "content" | "account") {
|
|
81
99
|
const tab = (id: string, label: string, selected: boolean) =>
|
|
82
100
|
`<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
|
|
83
|
-
|
|
101
|
+
const account = signedIn
|
|
102
|
+
? tab(
|
|
103
|
+
"account-tab",
|
|
104
|
+
accountEmail ? `Account (${escape(accountEmail)})` : "Account",
|
|
105
|
+
current === "account",
|
|
106
|
+
)
|
|
107
|
+
: tab("account-tab", "Sign in", false);
|
|
108
|
+
return tab("content-tab", "Content", current === "content") + account;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The bar belongs to the document, not to the screen being drawn: it survives
|
|
112
|
+
// every route change, so it is filled rather than rebuilt with the page.
|
|
113
|
+
function renderNav(current: "content" | "account") {
|
|
114
|
+
const nav = element("app-nav");
|
|
115
|
+
if (!nav) return;
|
|
116
|
+
nav.innerHTML = appNav(current);
|
|
117
|
+
nav.hidden = false;
|
|
118
|
+
wireNav();
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
function wireNav() {
|
|
87
122
|
element("content-tab")?.addEventListener("click", () => navigate("content"));
|
|
88
|
-
element("account-tab")?.addEventListener("click", () =>
|
|
89
|
-
|
|
90
|
-
navigate(signedIn ? "privacy" : "signin"),
|
|
123
|
+
element("account-tab")?.addEventListener("click", () =>
|
|
124
|
+
navigate(signedIn ? "account" : "signin"),
|
|
91
125
|
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Leaving this app's session, from the screen that is about this app's session.
|
|
129
|
+
function wireSignOut() {
|
|
92
130
|
element("exit")?.addEventListener(
|
|
93
131
|
"click",
|
|
94
132
|
() =>
|
|
@@ -153,7 +191,8 @@ async function refresh() {
|
|
|
153
191
|
// The ID token of a code flow carries only the subject, by design, so the
|
|
154
192
|
// address the entry can offer next time comes from the membership the app
|
|
155
193
|
// just read — not from a claim it does not have.
|
|
156
|
-
|
|
194
|
+
accountEmail = String(me?.poc?.email || me?.username || "");
|
|
195
|
+
rememberSignIn(config.appId, accountEmail);
|
|
157
196
|
}
|
|
158
197
|
async function action(task: () => Promise<void>) {
|
|
159
198
|
if (busy) return;
|
|
@@ -164,7 +203,13 @@ async function action(task: () => Promise<void>) {
|
|
|
164
203
|
.forEach((control) => {
|
|
165
204
|
control.disabled = true;
|
|
166
205
|
});
|
|
167
|
-
|
|
206
|
+
// Whichever button was pressed, when the caller marked it. The entry has two
|
|
207
|
+
// submit buttons now and only one of them is `.primary` first in the
|
|
208
|
+
// document, so "Please wait…" kept landing on the Fidj door while the person
|
|
209
|
+
// watched the button they had actually pressed sit there saying nothing.
|
|
210
|
+
const submit =
|
|
211
|
+
root.querySelector<HTMLButtonElement>("button[data-busy]") ||
|
|
212
|
+
root.querySelector<HTMLButtonElement>("button.primary");
|
|
168
213
|
if (submit) submit.textContent = "Please wait…";
|
|
169
214
|
failed = false;
|
|
170
215
|
if (initialized) {
|
|
@@ -211,6 +256,114 @@ async function action(task: () => Promise<void>) {
|
|
|
211
256
|
render();
|
|
212
257
|
}
|
|
213
258
|
}
|
|
259
|
+
// Taking the Fidj door, from inside the click that asked for it.
|
|
260
|
+
//
|
|
261
|
+
// The window has to exist before the authorization URL is fetched — building it
|
|
262
|
+
// costs a discovery round-trip, and a window opened after an await is one the
|
|
263
|
+
// browser no longer attributes to the click, which every popup blocker refuses.
|
|
264
|
+
// So it is opened empty first and sent somewhere second.
|
|
265
|
+
//
|
|
266
|
+
// The page that opens it is the page that finishes the sign-in: the PKCE
|
|
267
|
+
// transaction lives in this window's session storage, and a new window is given
|
|
268
|
+
// a copy of that storage rather than a share of it. The window it opened only
|
|
269
|
+
// carries the provider's answer back.
|
|
270
|
+
// The window this page is waiting on, while it is waiting on it.
|
|
271
|
+
let waitingFor: ProviderWindow | null = null;
|
|
272
|
+
|
|
273
|
+
// What the entry says while that window is open.
|
|
274
|
+
//
|
|
275
|
+
// `action` disables the screen and writes "Please wait…", which is right when
|
|
276
|
+
// the waiting happens here. This waiting happens somewhere else — in a window
|
|
277
|
+
// that can be behind this page, minimised, or on another desktop — and a
|
|
278
|
+
// disabled sentence is then a dead end in front of the only two things worth
|
|
279
|
+
// offering: the way back to that window, and the way out of it.
|
|
280
|
+
function offerTheWindowBack(providerWindow: ProviderWindow) {
|
|
281
|
+
const waiting = root.querySelector<HTMLButtonElement>("button[data-busy]");
|
|
282
|
+
if (!waiting) return;
|
|
283
|
+
waiting.disabled = false;
|
|
284
|
+
waiting.classList.add("is-waiting");
|
|
285
|
+
// It says what is happening rather than what to do, because what to do is
|
|
286
|
+
// happening in the other window. Dropping the accent is the point: this is a
|
|
287
|
+
// state, not the way in. It stays pressable all the same — somebody who has
|
|
288
|
+
// lost that window behind this page needs precisely this to be pressable.
|
|
289
|
+
waiting.textContent = "Connecting with Fidj…";
|
|
290
|
+
waiting.title = "Bring the Fidj window back to the front";
|
|
291
|
+
// It is a submit button, and submitting would open a second window: the press
|
|
292
|
+
// is being borrowed, so its default has to go.
|
|
293
|
+
waiting.onclick = (event) => {
|
|
294
|
+
event.preventDefault();
|
|
295
|
+
providerWindow.focus();
|
|
296
|
+
};
|
|
297
|
+
const cancel = document.createElement("button");
|
|
298
|
+
cancel.type = "button";
|
|
299
|
+
cancel.id = "cancel-provider";
|
|
300
|
+
cancel.className = "quiet";
|
|
301
|
+
cancel.textContent = "Cancel";
|
|
302
|
+
// Closing it is the whole answer: the window resolves to nothing, and the
|
|
303
|
+
// entry is drawn again exactly as it was.
|
|
304
|
+
cancel.onclick = () => {
|
|
305
|
+
cancel.disabled = true;
|
|
306
|
+
providerWindow.giveUp();
|
|
307
|
+
};
|
|
308
|
+
waiting.insertAdjacentElement("afterend", cancel);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function signInThroughProvider(
|
|
312
|
+
trigger: HTMLElement | null,
|
|
313
|
+
options: { silent?: boolean; prompt?: string } = {},
|
|
314
|
+
) {
|
|
315
|
+
if (!oidc) return;
|
|
316
|
+
// Already open: bring that one back rather than start a second conversation
|
|
317
|
+
// with the provider. Pressing the door again is what somebody does when the
|
|
318
|
+
// window is behind the page, and it means "where did it go", not "again".
|
|
319
|
+
if (waitingFor?.isOpen()) {
|
|
320
|
+
waitingFor.focus();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const providerWindow = openProviderWindow();
|
|
324
|
+
trigger?.setAttribute("data-busy", "true");
|
|
325
|
+
void action(async () => {
|
|
326
|
+
let url: string;
|
|
327
|
+
try {
|
|
328
|
+
// Being recognised again is the one thing somebody who has just signed
|
|
329
|
+
// out did not ask for, and skipping this is what let a sign-out be undone
|
|
330
|
+
// by pressing the door again: the provider still knew the browser and
|
|
331
|
+
// answered with a code, no screen at all. Ending the provider session is
|
|
332
|
+
// what should make that impossible, and that call can be refused — so the
|
|
333
|
+
// door asks rather than assumes, until somebody signs in again.
|
|
334
|
+
url = await oidc.beginLogin(
|
|
335
|
+
options.prompt || options.silent || !oidc.signedOutHere()
|
|
336
|
+
? options
|
|
337
|
+
: {...options, prompt: "login"},
|
|
338
|
+
);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
providerWindow?.giveUp();
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
// No window to put it in — blocked, or a browser that would not open one.
|
|
344
|
+
// Leaving this page is the flow this one replaced, and it still works.
|
|
345
|
+
if (!providerWindow) {
|
|
346
|
+
window.location.assign(url);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
providerWindow.show(url);
|
|
350
|
+
waitingFor = providerWindow;
|
|
351
|
+
offerTheWindowBack(providerWindow);
|
|
352
|
+
const callback = await providerWindow.answer().finally(() => {
|
|
353
|
+
waitingFor = null;
|
|
354
|
+
});
|
|
355
|
+
// Closed by hand. That is an answer too, and the entry simply comes back as
|
|
356
|
+
// it was — a person who changed their mind is not owed an error.
|
|
357
|
+
if (!callback) return;
|
|
358
|
+
await oidc.completeLogin(callback);
|
|
359
|
+
try {
|
|
360
|
+
sessionStorage.removeItem("fidj.interaction.email");
|
|
361
|
+
} catch {}
|
|
362
|
+
await refresh();
|
|
363
|
+
anonymous = false;
|
|
364
|
+
navigate("content");
|
|
365
|
+
});
|
|
366
|
+
}
|
|
214
367
|
// A person moving between screens is making history, so push an entry: Back has
|
|
215
368
|
// to return to the screen before, not to whatever preceded the application.
|
|
216
369
|
// Replacing is right everywhere else in this file — stripping a single-use token
|
|
@@ -318,17 +471,22 @@ function render() {
|
|
|
318
471
|
interactionScreen();
|
|
319
472
|
return;
|
|
320
473
|
}
|
|
321
|
-
if (!initialized) {
|
|
322
|
-
root.innerHTML = '<p role="status">Loading your session…</p>';
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
474
|
// Which of its routes need a session is the mounted app's business, not the
|
|
326
|
-
// shell's: Fidj's own console serves /pub to anyone
|
|
327
|
-
//
|
|
475
|
+
// shell's: Fidj's own console serves /pub to anyone, and every app that needs
|
|
476
|
+
// one revalidates it for itself. So any address the shell does not own is
|
|
477
|
+
// handed over as it stands — before the shell asks the API who this is, not
|
|
478
|
+
// after. Waiting cost several seconds of "Loading your session…" on an address
|
|
479
|
+
// the shell was never going to draw: a token refresh and three round trips ran
|
|
480
|
+
// to completion before a quarter of a megabyte of application even began
|
|
481
|
+
// downloading. Nothing in that answer decides anything here.
|
|
328
482
|
if (moduleRoute()) {
|
|
329
483
|
startModule();
|
|
330
484
|
return;
|
|
331
485
|
}
|
|
486
|
+
if (!initialized) {
|
|
487
|
+
root.innerHTML = '<p role="status">Loading your session…</p>';
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
332
490
|
let route = currentRoute();
|
|
333
491
|
if (
|
|
334
492
|
!signedIn &&
|
|
@@ -338,7 +496,10 @@ function render() {
|
|
|
338
496
|
route = "signin";
|
|
339
497
|
else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
|
|
340
498
|
route = "content";
|
|
341
|
-
|
|
499
|
+
// The privacy screen and the account screen became one. The address that
|
|
500
|
+
// named the first still means something to anyone who bookmarked it, so it
|
|
501
|
+
// arrives where that screen went rather than nowhere.
|
|
502
|
+
if (route === "privacy") route = signedIn ? "account" : "signin";
|
|
342
503
|
window.history.replaceState(null, "", "#/" + route);
|
|
343
504
|
// My account is a signed-in screen and keeps the app's chrome. Recovery and
|
|
344
505
|
// verification are reached without a session, so they stand alone.
|
|
@@ -348,14 +509,29 @@ function render() {
|
|
|
348
509
|
"signin-view",
|
|
349
510
|
route === "signin" || standaloneAccount,
|
|
350
511
|
);
|
|
512
|
+
// The bar belongs to the document and outlives the screen, so it has to be
|
|
513
|
+
// emptied rather than left standing: a sign-out that only redrew the page
|
|
514
|
+
// left the address of the person who had just gone still written across the
|
|
515
|
+
// top — hidden on the entry, and still in the document for anything that
|
|
516
|
+
// reads it. The screens that want it fill it again a moment later.
|
|
517
|
+
const bar = element("app-nav");
|
|
518
|
+
if (bar) {
|
|
519
|
+
bar.innerHTML = "";
|
|
520
|
+
bar.hidden = true;
|
|
521
|
+
}
|
|
351
522
|
if (standaloneAccount) {
|
|
352
523
|
renderAccount(route);
|
|
353
524
|
return;
|
|
354
525
|
}
|
|
355
526
|
if (route === "account") {
|
|
356
|
-
|
|
357
|
-
|
|
527
|
+
// The two ways out, last and together: back to the app, or out of this
|
|
528
|
+
// session. Leaving the app itself is a different decision and stays where
|
|
529
|
+
// the things it erases are listed.
|
|
530
|
+
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>`;
|
|
531
|
+
renderNav("account");
|
|
358
532
|
wireAccount("account");
|
|
533
|
+
wireSignOut();
|
|
534
|
+
wirePrivacy();
|
|
359
535
|
return;
|
|
360
536
|
}
|
|
361
537
|
if (route === "content" && config.moduleEntry) {
|
|
@@ -364,42 +540,18 @@ function render() {
|
|
|
364
540
|
return;
|
|
365
541
|
}
|
|
366
542
|
if (route === "content") {
|
|
367
|
-
root.innerHTML =
|
|
368
|
-
|
|
543
|
+
root.innerHTML = element<HTMLTemplateElement>("public-content")!.innerHTML;
|
|
544
|
+
renderNav("content");
|
|
369
545
|
return;
|
|
370
546
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
${
|
|
374
|
-
route === "signin"
|
|
375
|
-
? `<div class="signin-intro${config.highlights?.length ? "" : " is-plain"}"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
|
|
547
|
+
// Only the entry reaches here now: the privacy screen and the account
|
|
548
|
+
// screen became one, and that one is drawn above.
|
|
549
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
|
|
376
550
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
377
551
|
${highlights()}</div>
|
|
378
552
|
<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>${agreementMarkup()}<button class="primary" type="submit">Continue</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>` : ""}
|
|
379
553
|
<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>
|
|
380
|
-
${badges()}</div
|
|
381
|
-
: `
|
|
382
|
-
<h2>My privacy in ${escape(config.title)}</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
|
|
383
|
-
<p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
|
|
384
|
-
<p>Service agreement: ${consent.terms ? "Accepted" : "Not recorded"}. ${consent.terms ? "Leaving withdraws this agreement." : 'This generated example uses a demo agreement. <button id="terms">Accept demo agreement</button>'}</p>
|
|
385
|
-
${["analytics", "communications", "optionalData"].map((key, i) => `<label class="toggle"><span>${["Analytics", "Communications", "Optional data"][i]}</span><input type="checkbox" data-purpose="${key}" ${consent[key] ? "checked" : ""}></label>`).join("")}
|
|
386
|
-
<h3>Consent history</h3>${
|
|
387
|
-
history.length
|
|
388
|
-
? history
|
|
389
|
-
.slice()
|
|
390
|
-
.reverse()
|
|
391
|
-
.map(
|
|
392
|
-
(entry) =>
|
|
393
|
-
`<p>${escape(entry.type)} · ${entry.granted ? "Accepted" : "Withdrawn"} · ${escape(entry.changedAt)}</p>`,
|
|
394
|
-
)
|
|
395
|
-
.join("")
|
|
396
|
-
: "<p>No changes yet.</p>"
|
|
397
|
-
}
|
|
398
|
-
<button id="export">Export my app data</button>
|
|
399
|
-
<p>This app stores its session in this browser. The export covers Fidj-held records for this membership. There is no separate app database in this static template.</p>
|
|
400
|
-
${roles.includes("Owner") ? "<p>Resolve app ownership before leaving.</p>" : leaving ? '<p>Confirm departure: your membership and its Fidj-held data will be removed. Your other apps remain available.</p><button id="confirm-leave" class="danger">Confirm leaving this app</button><button id="cancel-leave">Keep my membership</button>' : '<button id="leave" class="danger">Leave this app</button>'}
|
|
401
|
-
<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>`
|
|
402
|
-
}</section>`;
|
|
554
|
+
${badges()}</div></section>`;
|
|
403
555
|
wireNav();
|
|
404
556
|
element("reveal")?.addEventListener("click", () => {
|
|
405
557
|
const field = element<HTMLInputElement>("password");
|
|
@@ -425,42 +577,18 @@ function render() {
|
|
|
425
577
|
element("signin")!.innerHTML = providerEntry(
|
|
426
578
|
config.title,
|
|
427
579
|
config.appId,
|
|
428
|
-
config.
|
|
580
|
+
config.signin === "button" ? "" : credentialFields(),
|
|
429
581
|
isFidjItself,
|
|
582
|
+
config.signin as SigninShape,
|
|
430
583
|
);
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// nobody was signed in — including the recovery screens, so a person opening
|
|
440
|
-
// a password-reset link was handed a sign-in form instead of the reset they
|
|
441
|
-
// had asked for, and could never finish.
|
|
442
|
-
element("signin") &&
|
|
443
|
-
!askedProvider &&
|
|
444
|
-
!interactionId &&
|
|
445
|
-
!signedIn &&
|
|
446
|
-
// Nor when the shell has something to say. A password reset ends on this
|
|
447
|
-
// screen with "your password has been changed"; leaving for the provider
|
|
448
|
-
// would swallow the one confirmation the person was waiting for.
|
|
449
|
-
!message
|
|
450
|
-
) {
|
|
451
|
-
askedProvider = true;
|
|
452
|
-
void (async () => {
|
|
453
|
-
if (!(await providerRendersHere())) return;
|
|
454
|
-
// Being recognised again is the one thing somebody who just signed out did
|
|
455
|
-
// not ask for, and this shortcut is what made a sign-out undoable by a page
|
|
456
|
-
// reload. Ending the provider session is what should make that impossible,
|
|
457
|
-
// and that call can be refused — so here the screen asks rather than
|
|
458
|
-
// assumes, until somebody signs in again.
|
|
459
|
-
window.location.assign(
|
|
460
|
-
await oidc.beginLogin(oidc.signedOutHere() ? { prompt: "login" } : {}),
|
|
461
|
-
);
|
|
462
|
-
})();
|
|
463
|
-
}
|
|
584
|
+
// The app's own form, for whoever came to type a password. Folded away rather
|
|
585
|
+
// than removed: the door above it is the one to take, and the person who
|
|
586
|
+
// wants this one is one click from it.
|
|
587
|
+
if (emailEntryOpen) showEmailEntry(true);
|
|
588
|
+
element("use-email")?.addEventListener("click", () => {
|
|
589
|
+
emailEntryOpen = !emailEntryOpen;
|
|
590
|
+
showEmailEntry(emailEntryOpen, true);
|
|
591
|
+
});
|
|
464
592
|
|
|
465
593
|
// Forgetting the address it remembered and handing the person back to a
|
|
466
594
|
// session it never ended would recognise them again: the offer has to reach
|
|
@@ -468,12 +596,10 @@ function render() {
|
|
|
468
596
|
// is how it says so, and the provider reads it as this person saying they are
|
|
469
597
|
// not the one it knows — it ends that session when it hands the screen over,
|
|
470
598
|
// so walking away from the form does not hand the old face back.
|
|
471
|
-
element("forget-hint")?.addEventListener("click", () => {
|
|
599
|
+
element("forget-hint")?.addEventListener("click", (event) => {
|
|
472
600
|
forgetSignIn(config.appId);
|
|
473
601
|
if (oidc) {
|
|
474
|
-
|
|
475
|
-
window.location.assign(await oidc.beginLogin({prompt: "login"}));
|
|
476
|
-
})();
|
|
602
|
+
signInThroughProvider(event.currentTarget as HTMLElement, {prompt: "login"});
|
|
477
603
|
return;
|
|
478
604
|
}
|
|
479
605
|
render();
|
|
@@ -503,19 +629,20 @@ function render() {
|
|
|
503
629
|
render();
|
|
504
630
|
return;
|
|
505
631
|
}
|
|
632
|
+
if (oidc && throughFidj) {
|
|
633
|
+
// "Continue as <them>" promises to carry on as that person, and handed
|
|
634
|
+
// them over to an empty email field — so the promise cost a second
|
|
635
|
+
// typing of the address it had just shown. The screen prefills from
|
|
636
|
+
// this, and nothing was writing it.
|
|
637
|
+
try {
|
|
638
|
+
const remembered = signInHint(config.appId);
|
|
639
|
+
if (remembered) sessionStorage.setItem("fidj.interaction.email", remembered);
|
|
640
|
+
} catch {}
|
|
641
|
+
signInThroughProvider(submitter);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
submitter?.setAttribute("data-busy", "true");
|
|
506
645
|
void action(async () => {
|
|
507
|
-
if (oidc && throughFidj) {
|
|
508
|
-
// "Continue as <them>" promises to carry on as that person, and handed
|
|
509
|
-
// them over to an empty email field — so the promise cost a second
|
|
510
|
-
// typing of the address it had just shown. The screen prefills from
|
|
511
|
-
// this, and nothing was writing it.
|
|
512
|
-
try {
|
|
513
|
-
const remembered = signInHint(config.appId);
|
|
514
|
-
if (remembered) sessionStorage.setItem("fidj.interaction.email", remembered);
|
|
515
|
-
} catch {}
|
|
516
|
-
window.location.assign(await oidc.beginLogin());
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
646
|
if (oidc && (!email || !password)) {
|
|
520
647
|
throw new Error("Enter your email and password, or sign in with Fidj.");
|
|
521
648
|
}
|
|
@@ -532,6 +659,12 @@ function render() {
|
|
|
532
659
|
navigate("content");
|
|
533
660
|
});
|
|
534
661
|
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Everything the account screen lets a person do about what this app holds:
|
|
665
|
+
// the roles it reads, the agreement, the optional choices, the export and
|
|
666
|
+
// the way out of the app itself.
|
|
667
|
+
function wirePrivacy() {
|
|
535
668
|
element("refresh")?.addEventListener("click", () => void action(refresh));
|
|
536
669
|
element("signout")?.addEventListener(
|
|
537
670
|
"click",
|
|
@@ -617,6 +750,33 @@ function render() {
|
|
|
617
750
|
|
|
618
751
|
// The four account screens. My account is shown inside the app; the recovery
|
|
619
752
|
// and verification ones are reached without a session and stand alone.
|
|
753
|
+
// What this app holds about the person signed in, and what they can do
|
|
754
|
+
// about it. It used to be a screen of its own called My privacy, one tab
|
|
755
|
+
// away from the account it was about — so a person looking for their own
|
|
756
|
+
// data had two places to try and no way to tell which.
|
|
757
|
+
function privacyBlock() {
|
|
758
|
+
return `<h2>What ${escape(config.title)} holds</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
|
|
759
|
+
<p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
|
|
760
|
+
<p>Service agreement: ${consent.terms ? "Accepted" : "Not recorded"}. ${consent.terms ? "Leaving withdraws this agreement." : 'This generated example uses a demo agreement. <button id="terms">Accept demo agreement</button>'}</p>
|
|
761
|
+
${["analytics", "communications", "optionalData"].map((key, i) => `<label class="toggle"><span>${["Analytics", "Communications", "Optional data"][i]}</span><input type="checkbox" data-purpose="${key}" ${consent[key] ? "checked" : ""}></label>`).join("")}
|
|
762
|
+
<h3>Consent history</h3>${
|
|
763
|
+
history.length
|
|
764
|
+
? history
|
|
765
|
+
.slice()
|
|
766
|
+
.reverse()
|
|
767
|
+
.map(
|
|
768
|
+
(entry) =>
|
|
769
|
+
`<p>${escape(entry.type)} · ${entry.granted ? "Accepted" : "Withdrawn"} · ${escape(entry.changedAt)}</p>`,
|
|
770
|
+
)
|
|
771
|
+
.join("")
|
|
772
|
+
: "<p>No changes yet.</p>"
|
|
773
|
+
}
|
|
774
|
+
<button id="export">Export my app data</button>
|
|
775
|
+
<p>This app stores its session in this browser. The export covers Fidj-held records for this membership. There is no separate app database in this static template.</p>
|
|
776
|
+
${roles.includes("Owner") ? "<p>Resolve app ownership before leaving.</p>" : leaving ? '<p>Confirm departure: your membership and its Fidj-held data will be removed. Your other apps remain available.</p><button id="confirm-leave" class="danger">Confirm leaving this app</button><button id="cancel-leave">Keep my membership</button>' : '<button id="leave" class="danger">Leave this app</button>'}
|
|
777
|
+
<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>`;
|
|
778
|
+
}
|
|
779
|
+
|
|
620
780
|
function accountForm(route: string) {
|
|
621
781
|
return route === "forgot"
|
|
622
782
|
? `<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>`
|
|
@@ -624,7 +784,7 @@ function accountForm(route: string) {
|
|
|
624
784
|
? `<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>'}`
|
|
625
785
|
: route === "verify"
|
|
626
786
|
? `<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>"}`
|
|
627
|
-
: `<h2>My Fidj account</h2><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
|
|
787
|
+
: `<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>`;
|
|
628
788
|
}
|
|
629
789
|
|
|
630
790
|
|
|
@@ -672,26 +832,13 @@ const refusals: Record<string, string> = {
|
|
|
672
832
|
// Fidj's own front end, told apart by the one fact it already carries: the
|
|
673
833
|
// dashboard it points people to is itself. "Sign in with Fidj" is the right
|
|
674
834
|
// label on an app that is not Fidj; here it names a provider the person is
|
|
675
|
-
// standing in,
|
|
676
|
-
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
async function providerRendersHere() {
|
|
683
|
-
if (signinOnThisUi !== null) return signinOnThisUi;
|
|
684
|
-
try {
|
|
685
|
-
const response = await fetch(
|
|
686
|
-
new URL("status", config.apiEndpoint.replace(/\/?$/, "/")).href,
|
|
687
|
-
{signal: AbortSignal.timeout(5000)},
|
|
688
|
-
);
|
|
689
|
-
signinOnThisUi = response.ok && (await response.json()).signin === "fidj-ui";
|
|
690
|
-
} catch {
|
|
691
|
-
signinOnThisUi = false;
|
|
692
|
-
}
|
|
693
|
-
return signinOnThisUi;
|
|
694
|
-
}
|
|
835
|
+
// standing in, so the door says only "Sign in".
|
|
836
|
+
//
|
|
837
|
+
// It used to hand the person straight to the provider on load, without waiting
|
|
838
|
+
// for a click, because the provider had a page and this screen did not. Now the
|
|
839
|
+
// provider opens in a window instead — and a window nobody asked for is a
|
|
840
|
+
// window the browser blocks. So Fidj's entry waits to be pressed, like every
|
|
841
|
+
// other app's, and the three doors finally behave the same way.
|
|
695
842
|
const isFidjItself = (() => {
|
|
696
843
|
try {
|
|
697
844
|
return new URL(config.dashboardUrl).origin === window.location.origin;
|
|
@@ -733,6 +880,14 @@ async function loadInteraction() {
|
|
|
733
880
|
interaction = (await response.json()) as Interaction;
|
|
734
881
|
}
|
|
735
882
|
|
|
883
|
+
// A window that opened on its own, over the page somebody was on, owes them the
|
|
884
|
+
// way out before it asks for anything. Naming the app they came from is also the
|
|
885
|
+
// only thing on this screen that they can check against what they were doing a
|
|
886
|
+
// second ago — which is exactly what a page asking for a password should offer.
|
|
887
|
+
function returnNotice(asking: string) {
|
|
888
|
+
return `<p class="signin-return" role="note">When you are done, this window closes and takes you back to ${escape(asking)}.</p>`;
|
|
889
|
+
}
|
|
890
|
+
|
|
736
891
|
function interactionScreen() {
|
|
737
892
|
const details = interaction!;
|
|
738
893
|
const asking = escape(details.app.title);
|
|
@@ -755,6 +910,7 @@ function interactionScreen() {
|
|
|
755
910
|
details.prompt === "login"
|
|
756
911
|
? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
|
|
757
912
|
<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>
|
|
913
|
+
${returnNotice(itself ? "Fidj" : asking)}
|
|
758
914
|
${notice}
|
|
759
915
|
<form method="post" action="${escape(action)}" id="interaction">
|
|
760
916
|
<input type="hidden" name="csrf" value="${escape(details.csrf)}">
|
|
@@ -767,6 +923,7 @@ function interactionScreen() {
|
|
|
767
923
|
</form>`
|
|
768
924
|
: `<h2>${itself ? "Continue to Fidj" : "Continue to " + asking}</h2>
|
|
769
925
|
<p class="signin-lead">${itself ? "Fidj is asking for the information below. Optional privacy choices stay separate for every app, including this one." : `${asking} is asking for the information below. Optional privacy choices stay separate, and you can change them in Fidj at any time.`}</p>
|
|
926
|
+
${returnNotice(itself ? "Fidj" : asking)}
|
|
770
927
|
${notice}
|
|
771
928
|
<ul class="scope-list">${details.scopes
|
|
772
929
|
.filter((scope) => scopeMeaning[scope])
|
|
@@ -883,38 +1040,56 @@ function wireAccount(route: string) {
|
|
|
883
1040
|
});
|
|
884
1041
|
});
|
|
885
1042
|
}
|
|
886
|
-
window
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1043
|
+
// This document is the window the entry opened, and the provider has just
|
|
1044
|
+
// answered into it. Its only job left is to hand that answer to the page that
|
|
1045
|
+
// opened it and get out of the way — never to complete the sign-in, because the
|
|
1046
|
+
// transaction the answer has to match belongs to that other page.
|
|
1047
|
+
if (relayProviderAnswer()) {
|
|
1048
|
+
root.innerHTML = '<p role="status">Signing you in…</p>';
|
|
1049
|
+
// A window the browser will not close is not a window that was scripted open,
|
|
1050
|
+
// so this is an ordinary return from the provider after all. Finish it here
|
|
1051
|
+
// rather than leave somebody looking at one sentence forever.
|
|
1052
|
+
window.setTimeout(() => {
|
|
1053
|
+
if (!window.closed) boot();
|
|
1054
|
+
}, 800);
|
|
1055
|
+
} else {
|
|
1056
|
+
boot();
|
|
900
1057
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1058
|
+
|
|
1059
|
+
function boot() {
|
|
1060
|
+
window.addEventListener("hashchange", render);
|
|
1061
|
+
render();
|
|
1062
|
+
if (readInteraction()) {
|
|
1063
|
+
render();
|
|
1064
|
+
void loadInteraction()
|
|
1065
|
+
.catch((error) => {
|
|
1066
|
+
interactionFailed = true;
|
|
1067
|
+
failed = true;
|
|
1068
|
+
message =
|
|
1069
|
+
error instanceof Error
|
|
1070
|
+
? error.message
|
|
1071
|
+
: "This sign-in could not be loaded. Start again from the app.";
|
|
1072
|
+
})
|
|
1073
|
+
.finally(render);
|
|
910
1074
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1075
|
+
void action(async () => {
|
|
1076
|
+
if (interactionId) return;
|
|
1077
|
+
if (oidc && new URL(window.location.href).searchParams.has("state")) {
|
|
1078
|
+
const callback = new URL(window.location.href);
|
|
1079
|
+
window.history.replaceState(null, "", window.location.pathname + "#/content");
|
|
1080
|
+
await oidc.completeLogin(callback);
|
|
1081
|
+
try {
|
|
1082
|
+
sessionStorage.removeItem("fidj.interaction.email");
|
|
1083
|
+
} catch {}
|
|
1084
|
+
}
|
|
1085
|
+
await sdk.init(config.appId, {
|
|
1086
|
+
apiEndpoint: config.apiEndpoint,
|
|
1087
|
+
prod: !config.localDemo,
|
|
1088
|
+
});
|
|
1089
|
+
if (sdk.isLoggedIn()) {
|
|
1090
|
+
await refresh();
|
|
1091
|
+
if (!moduleRoute() && !accountRoutes.includes(currentRoute()))
|
|
1092
|
+
navigate("content");
|
|
1093
|
+
}
|
|
914
1094
|
});
|
|
915
|
-
|
|
916
|
-
await refresh();
|
|
917
|
-
if (!moduleRoute() && !accountRoutes.includes(currentRoute()))
|
|
918
|
-
navigate("content");
|
|
919
|
-
}
|
|
920
|
-
});
|
|
1095
|
+
}
|