@ofidj/generator-fidj 1.1.0 → 1.2.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.
@@ -1,4 +1,4 @@
1
- import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage} from "./service-agreement";
1
+ import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn} from "./service-agreement";
2
2
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
3
3
  import config from "../app.config.json";
4
4
  import "./style.css";
@@ -58,10 +58,20 @@ function badges() {
58
58
  .map((entry) => `<span>${escape(entry)}</span>`)
59
59
  .join("")}</footer>`;
60
60
  }
61
+ // Signing out of this app revokes this app's access and nothing else: the Fidj
62
+ // session survives, which is what makes the next app free to enter. That is a
63
+ // good design and a bad surprise, so the notice says what actually happened and
64
+ // where the rest of it lives — on a shared computer the difference is the whole
65
+ // point.
66
+ let leftTheApp = false;
61
67
  function banner() {
62
- return message
63
- ? `<p role="${failed ? "alert" : "status"}" class="${failed ? "error" : "notice"}">${escape(message)}</p>`
68
+ if (!message) return "";
69
+ const role = failed ? "alert" : "status";
70
+ const kind = failed ? "error" : "notice";
71
+ const finish = leftTheApp
72
+ ? ` <a href="${escape(config.dashboardUrl)}/#/my/profile" target="_blank" rel="noopener">Sign out of Fidj too</a>`
64
73
  : "";
74
+ return `<p role="${role}" class="${kind}">${escape(message)}${finish}</p>`;
65
75
  }
66
76
 
67
77
  // One navigation bar for every in-app screen, so signing out stays one click
@@ -83,9 +93,15 @@ function wireNav() {
83
93
  "click",
84
94
  () =>
85
95
  void action(async () => {
96
+ const wasSignedIn = signedIn;
86
97
  if (signedIn) await sdk.logout(true);
98
+ forgetSignIn(config.appId);
87
99
  signedIn = false;
88
100
  anonymous = false;
101
+ if (wasSignedIn) {
102
+ leftTheApp = true;
103
+ message = `Signed out of ${config.title}. You are still signed in to Fidj.`;
104
+ }
89
105
  navigate("signin");
90
106
  }),
91
107
  );
@@ -131,8 +147,13 @@ async function refresh() {
131
147
  request(appPath + "/consents"),
132
148
  request(appPath + "/consents/history").then((result) => result.history),
133
149
  ]);
134
- emailVerified = (await request("/me")).user?.verified === true;
150
+ const me = (await request("/me")).user;
151
+ emailVerified = me?.verified === true;
135
152
  signedIn = true;
153
+ // The ID token of a code flow carries only the subject, by design, so the
154
+ // address the entry can offer next time comes from the membership the app
155
+ // just read — not from a claim it does not have.
156
+ rememberSignIn(config.appId, String(me?.poc?.email || me?.username || ""));
136
157
  }
137
158
  async function action(task: () => Promise<void>) {
138
159
  if (busy) return;
@@ -146,7 +167,10 @@ async function action(task: () => Promise<void>) {
146
167
  const submit = root.querySelector<HTMLButtonElement>("button.primary");
147
168
  if (submit) submit.textContent = "Please wait…";
148
169
  failed = false;
149
- if (initialized) message = "";
170
+ if (initialized) {
171
+ message = "";
172
+ leftTheApp = false;
173
+ }
150
174
  try {
151
175
  await task();
152
176
  } catch (error) {
@@ -345,7 +369,12 @@ function render() {
345
369
  anonymous = true;
346
370
  navigate("content");
347
371
  });
348
- 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>';
372
+ if (oidc && element("signin"))
373
+ element("signin")!.innerHTML = providerEntry(config.title, config.appId);
374
+ element("forget-hint")?.addEventListener("click", () => {
375
+ forgetSignIn(config.appId);
376
+ render();
377
+ });
349
378
  void bindAgreement(element<HTMLFormElement>("signin"), config.title, config.apiEndpoint, config.appId, signInAgreementAccepted);
350
379
  element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
351
380
  event.preventDefault();
@@ -384,8 +413,11 @@ function render() {
384
413
  () =>
385
414
  void action(async () => {
386
415
  await sdk.logout(true);
416
+ forgetSignIn(config.appId);
387
417
  signedIn = false;
388
418
  anonymous = false;
419
+ leftTheApp = true;
420
+ message = `Signed out of ${config.title}. You are still signed in to Fidj.`;
389
421
  navigate("signin");
390
422
  }),
391
423
  );
@@ -1,4 +1,4 @@
1
- import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage} from "./service-agreement";
1
+ import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn} from "./service-agreement";
2
2
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
3
3
  import "./style.css";
4
4
  import { showVersionBadge } from "./version";
@@ -66,6 +66,9 @@ async function api(path: string, method = "GET", data?: unknown) {
66
66
  }
67
67
  async function load() {
68
68
  session = await api("session");
69
+ // Remembered on this app's own origin so the entry can offer to continue as
70
+ // them next time; signing out forgets it.
71
+ if (session?.username) rememberSignIn(settings.appId, session.username);
69
72
  [notes, privacy] = await Promise.all([
70
73
  api("notes").then((result) => result.notes),
71
74
  api("privacy"),
@@ -94,7 +97,7 @@ function render() {
94
97
  <main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
95
98
  ${
96
99
  !session
97
- ? `<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>${oidc ? `<p>Continue securely with your Fidj account. Your password stays with Fidj.</p>${agreementMarkup()}<button class="primary" type="submit">Continue with Fidj</button>` : `<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>`
100
+ ? `<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>${oidc ? providerEntry(settings.title, settings.appId) : `<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>`
98
101
  : `
99
102
  <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>
100
103
  <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>
@@ -157,6 +160,7 @@ function render() {
157
160
  () =>
158
161
  void action(async () => {
159
162
  await sdk.logout(true);
163
+ forgetSignIn(settings.appId);
160
164
  session = null;
161
165
  notes = [];
162
166
  privacy = null;
@@ -60,3 +60,55 @@ export async function bindAgreement(form: HTMLFormElement | null, title: string,
60
60
  retry.addEventListener("click", load);
61
61
  await load();
62
62
  }
63
+
64
+ // The entry every app that delegates to the provider renders, in one place
65
+ // because both app shapes render it and they had drifted apart.
66
+ //
67
+ // "Continue with Fidj" alone borrows the grammar of an optional social login —
68
+ // that button always sits next to an email and a password — so on an app whose
69
+ // accounts *are* Fidj accounts, the missing form reads as something broken. The
70
+ // explanation therefore comes before the button, not as reassurance after it,
71
+ // and nothing presupposes an account the person may not have yet.
72
+ const hintKey = (appId: string) => "fidj.entry." + appId;
73
+
74
+ export function signInHint(appId: string) {
75
+ try {
76
+ return localStorage.getItem(hintKey(appId)) || "";
77
+ } catch {
78
+ return "";
79
+ }
80
+ }
81
+
82
+ // Remembered on this app's own origin, about this app's own member: no
83
+ // cross-site question is asked, and none is answered. Signing out forgets, so a
84
+ // shared browser does not show the next person an address.
85
+ export function rememberSignIn(appId: string, label: string) {
86
+ try {
87
+ if (label) localStorage.setItem(hintKey(appId), label);
88
+ } catch {}
89
+ }
90
+
91
+ export function forgetSignIn(appId: string) {
92
+ try {
93
+ localStorage.removeItem(hintKey(appId));
94
+ } catch {}
95
+ }
96
+
97
+ export function providerEntry(title: string, appId: string) {
98
+ const escapeText = (value: unknown) =>
99
+ String(value ?? "").replace(
100
+ /[&<>"']/g,
101
+ (char) =>
102
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
103
+ char
104
+ ]!,
105
+ );
106
+ const hint = signInHint(appId);
107
+ const lead = hint
108
+ ? `<p class="signin-lead">You signed in here with Fidj before. ${escapeText(title)} accounts are Fidj accounts, and this site never sees your password — you can also create or use another one.</p>`
109
+ : `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. You will sign in — or create yours — on Fidj's own page, so this site never sees your password.</p>`;
110
+ const action = hint
111
+ ? `<button class="primary" type="submit">Continue as ${escapeText(hint)}</button><button type="button" id="forget-hint" class="quiet">Use a different account</button>`
112
+ : `<button class="primary" type="submit">Sign in with Fidj</button>`;
113
+ return lead + agreementMarkup() + action;
114
+ }
@@ -935,3 +935,25 @@ summary:focus-visible {
935
935
  .signin-agreement button { margin-block: 0.5rem; padding: 0; border: 0; background: transparent; text-decoration: underline; }
936
936
  #agreement-dialog { color: var(--fidj-text); background: var(--fidj-paper); border: 1px solid var(--fidj-line); border-radius: var(--fidj-radius); padding: 1.5rem; max-width: min(40rem, calc(100vw - 2rem)); max-height: 80vh; overflow: auto; }
937
937
  #agreement-text { white-space: pre-wrap; overflow-wrap: anywhere; }
938
+
939
+ /* The entry explains itself before it offers its button. A person whose account
940
+ * lives somewhere else needs to know that before they look for the form that is
941
+ * not coming, so this is body text above the action, not fineprint below it. */
942
+ .signin-lead {
943
+ margin: 0 0 18px;
944
+ color: var(--fidj-ink-soft, inherit);
945
+ line-height: 1.5;
946
+ }
947
+ /* Present but not competing: "use a different account" is the way out of a
948
+ * remembered address, not an alternative worth the weight of the primary. */
949
+ button.quiet {
950
+ margin-top: 10px;
951
+ background: none;
952
+ border: none;
953
+ color: inherit;
954
+ text-decoration: underline;
955
+ padding: 6px 0;
956
+ font: inherit;
957
+ cursor: pointer;
958
+ width: auto;
959
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Generate a TypeScript app with Fidj sign-in, live server-side roles and per-app privacy.",
5
5
  "homepage": "https://fidj.ovh",
6
6
  "bugs": {