@ofidj/generator-fidj 1.0.0 → 1.0.3

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.
Files changed (161) hide show
  1. package/README.md +136 -57
  2. package/bin/create-fidj.cjs +62 -0
  3. package/generators/app/index.js +100 -189
  4. package/generators/app/templates/typescript/README.md +82 -0
  5. package/generators/app/templates/typescript/app.config.json +14 -0
  6. package/generators/app/templates/typescript/gitignore +5 -0
  7. package/generators/app/templates/typescript/package.json +25 -0
  8. package/generators/app/templates/typescript/public/fidj-logo.png +0 -0
  9. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
  10. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin.woff2 +0 -0
  11. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
  12. package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin.woff2 +0 -0
  13. package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin-ext.woff2 +0 -0
  14. package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin.woff2 +0 -0
  15. package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin-ext.woff2 +0 -0
  16. package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin.woff2 +0 -0
  17. package/generators/app/templates/typescript/public/index.html +16 -0
  18. package/generators/app/templates/typescript/scripts/build.mjs +49 -0
  19. package/generators/app/templates/typescript/scripts/privacy-check.mjs +21 -0
  20. package/generators/app/templates/typescript/scripts/render-content.mjs +14 -0
  21. package/generators/app/templates/typescript/server/data-store.ts +123 -0
  22. package/generators/app/templates/typescript/server/index.ts +338 -0
  23. package/generators/app/templates/typescript/server/privacy-adapter.ts +21 -0
  24. package/generators/app/templates/typescript/src/content.ts +491 -0
  25. package/generators/app/templates/typescript/src/fonts.css +92 -0
  26. package/generators/app/templates/typescript/src/main.ts +251 -0
  27. package/generators/app/templates/typescript/src/service-agreement.ts +40 -0
  28. package/generators/app/templates/typescript/src/style.css +916 -0
  29. package/generators/app/templates/typescript/src/tokens.css +56 -0
  30. package/generators/app/templates/typescript/test/server.test.mjs +287 -0
  31. package/generators/app/templates/typescript/tsconfig.json +15 -0
  32. package/lib/app-module.cjs +64 -0
  33. package/lib/scaffold.cjs +214 -0
  34. package/package.json +15 -13
  35. package/generators/app/templates/app2018/.travis.yml +0 -35
  36. package/generators/app/templates/app2018/LICENSE +0 -21
  37. package/generators/app/templates/app2018/Procfile +0 -1
  38. package/generators/app/templates/app2018/README.md +0 -49
  39. package/generators/app/templates/app2018/config.xml +0 -91
  40. package/generators/app/templates/app2018/gulpfile.js +0 -63
  41. package/generators/app/templates/app2018/ionic.config.json +0 -9
  42. package/generators/app/templates/app2018/package.json +0 -90
  43. package/generators/app/templates/app2018/platforms/README.md +0 -1
  44. package/generators/app/templates/app2018/resources/README.md +0 -6
  45. package/generators/app/templates/app2018/resources/icon.png +0 -0
  46. package/generators/app/templates/app2018/resources/marketing-01-ipad.png +0 -0
  47. package/generators/app/templates/app2018/resources/marketing-01-iphone.png +0 -0
  48. package/generators/app/templates/app2018/resources/marketing-02-ipad.png +0 -0
  49. package/generators/app/templates/app2018/resources/marketing-02-iphone.png +0 -0
  50. package/generators/app/templates/app2018/resources/marketing-03-ipad.png +0 -0
  51. package/generators/app/templates/app2018/resources/marketing-03-iphone.png +0 -0
  52. package/generators/app/templates/app2018/resources/marketing.json +0 -19
  53. package/generators/app/templates/app2018/resources/splash.png +0 -0
  54. package/generators/app/templates/app2018/src/app/app.component.ts +0 -88
  55. package/generators/app/templates/app2018/src/app/app.html +0 -19
  56. package/generators/app/templates/app2018/src/app/app.module.ts +0 -40
  57. package/generators/app/templates/app2018/src/app/app.scss +0 -16
  58. package/generators/app/templates/app2018/src/app/main.ts +0 -5
  59. package/generators/app/templates/app2018/src/app/shared/card.model.ts +0 -10
  60. package/generators/app/templates/app2018/src/app/shared/profile.service.ts +0 -46
  61. package/generators/app/templates/app2018/src/app/shared/shared.module.ts +0 -35
  62. package/generators/app/templates/app2018/src/app/shared/storage.service.ts +0 -11
  63. package/generators/app/templates/app2018/src/app/shared/version.const.ts +0 -5
  64. package/generators/app/templates/app2018/src/assets/icon/favicon.ico +0 -0
  65. package/generators/app/templates/app2018/src/assets/imgs/gg.png +0 -0
  66. package/generators/app/templates/app2018/src/assets/imgs/github.png +0 -0
  67. package/generators/app/templates/app2018/src/assets/imgs/login.svg +0 -5668
  68. package/generators/app/templates/app2018/src/assets/imgs/logo.png +0 -0
  69. package/generators/app/templates/app2018/src/index.html +0 -49
  70. package/generators/app/templates/app2018/src/manifest.json +0 -13
  71. package/generators/app/templates/app2018/src/pages/home/home.html +0 -56
  72. package/generators/app/templates/app2018/src/pages/home/home.scss +0 -3
  73. package/generators/app/templates/app2018/src/pages/home/home.ts +0 -126
  74. package/generators/app/templates/app2018/src/pages/login/login.html +0 -56
  75. package/generators/app/templates/app2018/src/pages/login/login.scss +0 -44
  76. package/generators/app/templates/app2018/src/pages/login/login.ts +0 -59
  77. package/generators/app/templates/app2018/src/pages/profile/profile.html +0 -33
  78. package/generators/app/templates/app2018/src/pages/profile/profile.scss +0 -3
  79. package/generators/app/templates/app2018/src/pages/profile/profile.ts +0 -36
  80. package/generators/app/templates/app2018/src/service-worker.js +0 -31
  81. package/generators/app/templates/app2018/src/theme/variables.scss +0 -88
  82. package/generators/app/templates/app2018/tests/karma.unit-tests.conf.js +0 -84
  83. package/generators/app/templates/app2018/tests/unit/app.test.js +0 -10
  84. package/generators/app/templates/app2018/tsconfig.json +0 -28
  85. package/generators/app/templates/app2018/tslint.json +0 -8
  86. package/generators/app/templates/app2018/web.js +0 -21
  87. package/generators/app/templates/app2021/.travis.yml +0 -32
  88. package/generators/app/templates/app2021/LICENSE +0 -21
  89. package/generators/app/templates/app2021/Procfile +0 -1
  90. package/generators/app/templates/app2021/README.md +0 -45
  91. package/generators/app/templates/app2021/angular.json +0 -188
  92. package/generators/app/templates/app2021/browserslist +0 -12
  93. package/generators/app/templates/app2021/ionic.config.json +0 -9
  94. package/generators/app/templates/app2021/karma.conf.js +0 -31
  95. package/generators/app/templates/app2021/package-lock.json +0 -5
  96. package/generators/app/templates/app2021/package.json +0 -77
  97. package/generators/app/templates/app2021/platforms/README.md +0 -1
  98. package/generators/app/templates/app2021/resources/README.md +0 -6
  99. package/generators/app/templates/app2021/resources/icon.png +0 -0
  100. package/generators/app/templates/app2021/resources/marketing-01-ipad.png +0 -0
  101. package/generators/app/templates/app2021/resources/marketing-01-iphone.png +0 -0
  102. package/generators/app/templates/app2021/resources/marketing-02-ipad.png +0 -0
  103. package/generators/app/templates/app2021/resources/marketing-02-iphone.png +0 -0
  104. package/generators/app/templates/app2021/resources/marketing-03-ipad.png +0 -0
  105. package/generators/app/templates/app2021/resources/marketing-03-iphone.png +0 -0
  106. package/generators/app/templates/app2021/resources/marketing.json +0 -19
  107. package/generators/app/templates/app2021/resources/splash.png +0 -0
  108. package/generators/app/templates/app2021/src/app/app-routing.module.ts +0 -25
  109. package/generators/app/templates/app2021/src/app/app.component.html +0 -3
  110. package/generators/app/templates/app2021/src/app/app.component.scss +0 -0
  111. package/generators/app/templates/app2021/src/app/app.component.spec.ts +0 -50
  112. package/generators/app/templates/app2021/src/app/app.component.ts +0 -28
  113. package/generators/app/templates/app2021/src/app/app.module.ts +0 -24
  114. package/generators/app/templates/app2021/src/app/content/content-routing.module.ts +0 -16
  115. package/generators/app/templates/app2021/src/app/content/content.module.ts +0 -20
  116. package/generators/app/templates/app2021/src/app/content/content.page.html +0 -31
  117. package/generators/app/templates/app2021/src/app/content/content.page.scss +0 -0
  118. package/generators/app/templates/app2021/src/app/content/content.page.ts +0 -26
  119. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.html +0 -4
  120. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.scss +0 -4
  121. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.spec.ts +0 -24
  122. package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.ts +0 -16
  123. package/generators/app/templates/app2021/src/app/explore-container/explore-container.module.ts +0 -14
  124. package/generators/app/templates/app2021/src/app/my/my-routing.module.ts +0 -36
  125. package/generators/app/templates/app2021/src/app/my/my.module.ts +0 -19
  126. package/generators/app/templates/app2021/src/app/my/my.page.html +0 -15
  127. package/generators/app/templates/app2021/src/app/my/my.page.scss +0 -1
  128. package/generators/app/templates/app2021/src/app/my/my.page.spec.ts +0 -26
  129. package/generators/app/templates/app2021/src/app/my/my.page.ts +0 -25
  130. package/generators/app/templates/app2021/src/app/profile/profile-routing.module.ts +0 -16
  131. package/generators/app/templates/app2021/src/app/profile/profile.module.ts +0 -20
  132. package/generators/app/templates/app2021/src/app/profile/profile.page.html +0 -49
  133. package/generators/app/templates/app2021/src/app/profile/profile.page.scss +0 -0
  134. package/generators/app/templates/app2021/src/app/profile/profile.page.spec.ts +0 -25
  135. package/generators/app/templates/app2021/src/app/profile/profile.page.ts +0 -44
  136. package/generators/app/templates/app2021/src/app/shared/const.ts +0 -3
  137. package/generators/app/templates/app2021/src/app/signin/signin-routing.module.ts +0 -16
  138. package/generators/app/templates/app2021/src/app/signin/signin.module.ts +0 -18
  139. package/generators/app/templates/app2021/src/app/signin/signin.page.html +0 -53
  140. package/generators/app/templates/app2021/src/app/signin/signin.page.scss +0 -36
  141. package/generators/app/templates/app2021/src/app/signin/signin.page.ts +0 -69
  142. package/generators/app/templates/app2021/src/assets/icon/favicon.png +0 -0
  143. package/generators/app/templates/app2021/src/assets/shapes.svg +0 -1
  144. package/generators/app/templates/app2021/src/environments/environment.prod.ts +0 -3
  145. package/generators/app/templates/app2021/src/environments/environment.ts +0 -16
  146. package/generators/app/templates/app2021/src/global.scss +0 -26
  147. package/generators/app/templates/app2021/src/index.html +0 -26
  148. package/generators/app/templates/app2021/src/main.ts +0 -12
  149. package/generators/app/templates/app2021/src/polyfills.ts +0 -67
  150. package/generators/app/templates/app2021/src/test.ts +0 -20
  151. package/generators/app/templates/app2021/src/theme/variables.scss +0 -232
  152. package/generators/app/templates/app2021/src/zone-flags.ts +0 -5
  153. package/generators/app/templates/app2021/tests/protractor.conf.js +0 -36
  154. package/generators/app/templates/app2021/tests/src/app.e2e-spec.ts +0 -14
  155. package/generators/app/templates/app2021/tests/src/app.po.ts +0 -11
  156. package/generators/app/templates/app2021/tests/tsconfig.json +0 -13
  157. package/generators/app/templates/app2021/tsconfig.app.json +0 -18
  158. package/generators/app/templates/app2021/tsconfig.json +0 -23
  159. package/generators/app/templates/app2021/tsconfig.spec.json +0 -18
  160. package/generators/app/templates/app2021/tslint.json +0 -149
  161. package/generators/app/templates/app2021/web.js +0 -21
