@ofidj/generator-fidj 3.11.0 → 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 +7 -0
- package/generators/app/templates/typescript/package.json +2 -2
- package/generators/app/templates/typescript/server/index.ts +2 -1
- package/generators/app/templates/typescript/src/content.ts +60 -32
- package/generators/app/templates/typescript/src/main.ts +4 -3
- 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.13.0",
|
|
19
|
+
"@ofidj/node": "^3.13.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
|
@@ -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";
|
|
@@ -123,7 +123,10 @@ function wireSignOut() {
|
|
|
123
123
|
() =>
|
|
124
124
|
void action(async () => {
|
|
125
125
|
const wasSignedIn = signedIn;
|
|
126
|
-
if (signedIn)
|
|
126
|
+
if (signedIn) {
|
|
127
|
+
if (oidc?.hasSession()) await oidc.logout();
|
|
128
|
+
else await sdk.logout(true);
|
|
129
|
+
}
|
|
127
130
|
forgetSignIn(config.appId);
|
|
128
131
|
signedIn = false;
|
|
129
132
|
anonymous = false;
|
|
@@ -497,6 +500,8 @@ function render() {
|
|
|
497
500
|
if (interactionId && !addressedInteraction()) {
|
|
498
501
|
interactionId = "";
|
|
499
502
|
interactionError = "";
|
|
503
|
+
interactionResent = false;
|
|
504
|
+
interactionNotYet = false;
|
|
500
505
|
interaction = null;
|
|
501
506
|
interactionFailed = false;
|
|
502
507
|
}
|
|
@@ -609,18 +614,10 @@ function render() {
|
|
|
609
614
|
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
|
|
610
615
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
611
616
|
${highlightCells(config.highlights)}</div>
|
|
612
|
-
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin"
|
|
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>` : ""}
|
|
613
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>
|
|
614
619
|
${badgeStrip(config.badges)}</div></section>`;
|
|
615
620
|
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
621
|
element("anonymous")?.addEventListener("click", () => {
|
|
625
622
|
if (!config.allowAnonymous) return;
|
|
626
623
|
anonymous = true;
|
|
@@ -647,13 +644,14 @@ function render() {
|
|
|
647
644
|
// form's place rather than sitting under it: the credentials were accepted,
|
|
648
645
|
// and what is left is a decision about this app.
|
|
649
646
|
if (pendingAgreement && element("signin")) {
|
|
650
|
-
element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement);
|
|
647
|
+
element("signin")!.innerHTML = agreementScreen(config.title, pendingAgreement, `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/agreements/${encodeURIComponent(pendingAgreement.version || "")}`);
|
|
651
648
|
bindAgreementScreen(element<HTMLFormElement>("signin"));
|
|
652
649
|
}
|
|
653
650
|
// The app's own form, for whoever came to type a password. Folded away rather
|
|
654
651
|
// than removed: the door above it is the one to take, and the person who
|
|
655
652
|
// wants this one is one click from it.
|
|
656
653
|
if (!pendingAgreement && emailEntryOpen) showEmailEntry(true);
|
|
654
|
+
if (!pendingAgreement && element("signin")) bindPasswordReveal(element("signin")!);
|
|
657
655
|
element("use-email")?.addEventListener("click", () => {
|
|
658
656
|
emailEntryOpen = !emailEntryOpen;
|
|
659
657
|
showEmailEntry(emailEntryOpen, true);
|
|
@@ -817,14 +815,12 @@ function completeSignIn() {
|
|
|
817
815
|
// every app must not be mintable by one that collects its own passwords.
|
|
818
816
|
if (isFidjItself) {
|
|
819
817
|
try {
|
|
820
|
-
await
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
withCredentials: true,
|
|
827
|
-
});
|
|
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;
|
|
828
824
|
} catch {
|
|
829
825
|
// Being signed in here still worked. The person is inside Fidj; what
|
|
830
826
|
// they lose is being recognised by the next app without typing again,
|
|
@@ -970,14 +966,24 @@ function privacyBlock() {
|
|
|
970
966
|
type Interaction = {
|
|
971
967
|
prompt: string;
|
|
972
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;
|
|
973
972
|
app: { id: string; title: string; description: string };
|
|
974
973
|
scopes: string[];
|
|
975
974
|
termsUri: string;
|
|
976
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;
|
|
977
978
|
action: string;
|
|
978
979
|
};
|
|
979
980
|
let interactionId = "";
|
|
980
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;
|
|
981
987
|
let interaction: Interaction | null = null;
|
|
982
988
|
let interactionFailed = false;
|
|
983
989
|
|
|
@@ -1026,6 +1032,8 @@ function readInteraction() {
|
|
|
1026
1032
|
if (!uid) return false;
|
|
1027
1033
|
interactionId = uid;
|
|
1028
1034
|
interactionError = parameters.get("error") || "";
|
|
1035
|
+
interactionResent = parameters.get("resent") === "1";
|
|
1036
|
+
interactionNotYet = parameters.get("notyet") === "1";
|
|
1029
1037
|
// The id stays in the address while the screen is up. Taking it out looked
|
|
1030
1038
|
// tidier and made the screen a trap: the address became "#/signin", so going
|
|
1031
1039
|
// back to "#/signin" changed nothing, the document never reloaded, and the
|
|
@@ -1049,6 +1057,15 @@ async function loadInteraction() {
|
|
|
1049
1057
|
}
|
|
1050
1058
|
|
|
1051
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 "";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1052
1069
|
function interactionScreen() {
|
|
1053
1070
|
const details = interaction!;
|
|
1054
1071
|
const asking = escape(details.app.title);
|
|
@@ -1067,8 +1084,24 @@ function interactionScreen() {
|
|
|
1067
1084
|
// sees your password" about itself is nonsense in the same family as offering
|
|
1068
1085
|
// to sign in with Fidj on Fidj.
|
|
1069
1086
|
const itself = details.app.id === config.appId;
|
|
1070
|
-
|
|
1071
|
-
|
|
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"
|
|
1072
1105
|
? `<h2>${itself ? "Sign in to Fidj" : "Sign in to continue to " + asking}</h2>
|
|
1073
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>
|
|
1074
1107
|
${returnNotice(itself ? "Fidj" : asking)}
|
|
@@ -1092,7 +1125,8 @@ function interactionScreen() {
|
|
|
1092
1125
|
.join("")}</ul>
|
|
1093
1126
|
<form method="post" action="${escape(action)}" id="interaction">
|
|
1094
1127
|
<input type="hidden" name="csrf" value="${escape(details.csrf)}">
|
|
1095
|
-
<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>` : ""}
|
|
1096
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>` : ""}
|
|
1097
1131
|
<button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
|
|
1098
1132
|
<button class="quiet" type="submit" id="not-me" name="action" value="switch" formnovalidate>Not you? Sign in with another account</button>
|
|
@@ -1106,14 +1140,7 @@ function interactionScreen() {
|
|
|
1106
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>
|
|
1107
1141
|
${badgeStrip(config.badges)}</section>`;
|
|
1108
1142
|
|
|
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
|
-
});
|
|
1143
|
+
if (element("interaction")) bindPasswordReveal(element("interaction")!);
|
|
1117
1144
|
// Being recognised is the point, and a dead end when the person is not who
|
|
1118
1145
|
// Fidj thinks — a shared computer, a second account, somebody else's tab. So
|
|
1119
1146
|
// the screen that recognises them asks again on request. It goes back to the
|
|
@@ -1141,6 +1168,7 @@ function renderAccount(route: string) {
|
|
|
1141
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>
|
|
1142
1169
|
</div>
|
|
1143
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);
|
|
1144
1172
|
wireAccount(route);
|
|
1145
1173
|
}
|
|
1146
1174
|
|
|
@@ -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() {
|
|
@@ -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),
|