@ofidj/generator-fidj 3.13.0 → 3.15.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 +4 -4
- 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/src/content.ts +69 -18
- package/generators/app/templates/typescript/src/main.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ one.
|
|
|
40
40
|
|
|
41
41
|
> Maintainers: `bin/create-fidj.cjs` is the same scaffolder without Yeoman, for
|
|
42
42
|
> tooling that runs from a checkout and cannot install `yo` —
|
|
43
|
-
> `scripts/local-stack.py` and `fidj-app`'s `create:local` use it.
|
|
43
|
+
> fidj-brain's `scripts/local-stack.py` and `fidj-app`'s `create:local` use it.
|
|
44
44
|
> `test/parity.test.cjs` fails if either door ever gains an input the other
|
|
45
45
|
> lacks. It is not an entry point to hand to anyone building an app.
|
|
46
46
|
|
|
@@ -87,14 +87,14 @@ build when one is supplied, the template's `@ofidj/node` range otherwise — and
|
|
|
87
87
|
written to `APP_VERSION` and `app.config.json`. On Fidj's own apps it also shows
|
|
88
88
|
the version `/v3/status` answers with, so the two can be compared without
|
|
89
89
|
translation. Keep that range on the version this generator releases: the
|
|
90
|
-
workspace's `scripts/check-versions.py` and this package's own suite both refuse
|
|
90
|
+
workspace's `scripts/check-versions.py` (in fidj-brain) and this package's own suite both refuse
|
|
91
91
|
the mismatch.
|
|
92
92
|
|
|
93
93
|
## Real validation repository
|
|
94
94
|
|
|
95
95
|
[mleweb](https://github.com/mlefree/mleweb) is the thin command-line validation fixture whose generated website is mlefree.com. Its `package.json` passes the original Mario GIF, welcome text and About/CV/contact links directly into this generator. No custom downstream renderer is required. GitHub CI runs the same generation/build path on Node 22/24 and uploads artifacts. Publishing the site to gh-pages, which is what mlefree.com serves, is a separate deliberate job: it runs on mleweb's `master` or on an explicit workflow dispatch, never from a version branch.
|
|
96
96
|
|
|
97
|
-
Run `npm test` for scaffolding/CLI safety tests. Generated projects include TypeScript checking and HTTP integration tests for live authorization and privacy isolation. See the [local acceptance walkthrough](../LOCAL-DEVELOPMENT.md). Follow TDD: add/run a failing generator or generated-app test before implementation, make it green, then refactor and rerun regression checks.
|
|
97
|
+
Run `npm test` for scaffolding/CLI safety tests. Generated projects include TypeScript checking and HTTP integration tests for live authorization and privacy isolation. See the [local acceptance walkthrough](../fidj-brain/LOCAL-DEVELOPMENT.md). Follow TDD: add/run a failing generator or generated-app test before implementation, make it green, then refactor and rerun regression checks.
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
## Releasing to npm
|
|
@@ -178,7 +178,7 @@ A compatible issuer, REST API and registered callback without a fragment are pre
|
|
|
178
178
|
|
|
179
179
|
## Required service agreement
|
|
180
180
|
|
|
181
|
-
[The workspace README](../README.md#entry-one-flow-the-same-everywhere) defines
|
|
181
|
+
[The workspace README](../fidj-brain/README.md#entry-one-flow-the-same-everywhere) defines
|
|
182
182
|
the flow every Fidj sign-in surface follows, and this generator owns the
|
|
183
183
|
implementation the others render. In short: the first screen asks for an email,
|
|
184
184
|
a password, **Sign in** and **Create an account**, and gates neither; creating an
|
|
@@ -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.15.0",
|
|
19
|
+
"@ofidj/node": "^3.15.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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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";
|
|
2
|
+
import {agreementScreen, bindAgreementScreen, bindPasswordReveal, acceptedAgreement, verificationWait, providerEntry, showEmailEntry, showVersionBadge, escape, masthead, highlightCells, badgeStrip, credentialFields, accountForm, returnNotice, passkeySupported, passkeyAssertion, walletDoor} 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";
|
|
@@ -10,6 +10,11 @@ const oidc = config.oidcIssuer ? new FidjOidcClient({issuer: config.oidcIssuer,
|
|
|
10
10
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
|
11
11
|
showVersionBadge(config.releaseVersion, config.title === "Fidj" ? config.apiEndpoint : undefined);
|
|
12
12
|
const appPath = `/me/apps/${encodeURIComponent(config.appId)}`;
|
|
13
|
+
// Passkeys (v3 P1-4): one relying party, Fidj's own domain. Only Fidj's own
|
|
14
|
+
// shell runs the ceremony on its page; every other app reaches the passkey
|
|
15
|
+
// through the Fidj window, like the rest of Fidj's sign-in.
|
|
16
|
+
const passkeyHere = config.title === "Fidj" && passkeySupported();
|
|
17
|
+
let signedInWithPasskey = false;
|
|
13
18
|
let signedIn = false;
|
|
14
19
|
let emailVerified = false;
|
|
15
20
|
let anonymous = false;
|
|
@@ -45,7 +50,7 @@ let accountEmail = "";
|
|
|
45
50
|
// with the agreement unchecked folded the form away and left the complaint
|
|
46
51
|
// floating above a door the person could no longer see.
|
|
47
52
|
let emailEntryOpen = false;
|
|
48
|
-
const accountRoutes = ["forgot", "reset", "verify", "
|
|
53
|
+
const accountRoutes = ["forgot", "reset", "verify", "profile"];
|
|
49
54
|
let linkToken = "";
|
|
50
55
|
let verificationConfirmed = false;
|
|
51
56
|
function currentRoute() {
|
|
@@ -88,17 +93,23 @@ function banner() {
|
|
|
88
93
|
// account, and it lives on that account's screen.
|
|
89
94
|
function appNav(current: "content" | "account") {
|
|
90
95
|
const tab = (id: string, label: string, selected: boolean) =>
|
|
91
|
-
`<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
|
|
96
|
+
`<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}><span class="tab-label">${label}</span></button>`;
|
|
92
97
|
const account = signedIn
|
|
93
98
|
? tab(
|
|
94
99
|
"account-tab",
|
|
95
|
-
accountEmail ? `
|
|
100
|
+
accountEmail ? `Profile (${escape(accountEmail)})` : "Profile",
|
|
96
101
|
current === "account",
|
|
97
102
|
)
|
|
98
103
|
: tab("account-tab", "Sign in", false);
|
|
99
104
|
return tab("content-tab", "Content", current === "content") + account;
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
function profileSummary() {
|
|
108
|
+
const publicUrl = `${config.dashboardUrl}/#/pub/${encodeURIComponent(config.appId)}`;
|
|
109
|
+
const badgeUrl = `${config.apiEndpoint}/apps/${encodeURIComponent(config.appId)}/badge`;
|
|
110
|
+
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>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
// The bar belongs to the document, not to the screen being drawn: it survives
|
|
103
114
|
// every route change, so it is filled rather than rebuilt with the page.
|
|
104
115
|
function renderNav(current: "content" | "account") {
|
|
@@ -112,7 +123,7 @@ function renderNav(current: "content" | "account") {
|
|
|
112
123
|
function wireNav() {
|
|
113
124
|
element("content-tab")?.addEventListener("click", () => navigate("content"));
|
|
114
125
|
element("account-tab")?.addEventListener("click", () =>
|
|
115
|
-
navigate(signedIn ? "
|
|
126
|
+
navigate(signedIn ? "profile" : "signin"),
|
|
116
127
|
);
|
|
117
128
|
}
|
|
118
129
|
|
|
@@ -557,15 +568,15 @@ function render() {
|
|
|
557
568
|
route = "signin";
|
|
558
569
|
else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
|
|
559
570
|
route = "content";
|
|
560
|
-
// The privacy screen and the
|
|
571
|
+
// The privacy screen and the profile screen became one. The address that
|
|
561
572
|
// named the first still means something to anyone who bookmarked it, so it
|
|
562
573
|
// arrives where that screen went rather than nowhere.
|
|
563
|
-
if (route === "privacy") route = signedIn ? "
|
|
574
|
+
if (route === "privacy") route = signedIn ? "profile" : "signin";
|
|
564
575
|
window.history.replaceState(null, "", "#/" + route);
|
|
565
|
-
//
|
|
576
|
+
// Profile is a signed-in screen and keeps the app's chrome. Recovery and
|
|
566
577
|
// verification are reached without a session, so they stand alone.
|
|
567
578
|
const standaloneAccount =
|
|
568
|
-
accountRoutes.includes(route) && !(route === "
|
|
579
|
+
accountRoutes.includes(route) && !(route === "profile" && signedIn);
|
|
569
580
|
document.body.classList.toggle(
|
|
570
581
|
"signin-view",
|
|
571
582
|
route === "signin" || standaloneAccount,
|
|
@@ -584,11 +595,11 @@ function render() {
|
|
|
584
595
|
renderAccount(route);
|
|
585
596
|
return;
|
|
586
597
|
}
|
|
587
|
-
if (route === "
|
|
598
|
+
if (route === "profile") {
|
|
588
599
|
// The two ways out, last and together: back to the app, or out of this
|
|
589
600
|
// session. Leaving the app itself is a different decision and stays where
|
|
590
601
|
// the things it erases are listed.
|
|
591
|
-
root.innerHTML = `<section class="
|
|
602
|
+
root.innerHTML = `<section class="content-account">${profileSummary()}<div class="card profile-body">${banner()}${accountForm("account", {linkToken, verificationConfirmed, emailVerified, accountEmail}, {compact: true})}${privacyBlock()}</div></section>`;
|
|
592
603
|
renderNav("account");
|
|
593
604
|
wireAccount("account");
|
|
594
605
|
wireSignOut();
|
|
@@ -614,8 +625,8 @@ function render() {
|
|
|
614
625
|
root.innerHTML = `<section class="signin-shell"><div class="signin-intro${config.highlights?.length ? "" : " is-plain"}">${masthead(config.logo, config.title)}
|
|
615
626
|
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
616
627
|
${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>` : ""}
|
|
618
|
-
|
|
628
|
+
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin">${credentialFields({email: signInEmail, password: signInPassword}, {passkey: passkeyHere})}</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>` : ""}
|
|
629
|
+
${config.title === "Fidj" ? walletDoor() : `<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>
|
|
619
630
|
${badgeStrip(config.badges)}</div></section>`;
|
|
620
631
|
wireNav();
|
|
621
632
|
element("anonymous")?.addEventListener("click", () => {
|
|
@@ -636,7 +647,7 @@ function render() {
|
|
|
636
647
|
config.appId,
|
|
637
648
|
config.signin === "button"
|
|
638
649
|
? ""
|
|
639
|
-
: credentialFields({email: signInEmail, password: signInPassword}),
|
|
650
|
+
: credentialFields({email: signInEmail, password: signInPassword}, {passkey: passkeyHere}),
|
|
640
651
|
isFidjItself,
|
|
641
652
|
config.signin as SigninShape,
|
|
642
653
|
);
|
|
@@ -712,10 +723,21 @@ function render() {
|
|
|
712
723
|
if (!acceptance) return;
|
|
713
724
|
submitter?.setAttribute("data-busy", "true");
|
|
714
725
|
void action(async () => {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
726
|
+
// A passkey sign-in has no password to send again: the agreement is
|
|
727
|
+
// accepted with a second touch of the same passkey.
|
|
728
|
+
const refused = signedInWithPasskey
|
|
729
|
+
? await refusedBeforePasskey(acceptance)
|
|
730
|
+
: await refusedBeforeSignIn(signInEmail, signInPassword, false, acceptance);
|
|
731
|
+
if (refused) return;
|
|
732
|
+
await completeSignIn();
|
|
733
|
+
});
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// The first door (v3): the passkey, with nothing typed.
|
|
737
|
+
if (submitter?.name === "entry" && submitter.value === "passkey") {
|
|
738
|
+
submitter.setAttribute("data-busy", "true");
|
|
739
|
+
void action(async () => {
|
|
740
|
+
if (await refusedBeforePasskey()) return;
|
|
719
741
|
await completeSignIn();
|
|
720
742
|
});
|
|
721
743
|
return;
|
|
@@ -758,6 +780,7 @@ async function refusedBeforeSignIn(
|
|
|
758
780
|
signup: boolean,
|
|
759
781
|
acceptance?: {termsAccepted: boolean; termsVersion: string},
|
|
760
782
|
): Promise<boolean> {
|
|
783
|
+
signedInWithPasskey = false;
|
|
761
784
|
try {
|
|
762
785
|
await sdk.login(email, password, {autoSignup: signup, ...acceptance});
|
|
763
786
|
pendingAgreement = null;
|
|
@@ -782,6 +805,34 @@ async function refusedBeforeSignIn(
|
|
|
782
805
|
}
|
|
783
806
|
}
|
|
784
807
|
|
|
808
|
+
// The same answers as a password sign-in, from a passkey: the browser runs the
|
|
809
|
+
// ceremony against Fidj's challenge, and the SDK trades the answer for tokens.
|
|
810
|
+
async function refusedBeforePasskey(
|
|
811
|
+
acceptance?: {termsAccepted: boolean; termsVersion: string},
|
|
812
|
+
): Promise<boolean> {
|
|
813
|
+
signedInWithPasskey = true;
|
|
814
|
+
try {
|
|
815
|
+
const {options, ticket} = await sdk.passkeyLoginOptions();
|
|
816
|
+
const response = await passkeyAssertion(options);
|
|
817
|
+
await sdk.loginWithPasskey(ticket, response, acceptance);
|
|
818
|
+
pendingAgreement = null;
|
|
819
|
+
awaitingVerification = "";
|
|
820
|
+
return false;
|
|
821
|
+
} catch (error) {
|
|
822
|
+
if (agreementRequired(error)) {
|
|
823
|
+
pendingAgreement = agreementFromRefusal(error) || (await readAgreement());
|
|
824
|
+
if (!pendingAgreement) {
|
|
825
|
+
throw new Error("We cannot reach Fidj right now. Please try again.");
|
|
826
|
+
}
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
if ((error as any)?.name === "NotAllowedError") {
|
|
830
|
+
throw new Error("No passkey was used. Try again, or sign in with your email.");
|
|
831
|
+
}
|
|
832
|
+
throw new Error(signInErrorMessage(error));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
785
836
|
// An API that refused without saying which agreement it wanted. Older ones
|
|
786
837
|
// cannot say, so the app is asked directly; it is the same agreement unless the
|
|
787
838
|
// owner published between the two calls.
|
|
@@ -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
|