@@ -0,0 +1,491 @@
1
+ import {agreementMarkup, bindAgreement, acceptedAgreement} from "./service-agreement";
2
+ import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
3
+ import config from "../app.config.json";
4
+ import "./style.css";
5
+
6
+ const sdk = new FidjNodeService();
7
+ const oidc = config.oidcIssuer ? new FidjOidcClient({issuer: config.oidcIssuer, clientId: config.appId, redirectUri: window.location.origin + window.location.pathname, apiEndpoint: config.apiEndpoint, storage: sessionStorage}) : null;
8
+ const root = document.querySelector<HTMLDivElement>("#app")!;
9
+ const appPath = `/me/apps/${encodeURIComponent(config.appId)}`;
10
+ let signedIn = false;
11
+ let emailVerified = false;
12
+ let anonymous = false;
13
+ let initialized = false;
14
+ let roles: string[] = [];
15
+ let consent: Record<string, boolean> = {};
16
+ let history: Array<{ type: string; granted: boolean; changedAt: string }> = [];
17
+ const departure = new URLSearchParams(window.location.search).get("departure");
18
+ let message =
19
+ departure === "completed"
20
+ ? `You left ${config.title}. Your other memberships and shared identity remain.`
21
+ : departure === "pending"
22
+ ? "Access was revoked. Cleanup is queued; follow its progress in Fidj."
23
+ : "";
24
+ let failed = false;
25
+ let busy = false;
26
+ let leaving = false;
27
+ const accountRoutes = ["forgot", "reset", "verify", "account"];
28
+ let linkToken = "";
29
+ let verificationConfirmed = false;
30
+ function currentRoute() {
31
+ const [route, query] = window.location.hash.slice(2).split("?");
32
+ if (["reset", "verify"].includes(route) && query) {
33
+ verificationConfirmed = false;
34
+ linkToken = new URLSearchParams(query).get("token") || "";
35
+ window.history.replaceState(null, "", "#/" + route);
36
+ }
37
+ return route;
38
+ }
39
+ const escape = (value: unknown) =>
40
+ String(value ?? "").replace(
41
+ /[&<>"']/g,
42
+ (char) =>
43
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
44
+ char
45
+ ]!,
46
+ );
47
+ const element = <T extends HTMLElement>(id: string) =>
48
+ document.getElementById(id) as T | null;
49
+ function badges() {
50
+ const entries: string[] = config.badges;
51
+ if (!entries?.length) return "";
52
+ return `<footer class="signin-badges">${entries
53
+ .map((entry) => `<span>${escape(entry)}</span>`)
54
+ .join("")}</footer>`;
55
+ }
56
+ function banner() {
57
+ return message
58
+ ? `<p role="${failed ? "alert" : "status"}" class="${failed ? "error" : "notice"}">${escape(message)}</p>`
59
+ : "";
60
+ }
61
+
62
+ // One navigation bar for every in-app screen, so signing out stays one click
63
+ // away wherever you are. The sign-in entry and the pre-authentication account
64
+ // screens are full-bleed and carry none.
65
+ function appNav(current: "content" | "privacy" | "account") {
66
+ const tab = (id: string, label: string, selected: boolean) =>
67
+ `<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
68
+ 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>`;
69
+ }
70
+
71
+ function wireNav() {
72
+ element("content-tab")?.addEventListener("click", () => navigate("content"));
73
+ element("account-tab")?.addEventListener("click", () => navigate("account"));
74
+ element("privacy-tab")?.addEventListener("click", () =>
75
+ navigate(signedIn ? "privacy" : "signin"),
76
+ );
77
+ element("exit")?.addEventListener(
78
+ "click",
79
+ () =>
80
+ void action(async () => {
81
+ if (signedIn) await sdk.logout(true);
82
+ signedIn = false;
83
+ anonymous = false;
84
+ navigate("signin");
85
+ }),
86
+ );
87
+ }
88
+
89
+ function highlights() {
90
+ const entries: Array<{ heading: string; body: string }> = config.highlights;
91
+ if (!entries?.length) return "";
92
+ return `<div class="signin-highlights">${entries
93
+ .map(
94
+ (entry, index) =>
95
+ `<article><p class="eyebrow">${String(index + 1).padStart(2, "0")}</p><h2>${escape(entry.heading)}</h2><p>${escape(entry.body)}</p></article>`,
96
+ )
97
+ .join("")}</div>`;
98
+ }
99
+ async function request(path: string, method = "GET", data?: unknown) {
100
+ const token = await sdk.fidjGetIdToken();
101
+ const response = await fetch(config.apiEndpoint + path, {
102
+ method,
103
+ headers: {
104
+ Authorization: `Bearer ${token}`,
105
+ "Content-Type": "application/json",
106
+ },
107
+ body: data === undefined ? undefined : JSON.stringify(data),
108
+ signal: AbortSignal.timeout(10000),
109
+ });
110
+ const result = await response.json();
111
+ if (!response.ok) {
112
+ if ([401, 403].includes(response.status)) {
113
+ signedIn = false;
114
+ await sdk.logout(true);
115
+ }
116
+ throw new Error(result.message || result.status || "Please retry.");
117
+ }
118
+ return result;
119
+ }
120
+ async function refresh() {
121
+ const membership = await request(
122
+ `/apps/${encodeURIComponent(config.appId)}/me`,
123
+ );
124
+ roles = membership.roles.map((role: { type: string }) => role.type);
125
+ [consent, history] = await Promise.all([
126
+ request(appPath + "/consents"),
127
+ request(appPath + "/consents/history").then((result) => result.history),
128
+ ]);
129
+ emailVerified = (await request("/me")).user?.verified === true;
130
+ signedIn = true;
131
+ }
132
+ async function action(task: () => Promise<void>) {
133
+ if (busy) return;
134
+ busy = true;
135
+ root.setAttribute("aria-busy", "true");
136
+ root
137
+ .querySelectorAll<HTMLButtonElement | HTMLInputElement>("button, input")
138
+ .forEach((control) => {
139
+ control.disabled = true;
140
+ });
141
+ const submit = root.querySelector<HTMLButtonElement>("button.primary");
142
+ if (submit) submit.textContent = "Please wait…";
143
+ failed = false;
144
+ if (initialized) message = "";
145
+ try {
146
+ await task();
147
+ } catch (error) {
148
+ failed = true;
149
+ const detail = error as {
150
+ code?: number;
151
+ reason?: unknown;
152
+ message?: unknown;
153
+ };
154
+ if (detail.code === 429)
155
+ message = "Too many attempts. Please wait before trying again.";
156
+ else if (detail.message || detail.reason) {
157
+ let reason: any = detail.message || detail.reason;
158
+ try {
159
+ if (typeof reason === "string") reason = JSON.parse(reason);
160
+ } catch {}
161
+ message =
162
+ typeof reason === "string"
163
+ ? reason
164
+ : reason.message ||
165
+ reason.status ||
166
+ "The request could not be completed. Please retry.";
167
+ } else
168
+ message =
169
+ error instanceof Error
170
+ ? error.message
171
+ : "The request could not be completed. Please retry.";
172
+ if (
173
+ /jwt expired|session revoked|session expired|token.*expired/i.test(
174
+ message,
175
+ )
176
+ )
177
+ message = "Your session has ended. Please sign in again.";
178
+ } finally {
179
+ busy = false;
180
+ initialized = true;
181
+ root.setAttribute("aria-busy", "false");
182
+ render();
183
+ }
184
+ }
185
+ function navigate(route: string) {
186
+ window.history.replaceState(null, "", "#/" + route);
187
+ if (!busy) render();
188
+ }
189
+ function moduleRoute() {
190
+ const route = window.location.hash.slice(2).split("?")[0];
191
+ if (
192
+ !config.moduleEntry ||
193
+ !route ||
194
+ ["signin", "content", "privacy", ...accountRoutes].includes(route)
195
+ )
196
+ return null;
197
+ const target = new URL(config.moduleEntry, window.location.href);
198
+ target.hash = window.location.hash;
199
+ return target.href;
200
+ }
201
+ function render() {
202
+ if (!initialized) {
203
+ root.innerHTML = '<p role="status">Loading your session…</p>';
204
+ return;
205
+ }
206
+ const applicationRoute = moduleRoute();
207
+ if (applicationRoute) {
208
+ root.innerHTML = '<p role="status">Opening your app…</p>';
209
+ window.location.replace(applicationRoute);
210
+ return;
211
+ }
212
+ let route = currentRoute();
213
+ if (
214
+ !signedIn &&
215
+ !(config.allowAnonymous && anonymous) &&
216
+ !["forgot", "reset", "verify"].includes(route)
217
+ )
218
+ route = "signin";
219
+ else if (!["signin", "content", "privacy", ...accountRoutes].includes(route))
220
+ route = "content";
221
+ if (route === "privacy" && !signedIn) route = "signin";
222
+ window.history.replaceState(null, "", "#/" + route);
223
+ // My account is a signed-in screen and keeps the app's chrome. Recovery and
224
+ // verification are reached without a session, so they stand alone.
225
+ const standaloneAccount =
226
+ accountRoutes.includes(route) && !(route === "account" && signedIn);
227
+ document.body.classList.toggle(
228
+ "signin-view",
229
+ route === "signin" || standaloneAccount,
230
+ );
231
+ if (standaloneAccount) {
232
+ renderAccount(route);
233
+ return;
234
+ }
235
+ if (route === "account") {
236
+ root.innerHTML = `${appNav("account")}<section class="card content-account">${banner()}${accountForm("account")}</section>`;
237
+ wireNav();
238
+ wireAccount("account");
239
+ return;
240
+ }
241
+ if (route === "content" && config.moduleEntry) {
242
+ root.innerHTML = '<p role="status">Opening your app…</p>';
243
+ window.location.assign(config.moduleEntry);
244
+ return;
245
+ }
246
+ if (route === "content") {
247
+ root.innerHTML = `${appNav("content")}${element<HTMLTemplateElement>("public-content")!.innerHTML}`;
248
+ wireNav();
249
+ return;
250
+ }
251
+ root.innerHTML = `${route === "signin" ? "" : appNav("privacy")}<section class="${route === "signin" ? "signin-shell" : "card content-account"}">
252
+ ${route === "signin" ? "" : banner()}
253
+ ${
254
+ route === "signin"
255
+ ? `<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>
256
+ <div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
257
+ ${highlights()}</div>
258
+ <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" 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" placeholder="••••••••••" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div>${agreementMarkup()}<button class="primary" type="submit" disabled>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>` : ""}
259
+ <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>
260
+ ${badges()}</div>`
261
+ : `
262
+ <h2>My privacy in ${escape(config.title)}</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
263
+ <p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
264
+ <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>
265
+ ${["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("")}
266
+ <h3>Consent history</h3>${
267
+ history.length
268
+ ? history
269
+ .slice()
270
+ .reverse()
271
+ .map(
272
+ (entry) =>
273
+ `<p>${escape(entry.type)} · ${entry.granted ? "Accepted" : "Withdrawn"} · ${escape(entry.changedAt)}</p>`,
274
+ )
275
+ .join("")
276
+ : "<p>No changes yet.</p>"
277
+ }
278
+ <button id="export">Export my app data</button>
279
+ <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>
280
+ ${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>'}
281
+ <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>`
282
+ }</section>`;
283
+ wireNav();
284
+ element("reveal")?.addEventListener("click", () => {
285
+ const field = element<HTMLInputElement>("password");
286
+ const button = element("reveal");
287
+ if (!field || !button) return;
288
+ const hidden = field.type === "password";
289
+ field.type = hidden ? "text" : "password";
290
+ button.textContent = hidden ? "Hide" : "Show";
291
+ });
292
+ element("anonymous")?.addEventListener("click", () => {
293
+ if (!config.allowAnonymous) return;
294
+ anonymous = true;
295
+ navigate("content");
296
+ });
297
+ 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>';
298
+ void bindAgreement(element<HTMLFormElement>("signin"), config.title, config.apiEndpoint, config.appId);
299
+ element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
300
+ event.preventDefault();
301
+ const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
302
+ if (!acceptance) return;
303
+ const email = element<HTMLInputElement>("email")?.value || "";
304
+ const password = element<HTMLInputElement>("password")?.value || "";
305
+ const signup = (event.submitter as HTMLButtonElement)?.name === "signup";
306
+ void action(async () => {
307
+ if (oidc) {window.location.assign(await oidc.beginLogin()); return;}
308
+ await sdk.login(email, password, { autoSignup: signup, ...acceptance });
309
+ await refresh();
310
+ anonymous = false;
311
+ // A new account belongs where a returning one lands: inside the app.
312
+ // Sending it to the account card instead dropped people who had just
313
+ // signed up on the shell, one click short of the app they came for.
314
+ navigate("content");
315
+ });
316
+ });
317
+ element("refresh")?.addEventListener("click", () => void action(refresh));
318
+ element("signout")?.addEventListener(
319
+ "click",
320
+ () =>
321
+ void action(async () => {
322
+ await sdk.logout(true);
323
+ signedIn = false;
324
+ anonymous = false;
325
+ navigate("signin");
326
+ }),
327
+ );
328
+ element("terms")?.addEventListener(
329
+ "click",
330
+ () =>
331
+ void action(async () => {
332
+ await request(appPath + "/consents", "PUT", {
333
+ terms: true,
334
+ cguVersion: "starter-demo-1",
335
+ source: "profile",
336
+ });
337
+ await refresh();
338
+ }),
339
+ );
340
+ root.querySelectorAll<HTMLInputElement>("[data-purpose]").forEach((input) =>
341
+ input.addEventListener(
342
+ "change",
343
+ () =>
344
+ void action(async () => {
345
+ await request(appPath + "/consents", "PUT", {
346
+ [input.dataset.purpose!]: input.checked,
347
+ source: "profile",
348
+ });
349
+ await refresh();
350
+ message = "Saved for this app.";
351
+ }),
352
+ ),
353
+ );
354
+ element("export")?.addEventListener(
355
+ "click",
356
+ () =>
357
+ void action(async () => {
358
+ const data = await request(appPath + "/export");
359
+ const url = URL.createObjectURL(
360
+ new Blob([JSON.stringify(data, null, 2)], {
361
+ type: "application/json",
362
+ }),
363
+ );
364
+ const link = document.createElement("a");
365
+ link.href = url;
366
+ link.download = `${config.appId}-my-data.json`;
367
+ link.click();
368
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
369
+ message = "Export downloaded.";
370
+ }),
371
+ );
372
+ element("leave")?.addEventListener("click", () => {
373
+ leaving = true;
374
+ render();
375
+ });
376
+ element("cancel-leave")?.addEventListener("click", () => {
377
+ leaving = false;
378
+ render();
379
+ });
380
+ element("confirm-leave")?.addEventListener("click", () => {
381
+ void action(async () => {
382
+ const result = await request(appPath, "DELETE", {
383
+ confirm: config.appId,
384
+ });
385
+ await sdk.logout(true);
386
+ signedIn = false;
387
+ anonymous = false;
388
+ leaving = false;
389
+ message =
390
+ result.status === "pending"
391
+ ? "Access revoked. Storage cleanup is pending; contact the app owner."
392
+ : "You left this app. Your other memberships remain available.";
393
+ });
394
+ });
395
+ }
396
+
397
+ // The four account screens. My account is shown inside the app; the recovery
398
+ // and verification ones are reached without a session and stand alone.
399
+ function accountForm(route: string) {
400
+ return route === "forgot"
401
+ ? `<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>`
402
+ : route === "reset"
403
+ ? `<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>'}`
404
+ : route === "verify"
405
+ ? `<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>"}`
406
+ : `<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>`;
407
+ }
408
+
409
+ function renderAccount(route: string) {
410
+ 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>
411
+ <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>
412
+ </div>
413
+ <div class="signin-form"><div>${banner()}${accountForm(route)}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
414
+ wireAccount(route);
415
+ }
416
+
417
+ function wireAccount(route: string) {
418
+ element("continue-app")?.addEventListener("click", () => navigate("content"));
419
+ element("check-verification")?.addEventListener(
420
+ "click",
421
+ () =>
422
+ void action(async () => {
423
+ const result = await request("/me");
424
+ emailVerified = result.user?.verified === true;
425
+ message = emailVerified
426
+ ? "Your email address is verified."
427
+ : "Your email is not verified yet. Request a verification email below.";
428
+ }),
429
+ );
430
+ element("resend-verification")?.addEventListener(
431
+ "click",
432
+ () =>
433
+ void action(async () => {
434
+ await sdk.resendVerification();
435
+ message =
436
+ "Verification email sent. Open the link and confirm your address. If it does not arrive, check your spam folder.";
437
+ }),
438
+ );
439
+ element<HTMLFormElement>("recovery")?.addEventListener("submit", (event) => {
440
+ event.preventDefault();
441
+ const email = element<HTMLInputElement>("recovery-email")?.value || "";
442
+ const password = element<HTMLInputElement>("new-password")?.value || "";
443
+ const confirmation =
444
+ element<HTMLInputElement>("confirm-password")?.value || "";
445
+ void action(async () => {
446
+ if (route === "forgot") {
447
+ await sdk.fidjForgotPasswordRequest(email);
448
+ message =
449
+ "If an account matches that email, a reset link is on its way. Check your inbox and spam folder.";
450
+ } else if (route === "reset") {
451
+ if (password !== confirmation)
452
+ throw new Error("The passwords do not match.");
453
+ if (new TextEncoder().encode(password).length > 72)
454
+ throw new Error(
455
+ "Use a shorter password: the limit is 72 UTF-8 bytes.",
456
+ );
457
+ await sdk.resetPassword({ token: linkToken, password });
458
+ linkToken = "";
459
+ signedIn = false;
460
+ anonymous = false;
461
+ message =
462
+ "Your password has been changed. Sign in with your new password.";
463
+ navigate("signin");
464
+ } else {
465
+ await sdk.verifyEmail({ token: linkToken });
466
+ verificationConfirmed = true;
467
+ linkToken = "";
468
+ message =
469
+ "Your email address is now verified. You can return to your app.";
470
+ }
471
+ });
472
+ });
473
+ }
474
+ window.addEventListener("hashchange", render);
475
+ render();
476
+ void action(async () => {
477
+ if (oidc && new URL(window.location.href).searchParams.has("state")) {
478
+ const callback = new URL(window.location.href);
479
+ window.history.replaceState(null, "", window.location.pathname + "#/content");
480
+ await oidc.completeLogin(callback);
481
+ }
482
+ await sdk.init(config.appId, {
483
+ apiEndpoint: config.apiEndpoint,
484
+ prod: !config.localDemo,
485
+ });
486
+ if (sdk.isLoggedIn()) {
487
+ await refresh();
488
+ if (!moduleRoute() && !accountRoutes.includes(currentRoute()))
489
+ navigate("content");
490
+ }
491
+ });
@@ -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
+ }