@ofidj/generator-fidj 1.0.1 → 1.0.4
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 +58 -14
- package/bin/create-fidj.cjs +9 -1
- package/generators/app/index.js +57 -15
- package/generators/app/templates/typescript/README.md +12 -2
- package/generators/app/templates/typescript/app.config.json +5 -1
- package/generators/app/templates/typescript/package.json +1 -1
- package/generators/app/templates/typescript/public/fidj-logo.png +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin.woff2 +0 -0
- package/generators/app/templates/typescript/scripts/build.mjs +3 -0
- package/generators/app/templates/typescript/scripts/render-content.mjs +2 -2
- package/generators/app/templates/typescript/server/index.ts +10 -1
- package/generators/app/templates/typescript/src/content.ts +128 -37
- package/generators/app/templates/typescript/src/fonts.css +92 -0
- package/generators/app/templates/typescript/src/main.ts +31 -9
- package/generators/app/templates/typescript/src/service-agreement.ts +51 -0
- package/generators/app/templates/typescript/src/style.css +640 -363
- package/generators/app/templates/typescript/src/tokens.css +56 -0
- package/lib/app-module.cjs +6 -1
- package/lib/scaffold.cjs +72 -3
- package/package.json +2 -6
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage} from "./service-agreement";
|
|
1
2
|
import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
|
|
2
3
|
import config from "../app.config.json";
|
|
3
4
|
import "./style.css";
|
|
@@ -23,6 +24,9 @@ let message =
|
|
|
23
24
|
let failed = false;
|
|
24
25
|
let busy = false;
|
|
25
26
|
let leaving = false;
|
|
27
|
+
let signInEmail = "";
|
|
28
|
+
let signInPassword = "";
|
|
29
|
+
let signInAgreementAccepted = false;
|
|
26
30
|
const accountRoutes = ["forgot", "reset", "verify", "account"];
|
|
27
31
|
let linkToken = "";
|
|
28
32
|
let verificationConfirmed = false;
|
|
@@ -45,6 +49,56 @@ const escape = (value: unknown) =>
|
|
|
45
49
|
);
|
|
46
50
|
const element = <T extends HTMLElement>(id: string) =>
|
|
47
51
|
document.getElementById(id) as T | null;
|
|
52
|
+
function badges() {
|
|
53
|
+
const entries: string[] = config.badges;
|
|
54
|
+
if (!entries?.length) return "";
|
|
55
|
+
return `<footer class="signin-badges">${entries
|
|
56
|
+
.map((entry) => `<span>${escape(entry)}</span>`)
|
|
57
|
+
.join("")}</footer>`;
|
|
58
|
+
}
|
|
59
|
+
function banner() {
|
|
60
|
+
return message
|
|
61
|
+
? `<p role="${failed ? "alert" : "status"}" class="${failed ? "error" : "notice"}">${escape(message)}</p>`
|
|
62
|
+
: "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// One navigation bar for every in-app screen, so signing out stays one click
|
|
66
|
+
// away wherever you are. The sign-in entry and the pre-authentication account
|
|
67
|
+
// screens are full-bleed and carry none.
|
|
68
|
+
function appNav(current: "content" | "privacy" | "account") {
|
|
69
|
+
const tab = (id: string, label: string, selected: boolean) =>
|
|
70
|
+
`<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
|
|
71
|
+
return `<nav class="content-nav" aria-label="App navigation">${tab("content-tab", "Content", current === "content")}${tab("privacy-tab", signedIn ? "My privacy" : "Sign in", current === "privacy")}${signedIn ? tab("account-tab", "My account", current === "account") : ""}${tab("exit", signedIn ? "Sign out" : "Back to sign in", false)}</nav>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function wireNav() {
|
|
75
|
+
element("content-tab")?.addEventListener("click", () => navigate("content"));
|
|
76
|
+
element("account-tab")?.addEventListener("click", () => navigate("account"));
|
|
77
|
+
element("privacy-tab")?.addEventListener("click", () =>
|
|
78
|
+
navigate(signedIn ? "privacy" : "signin"),
|
|
79
|
+
);
|
|
80
|
+
element("exit")?.addEventListener(
|
|
81
|
+
"click",
|
|
82
|
+
() =>
|
|
83
|
+
void action(async () => {
|
|
84
|
+
if (signedIn) await sdk.logout(true);
|
|
85
|
+
signedIn = false;
|
|
86
|
+
anonymous = false;
|
|
87
|
+
navigate("signin");
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function highlights() {
|
|
93
|
+
const entries: Array<{ heading: string; body: string }> = config.highlights;
|
|
94
|
+
if (!entries?.length) return "";
|
|
95
|
+
return `<div class="signin-highlights">${entries
|
|
96
|
+
.map(
|
|
97
|
+
(entry, index) =>
|
|
98
|
+
`<article><p class="eyebrow">${String(index + 1).padStart(2, "0")}</p><h2>${escape(entry.heading)}</h2><p>${escape(entry.body)}</p></article>`,
|
|
99
|
+
)
|
|
100
|
+
.join("")}</div>`;
|
|
101
|
+
}
|
|
48
102
|
async function request(path: string, method = "GET", data?: unknown) {
|
|
49
103
|
const token = await sdk.fidjGetIdToken();
|
|
50
104
|
const response = await fetch(config.apiEndpoint + path, {
|
|
@@ -169,44 +223,46 @@ function render() {
|
|
|
169
223
|
route = "content";
|
|
170
224
|
if (route === "privacy" && !signedIn) route = "signin";
|
|
171
225
|
window.history.replaceState(null, "", "#/" + route);
|
|
172
|
-
|
|
226
|
+
// My account is a signed-in screen and keeps the app's chrome. Recovery and
|
|
227
|
+
// verification are reached without a session, so they stand alone.
|
|
228
|
+
const standaloneAccount =
|
|
229
|
+
accountRoutes.includes(route) && !(route === "account" && signedIn);
|
|
230
|
+
document.body.classList.toggle(
|
|
231
|
+
"signin-view",
|
|
232
|
+
route === "signin" || standaloneAccount,
|
|
233
|
+
);
|
|
234
|
+
if (standaloneAccount) {
|
|
173
235
|
renderAccount(route);
|
|
174
236
|
return;
|
|
175
237
|
}
|
|
238
|
+
if (route === "account") {
|
|
239
|
+
root.innerHTML = `${appNav("account")}<section class="card content-account">${banner()}${accountForm("account")}</section>`;
|
|
240
|
+
wireNav();
|
|
241
|
+
wireAccount("account");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
176
244
|
if (route === "content" && config.moduleEntry) {
|
|
177
245
|
root.innerHTML = '<p role="status">Opening your app…</p>';
|
|
178
246
|
window.location.assign(config.moduleEntry);
|
|
179
247
|
return;
|
|
180
248
|
}
|
|
181
249
|
if (route === "content") {
|
|
182
|
-
root.innerHTML =
|
|
183
|
-
|
|
184
|
-
navigate("account"),
|
|
185
|
-
);
|
|
186
|
-
element("privacy-tab")!.addEventListener("click", () =>
|
|
187
|
-
navigate(signedIn ? "privacy" : "signin"),
|
|
188
|
-
);
|
|
189
|
-
element("exit")!.addEventListener(
|
|
190
|
-
"click",
|
|
191
|
-
() =>
|
|
192
|
-
void action(async () => {
|
|
193
|
-
if (signedIn) await sdk.logout(true);
|
|
194
|
-
signedIn = false;
|
|
195
|
-
anonymous = false;
|
|
196
|
-
navigate("signin");
|
|
197
|
-
}),
|
|
198
|
-
);
|
|
250
|
+
root.innerHTML = `${appNav("content")}${element<HTMLTemplateElement>("public-content")!.innerHTML}`;
|
|
251
|
+
wireNav();
|
|
199
252
|
return;
|
|
200
253
|
}
|
|
201
|
-
root.innerHTML =
|
|
202
|
-
${
|
|
254
|
+
root.innerHTML = `${route === "signin" ? "" : appNav("privacy")}<section class="${route === "signin" ? "signin-shell" : "card content-account"}">
|
|
255
|
+
${route === "signin" ? "" : banner()}
|
|
203
256
|
${
|
|
204
257
|
route === "signin"
|
|
205
|
-
? `<div class="signin-intro
|
|
206
|
-
<div class="signin-
|
|
207
|
-
</div
|
|
258
|
+
? `<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>
|
|
259
|
+
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
260
|
+
${highlights()}</div>
|
|
261
|
+
<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>` : ""}
|
|
262
|
+
<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>
|
|
263
|
+
${badges()}</div>`
|
|
208
264
|
: `
|
|
209
|
-
<
|
|
265
|
+
<h2>My privacy in ${escape(config.title)}</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
|
|
210
266
|
<p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
|
|
211
267
|
<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>
|
|
212
268
|
${["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("")}
|
|
@@ -225,26 +281,53 @@ function render() {
|
|
|
225
281
|
<button id="export">Export my app data</button>
|
|
226
282
|
<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>
|
|
227
283
|
${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>'}
|
|
228
|
-
<p><a href="${escape(config.dashboardUrl)}/#/my">
|
|
284
|
+
<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>`
|
|
229
285
|
}</section>`;
|
|
286
|
+
wireNav();
|
|
287
|
+
element("reveal")?.addEventListener("click", () => {
|
|
288
|
+
const field = element<HTMLInputElement>("password");
|
|
289
|
+
const button = element("reveal");
|
|
290
|
+
if (!field || !button) return;
|
|
291
|
+
const hidden = field.type === "password";
|
|
292
|
+
field.type = hidden ? "text" : "password";
|
|
293
|
+
button.textContent = hidden ? "Hide" : "Show";
|
|
294
|
+
});
|
|
230
295
|
element("anonymous")?.addEventListener("click", () => {
|
|
231
296
|
if (!config.allowAnonymous) return;
|
|
232
297
|
anonymous = true;
|
|
233
298
|
navigate("content");
|
|
234
299
|
});
|
|
235
|
-
element("
|
|
236
|
-
|
|
300
|
+
if (oidc && element("signin")) element("signin")!.innerHTML = agreementMarkup() + '<p>Continue securely with your Fidj account. Your password stays with Fidj.</p><button class="primary" type="submit">Continue with Fidj</button>';
|
|
301
|
+
void bindAgreement(element<HTMLFormElement>("signin"), config.title, config.apiEndpoint, config.appId, signInAgreementAccepted);
|
|
237
302
|
element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
|
|
238
303
|
event.preventDefault();
|
|
239
304
|
const email = element<HTMLInputElement>("email")?.value || "";
|
|
240
305
|
const password = element<HTMLInputElement>("password")?.value || "";
|
|
306
|
+
const agreement = element<HTMLInputElement>("service-agreement");
|
|
307
|
+
signInEmail = email;
|
|
308
|
+
signInPassword = password;
|
|
309
|
+
signInAgreementAccepted = agreement?.checked === true;
|
|
310
|
+
const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
|
|
311
|
+
if (!acceptance) {
|
|
312
|
+
failed = true;
|
|
313
|
+
message = "Please accept the service agreement before continuing.";
|
|
314
|
+
render();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
241
317
|
const signup = (event.submitter as HTMLButtonElement)?.name === "signup";
|
|
242
318
|
void action(async () => {
|
|
243
319
|
if (oidc) {window.location.assign(await oidc.beginLogin()); return;}
|
|
244
|
-
|
|
320
|
+
try {
|
|
321
|
+
await sdk.login(email, password, { autoSignup: signup, ...acceptance });
|
|
322
|
+
} catch (error) {
|
|
323
|
+
throw new Error(signInErrorMessage(error));
|
|
324
|
+
}
|
|
245
325
|
await refresh();
|
|
246
326
|
anonymous = false;
|
|
247
|
-
|
|
327
|
+
// A new account belongs where a returning one lands: inside the app.
|
|
328
|
+
// Sending it to the account card instead dropped people who had just
|
|
329
|
+
// signed up on the shell, one click short of the app they came for.
|
|
330
|
+
navigate("content");
|
|
248
331
|
});
|
|
249
332
|
});
|
|
250
333
|
element("refresh")?.addEventListener("click", () => void action(refresh));
|
|
@@ -264,7 +347,7 @@ function render() {
|
|
|
264
347
|
void action(async () => {
|
|
265
348
|
await request(appPath + "/consents", "PUT", {
|
|
266
349
|
terms: true,
|
|
267
|
-
|
|
350
|
+
cguVersion: "starter-demo-1",
|
|
268
351
|
source: "profile",
|
|
269
352
|
});
|
|
270
353
|
await refresh();
|
|
@@ -327,19 +410,27 @@ function render() {
|
|
|
327
410
|
});
|
|
328
411
|
}
|
|
329
412
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const form =
|
|
335
|
-
route === "forgot"
|
|
413
|
+
// The four account screens. My account is shown inside the app; the recovery
|
|
414
|
+
// and verification ones are reached without a session and stand alone.
|
|
415
|
+
function accountForm(route: string) {
|
|
416
|
+
return route === "forgot"
|
|
336
417
|
? `<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>`
|
|
337
418
|
: route === "reset"
|
|
338
419
|
? `<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>'}`
|
|
339
420
|
: route === "verify"
|
|
340
421
|
? `<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>"}`
|
|
341
422
|
: `<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><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button>`;
|
|
342
|
-
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function renderAccount(route: string) {
|
|
426
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
|
|
427
|
+
<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>
|
|
428
|
+
</div>
|
|
429
|
+
<div class="signin-form"><div>${banner()}${accountForm(route)}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
|
|
430
|
+
wireAccount(route);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function wireAccount(route: string) {
|
|
343
434
|
element("continue-app")?.addEventListener("click", () => navigate("content"));
|
|
344
435
|
element("check-verification")?.addEventListener(
|
|
345
436
|
"click",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* The design system's typefaces, self-hosted.
|
|
2
|
+
*
|
|
3
|
+
* A generated site has to stay statically hostable, and an app that sells
|
|
4
|
+
* privacy should not hand a third party a request log on every sign-in. Latin
|
|
5
|
+
* and latin-ext only; other scripts fall back to the stack in tokens.css.
|
|
6
|
+
*
|
|
7
|
+
* The url()s are runtime paths relative to the built main.css, not bundle
|
|
8
|
+
* inputs — the files ride along in public/ and esbuild leaves them external.
|
|
9
|
+
* A consumer that serves the same tokens from somewhere else (fidj-app mounts
|
|
10
|
+
* its console under /module/) writes its own @font-face block instead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
@font-face {
|
|
14
|
+
font-family: "IBM Plex Sans";
|
|
15
|
+
font-style: normal;
|
|
16
|
+
font-weight: 400 600;
|
|
17
|
+
font-display: swap;
|
|
18
|
+
src: url("fonts/IBMPlexSans-400_600-latin.woff2") format("woff2");
|
|
19
|
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
20
|
+
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
|
|
21
|
+
U+2212, U+2215, U+FEFF, U+FFFD;
|
|
22
|
+
}
|
|
23
|
+
@font-face {
|
|
24
|
+
font-family: "IBM Plex Sans";
|
|
25
|
+
font-style: normal;
|
|
26
|
+
font-weight: 400 600;
|
|
27
|
+
font-display: swap;
|
|
28
|
+
src: url("fonts/IBMPlexSans-400_600-latin-ext.woff2") format("woff2");
|
|
29
|
+
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
|
30
|
+
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
|
|
31
|
+
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
32
|
+
}
|
|
33
|
+
@font-face {
|
|
34
|
+
font-family: "IBM Plex Mono";
|
|
35
|
+
font-style: normal;
|
|
36
|
+
font-weight: 400;
|
|
37
|
+
font-display: swap;
|
|
38
|
+
src: url("fonts/IBMPlexMono-400-latin.woff2") format("woff2");
|
|
39
|
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
40
|
+
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
|
|
41
|
+
U+2212, U+2215, U+FEFF, U+FFFD;
|
|
42
|
+
}
|
|
43
|
+
@font-face {
|
|
44
|
+
font-family: "IBM Plex Mono";
|
|
45
|
+
font-style: normal;
|
|
46
|
+
font-weight: 400;
|
|
47
|
+
font-display: swap;
|
|
48
|
+
src: url("fonts/IBMPlexMono-400-latin-ext.woff2") format("woff2");
|
|
49
|
+
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
|
50
|
+
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
|
|
51
|
+
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
52
|
+
}
|
|
53
|
+
@font-face {
|
|
54
|
+
font-family: "IBM Plex Mono";
|
|
55
|
+
font-style: normal;
|
|
56
|
+
font-weight: 500;
|
|
57
|
+
font-display: swap;
|
|
58
|
+
src: url("fonts/IBMPlexMono-500-latin.woff2") format("woff2");
|
|
59
|
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
60
|
+
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
|
|
61
|
+
U+2212, U+2215, U+FEFF, U+FFFD;
|
|
62
|
+
}
|
|
63
|
+
@font-face {
|
|
64
|
+
font-family: "IBM Plex Mono";
|
|
65
|
+
font-style: normal;
|
|
66
|
+
font-weight: 500;
|
|
67
|
+
font-display: swap;
|
|
68
|
+
src: url("fonts/IBMPlexMono-500-latin-ext.woff2") format("woff2");
|
|
69
|
+
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
|
70
|
+
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
|
|
71
|
+
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
72
|
+
}
|
|
73
|
+
@font-face {
|
|
74
|
+
font-family: "Instrument Serif";
|
|
75
|
+
font-style: normal;
|
|
76
|
+
font-weight: 400;
|
|
77
|
+
font-display: swap;
|
|
78
|
+
src: url("fonts/InstrumentSerif-400-latin.woff2") format("woff2");
|
|
79
|
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
80
|
+
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
|
|
81
|
+
U+2212, U+2215, U+FEFF, U+FFFD;
|
|
82
|
+
}
|
|
83
|
+
@font-face {
|
|
84
|
+
font-family: "Instrument Serif";
|
|
85
|
+
font-style: normal;
|
|
86
|
+
font-weight: 400;
|
|
87
|
+
font-display: swap;
|
|
88
|
+
src: url("fonts/InstrumentSerif-400-latin-ext.woff2") format("woff2");
|
|
89
|
+
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
|
90
|
+
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
|
|
91
|
+
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
92
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage} from "./service-agreement";
|
|
1
2
|
import { FidjNodeService } from "@ofidj/node";
|
|
2
3
|
import "./style.css";
|
|
3
4
|
|
|
@@ -23,6 +24,10 @@ let view = "workspace";
|
|
|
23
24
|
let notice = "";
|
|
24
25
|
let error = "";
|
|
25
26
|
let busy = false;
|
|
27
|
+
let leaving = false;
|
|
28
|
+
let signInEmail = "";
|
|
29
|
+
let signInPassword = "";
|
|
30
|
+
let signInAgreementAccepted = false;
|
|
26
31
|
const escape = (value: unknown) =>
|
|
27
32
|
String(value ?? "").replace(
|
|
28
33
|
/[&<>"']/g,
|
|
@@ -83,7 +88,7 @@ function render() {
|
|
|
83
88
|
<main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
|
|
84
89
|
${
|
|
85
90
|
!session
|
|
86
|
-
? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2><p>Sign in with your Fidj account.</p><label for="email">Email</label><input id="email" type="email" autocomplete="username" required><label for="password">Password</label><input id="password" type="password" autocomplete="current-password" required
|
|
91
|
+
? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2><p>Sign in with your Fidj account.</p><label for="email">Email</label><input id="email" type="email" value="${escape(signInEmail)}" autocomplete="username" required><label for="password">Password</label><input id="password" type="password" value="${escape(signInPassword)}" autocomplete="current-password" required>${agreementMarkup()}<button class="primary" type="submit">Continue</button>${settings.localDemo ? `<div class="demo"><strong>Try the local example</strong><p>Alex owns the app. Maya and Sam start with the Free role.</p><button type="button" data-demo="alex">Alex · owner</button><button type="button" data-demo="maya">Maya · member</button><button type="button" data-demo="sam">Sam · member</button></div>` : ""}</form></section>`
|
|
87
92
|
: `
|
|
88
93
|
<div class="page-heading"><div><p class="eyebrow">YOUR WORKSPACE</p><h1>A place to think.</h1><p>${escape(session.username)} <span class="roles">${session.roles.map(escape).join(" · ") || "No assigned roles"}</span></p></div><button id="signout">Sign out</button></div>
|
|
89
94
|
<nav><button id="workspace-tab" class="${view === "workspace" ? "selected" : ""}">My notes</button><button id="privacy-tab" class="${view === "privacy" ? "selected" : ""}">My privacy</button><button id="refresh">Refresh access</button></nav>
|
|
@@ -101,16 +106,30 @@ function render() {
|
|
|
101
106
|
)
|
|
102
107
|
.join("")
|
|
103
108
|
: "<p>No changes yet.</p>"
|
|
104
|
-
}<hr><h2>Leave this app</h2><p>This removes your app membership and this starter’s notes. Your Fidj account and other memberships remain.</p>${session.roles.includes("Owner") ? "<p>As the app owner, resolve ownership before leaving.</p>" : '<button id="leave" class="danger">Leave and erase my app data</button>'}<p class="fineprint">Exports here include your Fidj membership and this starter’s notes. The registered app-data handler lets Fidj export and erase these notes too. If cleanup is pending, retry from My privacy on Fidj. Minimal completion receipts are retained; backups and unregistered systems are outside this operation.</p></article></section>`
|
|
109
|
+
}<hr><h2>Leave this app</h2><p>This removes your app membership and this starter’s notes. Your Fidj account and other memberships remain.</p>${session.roles.includes("Owner") ? "<p>As the app owner, resolve ownership before leaving.</p>" : leaving ? '<div role="alertdialog" aria-labelledby="leave-title"><h3 id="leave-title">Confirm departure</h3><p>Your membership, consent and notes in this app will be removed. Your Fidj account and other apps remain available.</p><button id="confirm-leave" class="danger">Confirm leaving and erase</button><button id="cancel-leave">Keep my membership</button></div>' : '<button id="leave" class="danger">Leave and erase my app data</button>'}<p class="fineprint">Exports here include your Fidj membership and this starter’s notes. The registered app-data handler lets Fidj export and erase these notes too. If cleanup is pending, retry from My privacy on Fidj. Minimal completion receipts are retained; backups and unregistered systems are outside this operation.</p></article></section>`
|
|
105
110
|
}`
|
|
106
111
|
}
|
|
107
112
|
<footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
|
|
113
|
+
void bindAgreement(el<HTMLFormElement>("signin"), settings.title, settings.apiEndpoint, settings.appId, signInAgreementAccepted);
|
|
108
114
|
el<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
|
|
109
115
|
event.preventDefault();
|
|
110
116
|
const email = el<HTMLInputElement>("email").value;
|
|
111
117
|
const password = el<HTMLInputElement>("password").value;
|
|
118
|
+
signInEmail = email;
|
|
119
|
+
signInPassword = password;
|
|
120
|
+
signInAgreementAccepted = el<HTMLInputElement>("service-agreement").checked;
|
|
121
|
+
const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
|
|
122
|
+
if (!acceptance) {
|
|
123
|
+
error = "Please accept the service agreement before continuing.";
|
|
124
|
+
render();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
112
127
|
void action(async () => {
|
|
113
|
-
|
|
128
|
+
try {
|
|
129
|
+
await sdk.login(email, password, { autoSignup: false, ...acceptance });
|
|
130
|
+
} catch (reason) {
|
|
131
|
+
throw new Error(signInErrorMessage(reason));
|
|
132
|
+
}
|
|
114
133
|
await load();
|
|
115
134
|
});
|
|
116
135
|
});
|
|
@@ -202,12 +221,14 @@ function render() {
|
|
|
202
221
|
}),
|
|
203
222
|
);
|
|
204
223
|
el("leave")?.addEventListener("click", () => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
224
|
+
leaving = true;
|
|
225
|
+
render();
|
|
226
|
+
});
|
|
227
|
+
el("cancel-leave")?.addEventListener("click", () => {
|
|
228
|
+
leaving = false;
|
|
229
|
+
render();
|
|
230
|
+
});
|
|
231
|
+
el("confirm-leave")?.addEventListener("click", () => {
|
|
211
232
|
void action(async () => {
|
|
212
233
|
const result = await api("privacy/leave", "DELETE", {
|
|
213
234
|
confirm: settings.appId,
|
|
@@ -216,6 +237,7 @@ function render() {
|
|
|
216
237
|
session = null;
|
|
217
238
|
notes = [];
|
|
218
239
|
privacy = null;
|
|
240
|
+
leaving = false;
|
|
219
241
|
notice =
|
|
220
242
|
result.status === "pending"
|
|
221
243
|
? "Access revoked. Cleanup is pending. Open My privacy on Fidj and choose Retry cleanup."
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function agreementMarkup() {
|
|
2
|
+
return `<div class="signin-agreement"><label class="agreement-choice"><input id="service-agreement" type="checkbox" aria-required="true" disabled><span id="agreement-label">I accept the service agreement for this app.</span></label><button type="button" id="read-agreement" disabled>Read service agreement</button><p id="agreement-status" class="fineprint" role="status">Loading service agreement…</p></div><dialog id="agreement-dialog" aria-labelledby="agreement-heading"><h2 id="agreement-heading">Service agreement</h2><p id="agreement-version"></p><p id="agreement-text"></p><button type="button" id="close-agreement">Close agreement</button></dialog>`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function acceptedAgreement(form: HTMLFormElement) {
|
|
6
|
+
const checkbox = form.querySelector<HTMLInputElement>("#service-agreement");
|
|
7
|
+
if (!checkbox?.checked || checkbox.disabled || !checkbox.dataset.version) return null;
|
|
8
|
+
return { termsAccepted: true, termsVersion: checkbox.dataset.version };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function signInErrorMessage(error: unknown) {
|
|
12
|
+
const detail = error as {code?: number; reason?: unknown; message?: unknown};
|
|
13
|
+
const reason = typeof detail?.reason === "string" ? detail.reason : typeof detail?.message === "string" ? detail.message : "";
|
|
14
|
+
if (detail?.code === 429) return "Too many attempts. Please wait before trying again.";
|
|
15
|
+
if (reason === "unknown-user") return "We could not sign in to this account. Check the email and password.";
|
|
16
|
+
if (reason === "already exists - inconsistent request") return "An account already uses this email. Check the password or sign in instead.";
|
|
17
|
+
if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|network/i.test(reason)) return "We cannot reach Fidj right now. Please try again.";
|
|
18
|
+
return "We could not sign in to this account. Please try again.";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function bindAgreement(form: HTMLFormElement | null, title: string, endpoint: string, appId: string, checked = false) {
|
|
22
|
+
if (!form) return;
|
|
23
|
+
const checkbox = form.querySelector<HTMLInputElement>("#service-agreement")!;
|
|
24
|
+
const read = form.querySelector<HTMLButtonElement>("#read-agreement")!;
|
|
25
|
+
const dialog = form.querySelector<HTMLDialogElement>("#agreement-dialog")!;
|
|
26
|
+
const status = form.querySelector<HTMLElement>("#agreement-status")!;
|
|
27
|
+
const submitButtons = form.querySelectorAll<HTMLButtonElement>('button[type="submit"]');
|
|
28
|
+
const update = () => submitButtons.forEach(button => { button.disabled = checkbox.disabled; });
|
|
29
|
+
form.querySelector("#agreement-label")!.textContent = `I accept the service agreement for ${title}.`;
|
|
30
|
+
update();
|
|
31
|
+
checkbox.addEventListener("change", update);
|
|
32
|
+
read.addEventListener("click", () => dialog.showModal());
|
|
33
|
+
form.querySelector("#close-agreement")!.addEventListener("click", () => dialog.close());
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(`${endpoint}/apps/${encodeURIComponent(appId)}`, {signal: AbortSignal.timeout(10000)});
|
|
36
|
+
if (!response.ok) throw new Error("Agreement unavailable");
|
|
37
|
+
const agreement = (await response.json()).app?.agreement;
|
|
38
|
+
if (!agreement || typeof agreement.version !== "string" || !agreement.version || typeof agreement.text !== "string" || !agreement.text) throw new Error("Agreement unavailable");
|
|
39
|
+
if (!form.isConnected) return;
|
|
40
|
+
checkbox.dataset.version = agreement.version;
|
|
41
|
+
checkbox.disabled = false;
|
|
42
|
+
checkbox.checked = checked;
|
|
43
|
+
read.disabled = false;
|
|
44
|
+
dialog.querySelector("#agreement-version")!.textContent = `Version ${agreement.version}`;
|
|
45
|
+
dialog.querySelector("#agreement-text")!.textContent = agreement.text;
|
|
46
|
+
status.textContent = "Required to sign in. Optional data choices stay separate.";
|
|
47
|
+
update();
|
|
48
|
+
} catch {
|
|
49
|
+
if (form.isConnected) status.textContent = "The service agreement could not be loaded. Reload this page to try again.";
|
|
50
|
+
}
|
|
51
|
+
}
|