@ofidj/generator-fidj 3.11.0 → 3.14.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 +7 -0
- package/generators/app/templates/typescript/package.json +2 -2
- package/generators/app/templates/typescript/scripts/render-content.mjs +1 -1
- package/generators/app/templates/typescript/server/index.ts +2 -1
- package/generators/app/templates/typescript/src/content.ts +76 -42
- package/generators/app/templates/typescript/src/main.ts +5 -4
- package/lib/scaffold.cjs +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,6 +64,13 @@ generated to reach them. A style change now lands in that package, and this
|
|
|
64
64
|
template is what remains genuinely the generator's: the shells that hold the
|
|
65
65
|
screens, the content app and the Notes app.
|
|
66
66
|
|
|
67
|
+
This is an architecture boundary, not only a packaging detail: never copy a
|
|
68
|
+
credential, signup, recovery or agreement component into a generated output.
|
|
69
|
+
The template may compose a shell and call `@ofidj/entry` after dynamic rendering,
|
|
70
|
+
but the markup rules and browser behavior such as password reveal belong in
|
|
71
|
+
`fidj-entry`. A fix there must reach Fidj, mleweb and newly generated apps
|
|
72
|
+
without maintaining parallel component implementations.
|
|
73
|
+
|
|
67
74
|
The design system is `@ofidj/entry`'s `tokens.css` — every colour, family and
|
|
68
75
|
radius — and its `style.css`, which may not introduce a literal of its own.
|
|
69
76
|
Fonts are self-hosted under `public/fonts` and served from the build manifest,
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"privacy:rehearse": "npm run build && node --test test/server.test.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@ofidj/entry": "^3.
|
|
19
|
-
"@ofidj/node": "^3.
|
|
18
|
+
"@ofidj/entry": "^3.14.0",
|
|
19
|
+
"@ofidj/node": "^3.14.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
|
@@ -32,6 +32,6 @@ function mountedHead(config) {
|
|
|
32
32
|
export function renderContent(config) {
|
|
33
33
|
// HTML is supplied by the developer at generation time, never by an app visitor.
|
|
34
34
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escape(config.title)}</title><link rel="icon" href="${escape(config.favicon)}"><link rel="stylesheet" href="./main.css">${mountedHead(config)}</head><body>
|
|
35
|
-
<header class="topbar"><a class="brand" href="#/content"><img src="${escape(config.logo)}" alt="">${escape(config.title)}</a><nav class="content-nav" id="app-nav" aria-label="App navigation" hidden></nav></header>
|
|
35
|
+
<header class="topbar"><a class="brand" href="#/content"><img src="${escape(config.logo)}" alt=""><span class="brand-name">${escape(config.title)}</span></a><nav class="content-nav" id="app-nav" aria-label="App navigation" hidden></nav></header>
|
|
36
36
|
<main><template id="public-content"><section class="card public-content"><h1>${escape(config.welcome)}</h1><div>${config.content}</div></section></template><div id="app" aria-live="polite"></div><footer>Built with Fidj · Your choices belong to this app.</footer></main><script type="module" src="./main.js"></script></body></html>`;
|
|
37
37
|
}
|
|
@@ -342,7 +342,8 @@ if (require.main === module) {
|
|
|
342
342
|
localDemo:
|
|
343
343
|
process.env.LOCAL_DEMO === "true" &&
|
|
344
344
|
host === "127.0.0.1" &&
|
|
345
|
-
["localhost", "127.0.0.1"].includes(api.hostname)
|
|
345
|
+
(["localhost", "127.0.0.1"].includes(api.hostname) ||
|
|
346
|
+
api.hostname.endsWith(".localhost")),
|
|
346
347
|
};
|
|
347
348
|
createApp(settings, {
|
|
348
349
|
dataDir: process.env.FIDJ_DATA_DIR,
|
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
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
3
|
import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "@ofidj/entry/window";
|
|
4
4
|
import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
|
|
5
5
|
import config from "../app.config.json";
|
|
@@ -45,7 +45,7 @@ let accountEmail = "";
|
|
|
45
45
|
// with the agreement unchecked folded the form away and left the complaint
|
|
46
46
|
// floating above a door the person could no longer see.
|
|
47
47
|
let emailEntryOpen = false;
|
|
48
|
-
const accountRoutes = ["forgot", "reset", "verify", "
|
|
48
|
+
const accountRoutes = ["forgot", "reset", "verify", "profile"];
|
|
49
49
|
let linkToken = "";
|
|
50
50
|
let verificationConfirmed = false;
|
|
51
51
|
function currentRoute() {
|
|
@@ -88,17 +88,23 @@ function banner() {
|
|
|
88
88
|
// account, and it lives on that account's screen.
|
|
89
89
|
function appNav(current: "content" | "account") {
|
|
90
90
|
const tab = (id: string, label: string, selected: boolean) =>
|
|
91
|
-
`<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
|
|
91
|
+
`<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}><span class="tab-label">${label}</span></button>`;
|
|
92
92
|
const account = signedIn
|
|
93
93
|
? tab(
|
|
94
94
|
"account-tab",
|
|
95
|
-
accountEmail ? `
|
|
95
|
+
accountEmail ? `Profile (${escape(accountEmail)})` : "Profile",
|
|
96
96
|
current === "account",
|
|
97
97
|
)
|
|
98
98
|
: tab("account-tab", "Sign in", false);
|
|
99
99
|
return tab("content-tab", "Content", current === "content") + account;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
function profileSummary() {
|
|
103
|
+
const publicUrl = `${config.dashboardUrl}/#/pub/${encodeURIComponent(config.appId)}`;
|
|
104
|
+
const badgeUrl = `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/badge`;
|
|
105
|
+
return `<header class="profile-summary"><div class="profile-summary-copy"><span class="eyebrow">Profile</span><strong>${escape(accountEmail)}</strong><span>${escape(config.title)}</span></div><a class="profile-public" href="${escape(publicUrl)}" target="_blank" rel="noopener"><img src="${escape(badgeUrl)}" alt="${escape(config.title)} public badge" width="133" height="20"></a><button id="exit" class="danger">Sign out</button></header>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
102
108
|
// The bar belongs to the document, not to the screen being drawn: it survives
|
|
103
109
|
// every route change, so it is filled rather than rebuilt with the page.
|
|
104
110
|
function renderNav(current: "content" | "account") {
|
|
@@ -112,7 +118,7 @@ function renderNav(current: "content" | "account") {
|
|
|
112
118
|
function wireNav() {
|
|
113
119
|
element("content-tab")?.addEventListener("click", () => navigate("content"));
|
|
114
120
|
element("account-tab")?.addEventListener("click", () =>
|
|
115
|
-
navigate(signedIn ? "
|
|
121
|
+
navigate(signedIn ? "profile" : "signin"),
|
|
116
122
|
);
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -123,7 +129,10 @@ function wireSignOut() {
|
|
|
123
129
|
() =>
|
|
124
130
|
void action(async () => {
|
|
125
131
|
const wasSignedIn = signedIn;
|
|
126
|
-
if (signedIn)
|
|
132
|
+
if (signedIn) {
|
|
133
|
+
if (oidc?.hasSession()) await oidc.logout();
|
|
134
|
+
else await sdk.logout(true);
|
|
135
|
+
}
|
|
127
136
|
forgetSignIn(config.appId);
|
|
128
137
|
signedIn = false;
|
|
129
138
|
anonymous = false;
|
|
@@ -497,6 +506,8 @@ function render() {
|
|
|
497
506
|
if (interactionId && !addressedInteraction()) {
|
|
498
507
|
interactionId = "";
|
|
499
508
|
interactionError = "";
|
|
509
|
+
interactionResent = false;
|
|
510
|
+
interactionNotYet = false;
|
|
500
511
|
interaction = null;
|
|
501
512
|
interactionFailed = false;
|
|
502
513
|
}
|
|
@@ -552,15 +563,15 @@ function render() {
|
|
|
552
563
|
route = "signin";
|
|
553
564
|
else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
|
|
554
565
|
route = "content";
|
|
555
|
-
// The privacy screen and the
|
|
566
|
+
// The privacy screen and the profile screen became one. The address that
|
|
556
567
|
// named the first still means something to anyone who bookmarked it, so it
|
|
557
568
|
// arrives where that screen went rather than nowhere.
|
|
558
|
-
if (route === "privacy") route = signedIn ? "
|
|
569
|
+
if (route === "privacy") route = signedIn ? "profile" : "signin";
|
|
559
570
|
window.history.replaceState(null, "", "#/" + route);
|
|
560
|
-
//
|
|
571
|
+
// Profile is a signed-in screen and keeps the app's chrome. Recovery and
|
|
561
572
|
// verification are reached without a session, so they stand alone.
|
|
562
573
|
const standaloneAccount =
|
|
563
|
-
accountRoutes.includes(route) && !(route === "
|
|
574
|
+
accountRoutes.includes(route) && !(route === "profile" && signedIn);
|
|
564
575
|
document.body.classList.toggle(
|
|
565
576
|
"signin-view",
|
|
566
577
|
route === "signin" || standaloneAccount,
|
|
@@ -579,11 +590,11 @@ function render() {
|
|
|
579
590
|
renderAccount(route);
|
|
580
591
|
return;
|
|
581
592
|
}
|
|
582
|
-
if (route === "
|
|
593
|
+
if (route === "profile") {
|
|
583
594
|
// The two ways out, last and together: back to the app, or out of this
|
|
584
595
|
// session. Leaving the app itself is a different decision and stays where
|
|
585
596
|
// the things it erases are listed.
|
|
586
|
-
root.innerHTML = `<section class="
|
|
597
|
+
root.innerHTML = `<section class="content-account">${profileSummary()}<div class="card profile-body">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail}, {compact: true})}${privacyBlock()}</div></section>`;
|
|
587
598
|
renderNav("account");
|
|
588
599
|
wireAccount("account");
|
|
589
600
|
wireSignOut();
|
|
@@ -609,18 +620,10 @@ function render() {
|
|
|
609
620
|
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
|
|
610
621
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
611
622
|
${highlightCells(config.highlights)}</div>
|
|
612
|
-
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin"
|
|
623
|
+
<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>` : ""}
|
|
613
624
|
<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>
|
|
614
625
|
${badgeStrip(config.badges)}</div></section>`;
|
|
615
626
|
wireNav();
|
|
616
|
-
element("reveal")?.addEventListener("click", () => {
|
|
617
|
-
const field = element<HTMLInputElement>("password");
|
|
618
|
-
const button = element("reveal");
|
|
619
|
-
if (!field || !button) return;
|
|
620
|
-
const hidden = field.type === "password";
|
|
621
|
-
field.type = hidden ? "text" : "password";
|
|
622
|
-
button.textContent = hidden ? "Hide" : "Show";
|
|
623
|
-
});
|
|
624
627
|
element("anonymous")?.addEventListener("click", () => {
|
|
625
628
|
if (!config.allowAnonymous) return;
|
|
626
629
|
anonymous = true;
|
|
@@ -647,13 +650,14 @@ function render() {
|
|
|
647
650
|
// form's place rather than sitting under it: the credentials were accepted,
|
|
648
651
|
// and what is left is a decision about this app.
|
|
649
652
|
if (pendingAgreement && element("signin")) {
|
|
650
|
-
element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement);
|
|
653
|
+
element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement, `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
|
|
651
654
|
bindAgreementScreen(element<HTMLFormElement>("signin"));
|
|
652
655
|
}
|
|
653
656
|
// The app's own form, for whoever came to type a password. Folded away rather
|
|
654
657
|
// than removed: the door above it is the one to take, and the person who
|
|
655
658
|
// wants this one is one click from it.
|
|
656
659
|
if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
|
|
660
|
+
if (!pendingAgreement && element("signin")) bindPasswordReveal(element("signin")!);
|
|
657
661
|
element("use-email")?.addEventListener("click", () => {
|
|
658
662
|
emailEntryOpen = !emailEntryOpen;
|
|
659
663
|
showEmailEntry(emailEntryOpen, true);
|
|
@@ -817,14 +821,12 @@ function completeSignIn() {
|
|
|
817
821
|
// every app must not be mintable by one that collects its own passwords.
|
|
818
822
|
if (isFidjItself) {
|
|
819
823
|
try {
|
|
820
|
-
await
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
withCredentials: true,
|
|
827
|
-
});
|
|
824
|
+
const transfer = await request("/me/oidc/session-transfer", "POST");
|
|
825
|
+
// The provider cookie belongs to the API origin. A cross-site fetch
|
|
826
|
+
// cannot reliably replace it, so let that origin answer once as the
|
|
827
|
+
// top-level page, consume a one-use ticket, then send us back here.
|
|
828
|
+
window.location.assign(transfer.location);
|
|
829
|
+
return;
|
|
828
830
|
} catch {
|
|
829
831
|
// Being signed in here still worked. The person is inside Fidj; what
|
|
830
832
|
// they lose is being recognised by the next app without typing again,
|
|
@@ -970,14 +972,24 @@ function privacyBlock() {
|
|
|
970
972
|
type Interaction = {
|
|
971
973
|
prompt: string;
|
|
972
974
|
csrf: string;
|
|
975
|
+
// The address this interaction is waiting on, when it is waiting rather than
|
|
976
|
+
// asking. Empty on every other screen.
|
|
977
|
+
awaiting?: string;
|
|
973
978
|
app: { id: string; title: string; description: string };
|
|
974
979
|
scopes: string[];
|
|
975
980
|
termsUri: string;
|
|
976
981
|
privacyUri: string;
|
|
982
|
+
// The agreement's own text, so it can be read here rather than in another tab.
|
|
983
|
+
agreement?: {version: string; text: string} | null;
|
|
977
984
|
action: string;
|
|
978
985
|
};
|
|
979
986
|
let interactionId = "";
|
|
980
987
|
let interactionError = "";
|
|
988
|
+
// Whether the link was just sent again, or pressed Continue before opening it.
|
|
989
|
+
// They are about the wait, not about the person, so they travel in the address
|
|
990
|
+
// where the address itself does not.
|
|
991
|
+
let interactionResent = false;
|
|
992
|
+
let interactionNotYet = false;
|
|
981
993
|
let interaction: Interaction | null = null;
|
|
982
994
|
let interactionFailed = false;
|
|
983
995
|
|
|
@@ -1026,6 +1038,8 @@ function readInteraction() {
|
|
|
1026
1038
|
if (!uid) return false;
|
|
1027
1039
|
interactionId = uid;
|
|
1028
1040
|
interactionError = parameters.get("error") || "";
|
|
1041
|
+
interactionResent = parameters.get("resent") === "1";
|
|
1042
|
+
interactionNotYet = parameters.get("notyet") === "1";
|
|
1029
1043
|
// The id stays in the address while the screen is up. Taking it out looked
|
|
1030
1044
|
// tidier and made the screen a trap: the address became "#/signin", so going
|
|
1031
1045
|
// back to "#/signin" changed nothing, the document never reloaded, and the
|
|
@@ -1049,6 +1063,15 @@ async function loadInteraction() {
|
|
|
1049
1063
|
}
|
|
1050
1064
|
|
|
1051
1065
|
|
|
1066
|
+
// What the wait has to say beyond the address, if anything.
|
|
1067
|
+
function interactionWaitNotice() {
|
|
1068
|
+
if (interactionNotYet)
|
|
1069
|
+
return '<p class="fineprint">The link has not been opened yet. Open it, then press Continue again.</p>';
|
|
1070
|
+
if (interactionResent)
|
|
1071
|
+
return '<p class="fineprint">The link was sent again. Only the newest one works.</p>';
|
|
1072
|
+
return "";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1052
1075
|
function interactionScreen() {
|
|
1053
1076
|
const details = interaction!;
|
|
1054
1077
|
const asking = escape(details.app.title);
|
|
@@ -1067,8 +1090,24 @@ function interactionScreen() {
|
|
|
1067
1090
|
// sees your password" about itself is nonsense in the same family as offering
|
|
1068
1091
|
// to sign in with Fidj on Fidj.
|
|
1069
1092
|
const itself = details.app.id === config.appId;
|
|
1070
|
-
|
|
1071
|
-
|
|
1093
|
+
// The wait, when this interaction is waiting on an address rather than asking
|
|
1094
|
+
// for anything. It is not a refusal and must not be drawn as one: the account
|
|
1095
|
+
// exists, the link was sent, and what is left is opening it. The address is
|
|
1096
|
+
// read from the interaction's own context rather than from the URL, so
|
|
1097
|
+
// nothing about the person travels in an address bar.
|
|
1098
|
+
const body = details.awaiting
|
|
1099
|
+
? `<h2>Check your email</h2>
|
|
1100
|
+
<p class="signin-lead">Your account is created. Waiting for you to open the link sent to <strong>${escape(details.awaiting)}</strong>.</p>
|
|
1101
|
+
${returnNotice(itself ? "Fidj" : asking)}
|
|
1102
|
+
<form method="post" action="${escape(action)}" id="interaction">
|
|
1103
|
+
<input type="hidden" name="csrf" value="${escape(details.csrf)}">
|
|
1104
|
+
<p class="fineprint">The link may take a minute, and it sometimes lands in spam. Open it, then come back here.</p>
|
|
1105
|
+
${interactionWaitNotice()}
|
|
1106
|
+
<button class="primary" type="submit" name="action" value="continue">Continue</button>
|
|
1107
|
+
<button class="secondary" type="submit" name="action" value="resend">Send the link again</button>
|
|
1108
|
+
<button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
|
|
1109
|
+
</form>`
|
|
1110
|
+
: details.prompt === "login"
|
|
1072
1111
|
? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
|
|
1073
1112
|
<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>
|
|
1074
1113
|
${returnNotice(itself ? "Fidj" : asking)}
|
|
@@ -1092,7 +1131,8 @@ function interactionScreen() {
|
|
|
1092
1131
|
.join("")}</ul>
|
|
1093
1132
|
<form method="post" action="${escape(action)}" id="interaction">
|
|
1094
1133
|
<input type="hidden" name="csrf" value="${escape(details.csrf)}">
|
|
1095
|
-
<label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span
|
|
1134
|
+
<label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>${escape(agreementModel(details.app.title, {}).checkboxLabel)}</span></label>
|
|
1135
|
+
${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>` : ""}
|
|
1096
1136
|
${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>` : ""}
|
|
1097
1137
|
<button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
|
|
1098
1138
|
<button class="quiet" type="submit" id="not-me" name="action" value="switch" formnovalidate>Not you? Sign in with another account</button>
|
|
@@ -1106,14 +1146,7 @@ function interactionScreen() {
|
|
|
1106
1146
|
<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>
|
|
1107
1147
|
${badgeStrip(config.badges)}</section>`;
|
|
1108
1148
|
|
|
1109
|
-
element("
|
|
1110
|
-
const field = element<HTMLInputElement>("password");
|
|
1111
|
-
const button = element("reveal");
|
|
1112
|
-
if (!field || !button) return;
|
|
1113
|
-
const hidden = field.type === "password";
|
|
1114
|
-
field.type = hidden ? "text" : "password";
|
|
1115
|
-
button.textContent = hidden ? "Hide" : "Show";
|
|
1116
|
-
});
|
|
1149
|
+
if (element("interaction")) bindPasswordReveal(element("interaction")!);
|
|
1117
1150
|
// Being recognised is the point, and a dead end when the person is not who
|
|
1118
1151
|
// Fidj thinks — a shared computer, a second account, somebody else's tab. So
|
|
1119
1152
|
// the screen that recognises them asks again on request. It goes back to the
|
|
@@ -1141,6 +1174,7 @@ function renderAccount(route: string) {
|
|
|
1141
1174
|
<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>
|
|
1142
1175
|
</div>
|
|
1143
1176
|
<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>`;
|
|
1177
|
+
bindPasswordReveal(root);
|
|
1144
1178
|
wireAccount(route);
|
|
1145
1179
|
}
|
|
1146
1180
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {agreementRequired, agreementFromRefusal, signInErrorMessage, rememberSignIn, forgetSignIn, type SigninShape} from "@ofidj/entry";
|
|
2
|
-
import {acceptedAgreement, agreementScreen, bindAgreementScreen, providerEntry, showEmailEntry, showVersionBadge} from "@ofidj/entry/dom";
|
|
2
|
+
import {acceptedAgreement, agreementScreen, bindAgreementScreen, bindPasswordReveal, providerEntry, showEmailEntry, showVersionBadge} from "@ofidj/entry/dom";
|
|
3
3
|
import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "@ofidj/entry/window";
|
|
4
4
|
import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
|
|
5
5
|
import "@ofidj/entry/style.css";
|
|
@@ -79,7 +79,7 @@ async function api(path: string, method = "GET", data?: unknown) {
|
|
|
79
79
|
// The credential fields, in one place because the entry shows them beside the
|
|
80
80
|
// Fidj door rather than instead of it.
|
|
81
81
|
function credentialFields() {
|
|
82
|
-
return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username"><label for="password">Password</label><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password"><button class="primary" type="submit" name="entry" value="credentials">Continue</button>`;
|
|
82
|
+
return `<label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username"><label for="password">Password</label><div class="password-field"><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password"><button type="button" aria-controls="password">Show</button></div><button class="primary" type="submit" name="entry" value="credentials">Continue</button>`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
async function load() {
|
|
@@ -213,7 +213,7 @@ function render() {
|
|
|
213
213
|
const canWrite = session?.roles.some((role) =>
|
|
214
214
|
["Owner", "Editor"].includes(role),
|
|
215
215
|
);
|
|
216
|
-
root.innerHTML = `<header class="topbar"><a class="brand" href="/"><span class="app-symbol">✳</span>${escape(settings.title)}</a><a class="fidj-brand" href="${escape(settings.dashboardUrl)}" target="_blank" rel="noopener"><img src="/fidj-logo.png" alt="Fidj">Privacy with Fidj ↗</a></header>
|
|
216
|
+
root.innerHTML = `<header class="topbar"><a class="brand" href="/"><span class="app-symbol">✳</span><span class="brand-name">${escape(settings.title)}</span></a><a class="fidj-brand" href="${escape(settings.dashboardUrl)}" target="_blank" rel="noopener"><img src="/fidj-logo.png" alt="Fidj">Privacy with Fidj ↗</a></header>
|
|
217
217
|
<main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
|
|
218
218
|
${
|
|
219
219
|
!session
|
|
@@ -241,11 +241,12 @@ function render() {
|
|
|
241
241
|
<footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
|
|
242
242
|
// The agreement takes the form's place once the API says this app is owed one.
|
|
243
243
|
if (pendingAgreement && el("signin")) {
|
|
244
|
-
el("signin")!.innerHTML = agreementScreen(settings.title, pendingAgreement);
|
|
244
|
+
el("signin")!.innerHTML = agreementScreen(settings.title, pendingAgreement, `${settings.apiEndpoint}/apps/${encodeURIComponent(settings.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
|
|
245
245
|
bindAgreementScreen(el<HTMLFormElement>("signin"));
|
|
246
246
|
}
|
|
247
247
|
// The app's own form, folded away under the Fidj door rather than beside it.
|
|
248
248
|
if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
|
|
249
|
+
if (!pendingAgreement && el("signin")) bindPasswordReveal(el("signin")!);
|
|
249
250
|
el("use-email")?.addEventListener("click", () => {
|
|
250
251
|
emailEntryOpen = !emailEntryOpen;
|
|
251
252
|
showEmailEntry(emailEntryOpen, true);
|
package/lib/scaffold.cjs
CHANGED
|
@@ -81,7 +81,8 @@ function scaffold(destination, options) {
|
|
|
81
81
|
);
|
|
82
82
|
if (
|
|
83
83
|
api.protocol === "http:" &&
|
|
84
|
-
!["localhost", "127.0.0.1", "[::1]"].includes(api.hostname)
|
|
84
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(api.hostname) &&
|
|
85
|
+
!api.hostname.endsWith(".localhost")
|
|
85
86
|
)
|
|
86
87
|
throw new Error("Use HTTPS for a remote API.");
|
|
87
88
|
if (options.oidcIssuer) {
|
|
@@ -198,7 +199,7 @@ function scaffold(destination, options) {
|
|
|
198
199
|
const issuerSetting = options.oidcIssuer ? `FIDJ_OIDC_ISSUER=${options.oidcIssuer}\n` : "";
|
|
199
200
|
const settings = `FIDJ_APP_ID=${options.appId}\nFIDJ_API_ENDPOINT=${api.href.replace(/\/$/, "")}\n${issuerSetting}FIDJ_DASHBOARD_URL=https://fidj.ovh\nAPP_TITLE=${title}\nAPP_VERSION=${releaseVersion}\nFIDJ_SIGNIN=${signin}\nPORT=8200\nHOST=127.0.0.1\nLOCAL_DEMO=false\nFIDJ_DATA_DIR=../.fidj-data/${name}\nFIDJ_PRIVACY_ADAPTER_KEY=\n`;
|
|
200
201
|
const localApi = options.local
|
|
201
|
-
? "http://localhost:3201/v3"
|
|
202
|
+
? process.env.FIDJ_LOCAL_API_ENDPOINT || "http://localhost:3201/v3"
|
|
202
203
|
: api.href.replace(/\/$/, "");
|
|
203
204
|
const configuration = {
|
|
204
205
|
appId: options.appId,
|
|
@@ -209,7 +210,9 @@ function scaffold(destination, options) {
|
|
|
209
210
|
? new URL("/oidc", localApi).href
|
|
210
211
|
: null,
|
|
211
212
|
apiEndpoint: localApi,
|
|
212
|
-
dashboardUrl: options.local
|
|
213
|
+
dashboardUrl: options.local
|
|
214
|
+
? process.env.FIDJ_LOCAL_DASHBOARD_URL || "http://localhost:4200"
|
|
215
|
+
: dashboardFor(api),
|
|
213
216
|
title,
|
|
214
217
|
releaseVersion,
|
|
215
218
|
localDemo: Boolean(options.local),
|