@ofidj/generator-fidj 1.3.1 → 1.4.1

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.
@@ -274,6 +274,28 @@ function startModule() {
274
274
  }
275
275
  }
276
276
  function render() {
277
+ // Once the mounted app owns the document the shell cannot draw over it, and
278
+ // its own router will try to match addresses that were never its business.
279
+ // So any address it does not own means starting the document again — checked
280
+ // first, because every later branch writes into an element that is gone.
281
+ if (moduleStarted && !moduleRoute()) {
282
+ window.location.reload();
283
+ return;
284
+ }
285
+ // The provider handed this person here to answer a question. Nothing else
286
+ // this app might want to show belongs on the screen until they have.
287
+ if (interactionId) {
288
+ if (interactionFailed) {
289
+ root.innerHTML = `<section class="card"><p role="alert" class="error">${escape(message)}</p><p><a href="#/signin">Back to sign in</a></p></section>`;
290
+ return;
291
+ }
292
+ if (!interaction) {
293
+ root.innerHTML = '<p role="status">Loading…</p>';
294
+ return;
295
+ }
296
+ interactionScreen();
297
+ return;
298
+ }
277
299
  if (!initialized) {
278
300
  root.innerHTML = '<p role="status">Loading your session…</p>';
279
301
  return;
@@ -285,11 +307,6 @@ function render() {
285
307
  startModule();
286
308
  return;
287
309
  }
288
- // The mounted app has taken the document; the shell cannot draw over it.
289
- if (moduleStarted) {
290
- window.location.reload();
291
- return;
292
- }
293
310
  let route = currentRoute();
294
311
  if (
295
312
  !signedIn &&
@@ -519,6 +536,144 @@ function accountForm(route: string) {
519
536
  : `<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>`;
520
537
  }
521
538
 
539
+
540
+ // ------------------------------------------------ signing in, for the provider
541
+ //
542
+ // When Fidj's provider needs a person to identify themselves it hands them to a
543
+ // Fidj front end — this one — rather than serving a page of its own. So this
544
+ // screen belongs to the provider's conversation, not to this app's: it says who
545
+ // is asking, collects what is being asked for, and posts it straight back.
546
+ //
547
+ // The post is a real form navigation, not a fetch: the provider answers with a
548
+ // redirect that carries the person onward through the authorization, and only a
549
+ // navigation can follow it. The single-use token comes from the context call,
550
+ // which is the only thing that reads the interaction cookie.
551
+ type Interaction = {
552
+ prompt: string;
553
+ csrf: string;
554
+ app: { id: string; title: string; description: string };
555
+ scopes: string[];
556
+ termsUri: string;
557
+ privacyUri: string;
558
+ action: string;
559
+ };
560
+ let interactionId = "";
561
+ let interactionError = "";
562
+ let interaction: Interaction | null = null;
563
+ let interactionFailed = false;
564
+
565
+ const scopeMeaning: Record<string, string> = {
566
+ openid: "An identity specific to this app",
567
+ profile: "Your display name",
568
+ email: "Your email and verification status",
569
+ offline_access: "Stay signed in",
570
+ "fidj:api": "Use Fidj account and privacy services for this app",
571
+ };
572
+
573
+ const refusals: Record<string, string> = {
574
+ credentials: "We could not sign you in. Check your email and password.",
575
+ signup:
576
+ "Could not create an account. Use a valid email and a password of at least 12 characters, or sign in to your existing account.",
577
+ agreement: "Accept the app's service agreement to continue.",
578
+ refused: "That could not be completed. Please try again.",
579
+ };
580
+
581
+ function readInteraction() {
582
+ const query = window.location.hash.slice(2).split("?")[1] || "";
583
+ const parameters = new URLSearchParams(query);
584
+ const uid = parameters.get("interaction") || "";
585
+ if (!uid) return false;
586
+ interactionId = uid;
587
+ interactionError = parameters.get("error") || "";
588
+ // The id stays in the address while the screen is up. Taking it out looked
589
+ // tidier and made the screen a trap: the address became "#/signin", so going
590
+ // back to "#/signin" changed nothing, the document never reloaded, and the
591
+ // person stayed on a screen they had asked to leave. It is single-use, it is
592
+ // where the provider put it, and the form navigates away from it.
593
+ return true;
594
+ }
595
+
596
+ async function loadInteraction() {
597
+ const endpoint = new URL(
598
+ `/oidc/interaction/${encodeURIComponent(interactionId)}/context`,
599
+ config.apiEndpoint,
600
+ );
601
+ const response = await fetch(endpoint.href, {
602
+ credentials: "include",
603
+ headers: {Accept: "application/json"},
604
+ signal: AbortSignal.timeout(10000),
605
+ });
606
+ if (!response.ok) throw new Error("This sign-in has expired. Start again from the app.");
607
+ interaction = (await response.json()) as Interaction;
608
+ }
609
+
610
+ function interactionScreen() {
611
+ const details = interaction!;
612
+ const asking = escape(details.app.title);
613
+ const notice = interactionError
614
+ ? `<p role="alert" class="error">${escape(refusals[interactionError] || refusals.refused)}</p>`
615
+ : "";
616
+ const action = new URL(details.action, config.apiEndpoint).href;
617
+ // A refusal comes back as a redirect, so the typed address would be lost —
618
+ // and retyping an address is the part a person gets wrong twice. It is kept
619
+ // in this browser, never in the address: nothing about them travels in a URL.
620
+ let typed = "";
621
+ try {
622
+ typed = sessionStorage.getItem("fidj.interaction.email") || "";
623
+ } catch {}
624
+ const body =
625
+ details.prompt === "login"
626
+ ? `<h2>Sign in to continue to ${asking}</h2>
627
+ <p class="signin-lead">This is Fidj, the account behind ${asking}. One account, and separate choices for every app that uses it — ${asking} never sees your password.</p>
628
+ ${notice}
629
+ <form method="post" action="${escape(action)}" id="interaction">
630
+ <input type="hidden" name="csrf" value="${escape(details.csrf)}">
631
+ <label for="email">Email</label><input id="email" name="email" type="email" value="${escape(typed)}" autocomplete="username" required>
632
+ <div class="field-head"><label for="password">Password</label><a href="${escape(config.dashboardUrl)}/#/forgot">Forgot?</a></div>
633
+ <div class="password-field"><input id="password" name="password" type="password" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div>
634
+ <button class="primary" type="submit" name="action" value="continue">Sign in</button>
635
+ <button class="secondary" type="submit" name="action" value="signup">Create a Fidj account</button>
636
+ <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
637
+ </form>`
638
+ : `<h2>Continue to ${asking}</h2>
639
+ <p class="signin-lead">${asking} is asking for the information below. Optional privacy choices stay separate, and you can change them in Fidj at any time.</p>
640
+ ${notice}
641
+ <ul class="scope-list">${details.scopes
642
+ .filter((scope) => scopeMeaning[scope])
643
+ .map((scope) => `<li>${escape(scopeMeaning[scope])}</li>`)
644
+ .join("")}</ul>
645
+ <form method="post" action="${escape(action)}" id="interaction">
646
+ <input type="hidden" name="csrf" value="${escape(details.csrf)}">
647
+ <label class="agreement-choice"><input type="checkbox" name="terms" value="true" required><span>I accept ${asking}'s service agreement.</span></label>
648
+ ${details.termsUri ? `<p class="fineprint"><a href="${escape(details.termsUri)}" target="_blank" rel="noopener noreferrer">Service agreement</a>${details.privacyUri ? ` · <a href="${escape(details.privacyUri)}" target="_blank" rel="noopener noreferrer">Privacy notice</a>` : ""}</p>` : ""}
649
+ <button class="primary" type="submit" name="action" value="continue">Allow and continue</button>
650
+ <button class="quiet" type="submit" name="action" value="cancel" formnovalidate>Cancel and go back</button>
651
+ </form>`;
652
+
653
+ 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>
654
+ <div class="signin-identity"><h1>Your identity.<br>Your choices.</h1><p class="signin-description">One account across every app that uses Fidj, and a separate set of choices for each one.</p></div>
655
+ ${highlights()}</div>
656
+ <div class="signin-form"><div>${body}</div>
657
+ <div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>What Fidj is</strong></p><p>Fidj holds your account so each app does not have to. You can see every app you use, what it holds, and take it back — at any time.</p></div></div>
658
+ ${badges()}</section>`;
659
+
660
+ element("reveal")?.addEventListener("click", () => {
661
+ const field = element<HTMLInputElement>("password");
662
+ const button = element("reveal");
663
+ if (!field || !button) return;
664
+ const hidden = field.type === "password";
665
+ field.type = hidden ? "text" : "password";
666
+ button.textContent = hidden ? "Hide" : "Show";
667
+ });
668
+ element("interaction")?.addEventListener("submit", () => {
669
+ const address = element<HTMLInputElement>("email")?.value || "";
670
+ try {
671
+ if (address) sessionStorage.setItem("fidj.interaction.email", address);
672
+ else sessionStorage.removeItem("fidj.interaction.email");
673
+ } catch {}
674
+ });
675
+ }
676
+
522
677
  function renderAccount(route: string) {
523
678
  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>
524
679
  <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>
@@ -586,11 +741,28 @@ function wireAccount(route: string) {
586
741
  }
587
742
  window.addEventListener("hashchange", render);
588
743
  render();
744
+ if (readInteraction()) {
745
+ render();
746
+ void loadInteraction()
747
+ .catch((error) => {
748
+ interactionFailed = true;
749
+ failed = true;
750
+ message =
751
+ error instanceof Error
752
+ ? error.message
753
+ : "This sign-in could not be loaded. Start again from the app.";
754
+ })
755
+ .finally(render);
756
+ }
589
757
  void action(async () => {
758
+ if (interactionId) return;
590
759
  if (oidc && new URL(window.location.href).searchParams.has("state")) {
591
760
  const callback = new URL(window.location.href);
592
761
  window.history.replaceState(null, "", window.location.pathname + "#/content");
593
762
  await oidc.completeLogin(callback);
763
+ try {
764
+ sessionStorage.removeItem("fidj.interaction.email");
765
+ } catch {}
594
766
  }
595
767
  await sdk.init(config.appId, {
596
768
  apiEndpoint: config.apiEndpoint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ofidj/generator-fidj",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
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": {