ultimate-jekyll-manager 1.3.7 → 1.3.10

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/CHANGELOG.md CHANGED
@@ -14,6 +14,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ ---
18
+ ## [1.3.10] - 2026-05-24
19
+
20
+ ### Added
21
+
22
+ - **Generate signin link** — advanced-user feature on `/account#security` that creates a temporary `/signin?authCustomToken=<token>` URL via `POST /backend-manager/user/token`. Centered text-link trigger sits under the Active sessions card (same Bootstrap utility classes as Cancel subscription: `btn btn-link btn-sm text-muted text-decoration-underline opacity-50`). Opens a danger-bordered modal with a typed-phrase gate (`I will not share this link`) that enables the red Generate button. On success, the warning view swaps in-place for the link inside a readonly monospaced input + Copy button, with a prominent 1-hour expiry warning. Token never persists outside the input — `show.bs.modal` resets state every open. Backend route already existed (`functions/routes/user/token/post.js`) and the existing `handleCustomTokenSignin` flow in `src/assets/js/libs/auth.js` consumes the resulting URL.
23
+
24
+ ### Changed
25
+
26
+ - **Account-page marketing toggle now requires an explicit "Save preferences" button.** v1.3.9 made the toggle auto-submit on change. Cleaner UX: user flips the toggle, sees the new state visually, then clicks Save when they actually want to commit. Markup adds a `<button type="submit" class="btn btn-primary">Save preferences</button>` to the `#marketing-emails-form` (classy account template). JS drops the `addEventListener('change', () => formManager.submit())` line. On failure, the toggle stays where the user left it (no auto-revert) — they see the error message via FormManager and can hit Save again.
27
+
28
+ ### Removed
29
+
30
+ - **`.cancel-trigger-link` SCSS class.** Replaced with Bootstrap utilities on both the existing Cancel subscription button and the new Generate signin link button (`text-muted opacity-50` instead of the custom `opacity: 0.7` + hover transition + `0.8125rem` font size). Avoids inventing project-specific classes when Bootstrap utilities cover the same styling.
31
+
32
+ ---
33
+ ## [1.3.9] - 2026-05-24
34
+
35
+ ### Fixed
36
+
37
+ - **Account-page marketing toggle showed "Failed to update email preferences" even on successful unsubscribe.** Root cause: `pages/account/sections/notifications.js` was checking `response.data?.success !== true`, but `authorizedFetch` returns the JSON body directly (no `data` wrapper), and BEM's `assistant.respond({ success: true })` writes the object at the response root via `res.json(response)`. So `response.success === true` and `response.data?.success === undefined` — the check fired even though the backend had successfully written `consent.marketing.status = 'revoked'` AND removed the contact from SendGrid + Beehiiv. Frontend UX showed a danger toast and reverted the toggle, but server state was already correct, putting the UI out of sync with reality. Fix: check `response.success` at the root.
38
+
39
+ ### Changed
40
+
41
+ - **`pages/account/sections/notifications.js` refactored to use FormManager**, matching the project rule that all user-driven API forms use form-manager for in-flight/success/error UX. The toggle is now wrapped in `<form id="marketing-emails-form">` (classy account template, `src/defaults/dist/_layouts/themes/classy/frontend/pages/account/index.html`), and the change event triggers `formManager.submit()`. Success notification uses `formManager.showSuccess()`; failure throws so FormManager surfaces the error toast and the JS reverts the toggle to its last-known-good state. Replaces the ad-hoc `addEventListener('change')` + raw `authorizedFetch` + manual `webManager.utilities().showNotification()` pattern.
42
+
43
+ ---
44
+ ## [1.3.8] - 2026-05-24
45
+
46
+ ### Fixed
47
+
48
+ - **Reverse-signup now keeps the user on `/signin` so they actually see the inline error.** v1.3.7 fixed `isNewUser` detection, but a follow-on race appeared: when Firebase's `getRedirectResult()` returns a fresh-signup user, the auth-state-change listener in `core/auth.js` fires `state.user = <about-to-be-deleted>` BEFORE `reverseAccidentalSignup`'s `await newUser.delete() → signOut()` chain completes. The listener's `policy === 'unauthenticated'` branch then redirects to `/account` (or `authReturnUrl`), and by the time the inline `showError()` call fires, the user is already off the page. Fixed with a `window.__UJM_REVERSING_SIGNUP` flag set synchronously before the delete + cleared after signOut's followup state-change. The listener checks the flag at the top and short-circuits the entire callback — no redirect, no metadata POST, no consent guard, nothing — until the reversal completes and the user lands on `user = null` with the inline error visible on `/signin`.
49
+
17
50
  ---
18
51
  ## [1.3.7] - 2026-05-24
19
52
 
@@ -78,16 +78,6 @@
78
78
  background-color: var(--bs-tertiary-bg);
79
79
  }
80
80
 
81
- .cancel-trigger-link {
82
- font-size: 0.8125rem;
83
- opacity: 0.7;
84
- transition: opacity 0.2s;
85
-
86
- &:hover {
87
- opacity: 1;
88
- }
89
- }
90
-
91
81
  #cancel-subscription-btn {
92
82
  &:disabled {
93
83
  opacity: 0.5;
@@ -51,6 +51,16 @@ export default function () {
51
51
  // Log
52
52
  console.log('[Auth] state changed:', state);
53
53
 
54
+ // Short-circuit if a reverse-signup is in progress (libs/auth.js#reverseAccidentalSignup
55
+ // sets this synchronously before .delete() + signOut()). Without this, the brief
56
+ // window where Firebase shows user=<about-to-be-deleted-account> would trigger the
57
+ // policy-based redirect to /account (or authReturnUrl) BEFORE the user sees the
58
+ // inline error on /signin. Flag is cleared at the end of reverseAccidentalSignup.
59
+ if (window.__UJM_REVERSING_SIGNUP) {
60
+ console.warn('[Auth] Skipping state-change processing — reverse-signup in progress');
61
+ return;
62
+ }
63
+
54
64
  // Set user ID for analytics tracking
55
65
  setAnalyticsUserId(user);
56
66
 
@@ -125,6 +125,16 @@ export default function () {
125
125
  async function reverseAccidentalSignup(newUser) {
126
126
  console.warn('[Auth] Reversing accidental signup from /signin (new Google account created with no consent on record)');
127
127
 
128
+ // SYNCHRONOUSLY flag the reversal so the auth-state-change listener in
129
+ // core/auth.js short-circuits its policy-based redirect for this user.
130
+ // Without this, Firebase's redirect-result-success path triggers an auth
131
+ // state change with user=<the-about-to-be-deleted-account> BEFORE we
132
+ // finish .delete() + signOut(), and the listener redirects to /account
133
+ // (or authReturnUrl) before the user ever sees the inline error.
134
+ // Cleared in the finally block after signOut() has fired the followup
135
+ // auth-state-change with user=null.
136
+ window.__UJM_REVERSING_SIGNUP = true;
137
+
128
138
  try {
129
139
  await newUser.delete();
130
140
  } catch (e) {
@@ -152,6 +162,11 @@ export default function () {
152
162
  formManager.showError(`This account doesn't exist. Try signing up first or use a different account.`);
153
163
  formManager.ready();
154
164
  }
165
+
166
+ // Clear the flag now that signOut() has fired its auth-state-change
167
+ // with user=null. Future state changes (e.g. user re-clicks Continue
168
+ // with Google after seeing the error) get normal listener processing.
169
+ window.__UJM_REVERSING_SIGNUP = false;
155
170
  }
156
171
 
157
172
  // Validate that the user has agreed to the legal terms. Instead of highlighting the
@@ -2,25 +2,71 @@
2
2
  * Notifications section — marketing email consent toggle.
3
3
  *
4
4
  * Reads consent.marketing.status from the user doc for the toggle's initial state.
5
- * On toggle, POSTs to /backend-manager/marketing/email-preferences with subscribe|unsubscribe.
5
+ * User flips the toggle then clicks Save; on submit, POSTs to
6
+ * /backend-manager/marketing/email-preferences with subscribe|unsubscribe.
6
7
  * The server writes consent.marketing to the user doc + syncs SendGrid + Beehiiv.
8
+ *
9
+ * Uses FormManager for standard in-flight/success/error UX. On failure, the
10
+ * error message is shown via FormManager and the toggle stays where the user
11
+ * left it — they can try Save again without re-flipping.
7
12
  */
8
13
  import authorizedFetch from '__main_assets__/js/libs/authorized-fetch.js';
14
+ import { FormManager } from '__main_assets__/js/libs/form-manager.js';
9
15
  import webManager from 'web-manager';
10
16
 
17
+ const FORM_ID = 'marketing-emails-form';
11
18
  const TOGGLE_ID = 'marketing-emails';
12
19
  const GRANT_DATE_ID = 'marketing-emails-grant-date';
13
20
 
21
+ let formManager = null;
22
+
14
23
  export function init() {
15
- const $toggle = document.getElementById(TOGGLE_ID);
16
- if (!$toggle) {
24
+ const $form = document.getElementById(FORM_ID);
25
+ if (!$form) {
17
26
  return;
18
27
  }
19
- $toggle.addEventListener('change', handleToggleChange);
28
+
29
+ formManager = new FormManager(`#${FORM_ID}`, {
30
+ autoReady: false, // Wait for loadData() to populate the toggle
31
+ allowResubmit: true, // Save → flip again → Save again is normal flow
32
+ warnOnUnsavedChanges: false, // Toggle changes are explicit-Save, not draft
33
+ });
34
+
35
+ formManager.on('submit', async ({ data }) => {
36
+ const action = data.enabled ? 'subscribe' : 'unsubscribe';
37
+
38
+ const response = await authorizedFetch(`${webManager.getApiUrl()}/backend-manager/marketing/email-preferences`, {
39
+ method: 'POST',
40
+ timeout: 60000,
41
+ response: 'json',
42
+ tries: 2,
43
+ body: { action },
44
+ });
45
+
46
+ if (!response?.success) {
47
+ throw new Error(response?.message || 'Failed to update email preferences. Please try again.');
48
+ }
49
+
50
+ formManager.showSuccess(
51
+ data.enabled
52
+ ? 'Subscribed to email updates.'
53
+ : 'Unsubscribed from email updates.'
54
+ );
55
+
56
+ // Hide the grant-date line on unsubscribe (the displayed date was the OLD grant;
57
+ // informational only). loadData() will repopulate it on the next page load if
58
+ // the user re-subscribes.
59
+ if (!data.enabled) {
60
+ const $date = document.getElementById(GRANT_DATE_ID);
61
+ if ($date) {
62
+ $date.classList.add('d-none');
63
+ }
64
+ }
65
+ });
20
66
  }
21
67
 
22
68
  export function loadData(account) {
23
- if (!account) {
69
+ if (!account || !formManager) {
24
70
  return;
25
71
  }
26
72
 
@@ -42,54 +88,6 @@ export function loadData(account) {
42
88
  $date.classList.remove('d-none');
43
89
  }
44
90
  }
45
- }
46
-
47
- async function handleToggleChange(event) {
48
- const $toggle = event.target;
49
- const wasChecked = !$toggle.checked; // checkbox already flipped at this point
50
- const isEnabled = $toggle.checked;
51
- const action = isEnabled ? 'subscribe' : 'unsubscribe';
52
-
53
- // Disable while in-flight so rapid clicks don't fire multiple requests
54
- $toggle.disabled = true;
55
-
56
- try {
57
- const response = await authorizedFetch(`${webManager.getApiUrl()}/backend-manager/marketing/email-preferences`, {
58
- method: 'POST',
59
- timeout: 15000,
60
- response: 'json',
61
- tries: 2,
62
- body: { action },
63
- });
64
-
65
- if (response.error || response.data?.success !== true) {
66
- throw new Error(response.message || response.error || 'Failed to update email preferences.');
67
- }
68
91
 
69
- webManager.utilities().showNotification(
70
- isEnabled ? 'Subscribed to email updates.' : 'Unsubscribed from email updates.',
71
- { type: 'success' }
72
- );
73
-
74
- // Hide the grant-date line on unsubscribe (it was the OLD grant date — informational only).
75
- // It'll get repopulated the next time loadData runs if the user re-subscribes.
76
- if (!isEnabled) {
77
- const $date = document.getElementById(GRANT_DATE_ID);
78
- if ($date) {
79
- $date.classList.add('d-none');
80
- }
81
- }
82
- } catch (error) {
83
- console.error('Failed to update marketing consent:', error);
84
-
85
- // Revert toggle on failure so UI matches server state
86
- $toggle.checked = wasChecked;
87
-
88
- webManager.utilities().showNotification(
89
- 'Failed to update email preferences. Please try again.',
90
- { type: 'danger' }
91
- );
92
- } finally {
93
- $toggle.disabled = false;
94
- }
92
+ formManager.ready();
95
93
  }
@@ -20,6 +20,7 @@ const useAuthPopup = url.searchParams.get('authPopup') === 'true' || window !==
20
20
  export function init() {
21
21
  initializeSigninMethods();
22
22
  initializeSignoutAllForm();
23
+ initializeSigninLinkGenerator();
23
24
  }
24
25
 
25
26
  // Load security data
@@ -401,6 +402,96 @@ function initializeSignoutAllForm() {
401
402
  }
402
403
  }
403
404
 
405
+ // Initialize signin link generator (advanced feature).
406
+ // Creates a temporary signin URL using a Firebase custom token. The link grants
407
+ // full account access to anyone who holds it, so we gate it behind a typed
408
+ // confirmation phrase before hitting BEM's /user/token route.
409
+ function initializeSigninLinkGenerator() {
410
+ const $modal = document.getElementById('generate-signin-link-modal');
411
+ if (!$modal) {
412
+ return;
413
+ }
414
+
415
+ const $phrase = document.getElementById('signin-link-confirm-phrase');
416
+ const $input = document.getElementById('signin-link-confirm-input');
417
+ const $generateBtn = document.getElementById('signin-link-generate-btn');
418
+ const $warningView = document.getElementById('generate-signin-link-warning');
419
+ const $resultView = document.getElementById('generate-signin-link-result');
420
+ const $output = document.getElementById('signin-link-output');
421
+ const $copyBtn = document.getElementById('signin-link-copy-btn');
422
+
423
+ const expectedPhrase = $phrase.textContent.trim();
424
+
425
+ // Reset modal state every time it opens. The custom token is never persisted
426
+ // outside the input — closing the modal must drop it from the DOM.
427
+ $modal.addEventListener('show.bs.modal', () => {
428
+ $input.value = '';
429
+ $output.value = '';
430
+ $generateBtn.disabled = true;
431
+ $warningView.classList.remove('d-none');
432
+ $resultView.classList.add('d-none');
433
+ });
434
+
435
+ // Enable Generate only when the typed phrase matches exactly.
436
+ $input.addEventListener('input', () => {
437
+ $generateBtn.disabled = $input.value.trim() !== expectedPhrase;
438
+ });
439
+
440
+ $generateBtn.addEventListener('click', async () => {
441
+ // Defensive: the button is disabled until the phrase matches, but re-check
442
+ // in case anything bypassed the input handler.
443
+ if ($input.value.trim() !== expectedPhrase) {
444
+ return;
445
+ }
446
+
447
+ const originalText = $generateBtn.querySelector('.button-text').textContent;
448
+ $generateBtn.disabled = true;
449
+ $generateBtn.querySelector('.button-text').textContent = 'Generating...';
450
+
451
+ try {
452
+ const tokenURL = `${webManager.getApiUrl()}/backend-manager/user/token`;
453
+ const data = await authorizedFetch(tokenURL, {
454
+ method: 'POST',
455
+ timeout: 60000,
456
+ response: 'json',
457
+ tries: 2,
458
+ });
459
+
460
+ const token = data?.token;
461
+ if (!token) {
462
+ throw new Error('No token returned from server');
463
+ }
464
+
465
+ const signinURL = new URL('/signin', window.location.origin);
466
+ signinURL.searchParams.set('authCustomToken', token);
467
+
468
+ $output.value = signinURL.toString();
469
+ $warningView.classList.add('d-none');
470
+ $resultView.classList.remove('d-none');
471
+ } catch (error) {
472
+ console.error('[Security] Failed to generate signin link:', error);
473
+ webManager.utilities().showNotification(
474
+ `Failed to generate signin link: ${error.message || 'Unknown error'}`,
475
+ { type: 'danger', timeout: 8000 }
476
+ );
477
+ $generateBtn.disabled = false;
478
+ } finally {
479
+ $generateBtn.querySelector('.button-text').textContent = originalText;
480
+ }
481
+ });
482
+
483
+ $copyBtn.addEventListener('click', async () => {
484
+ try {
485
+ await navigator.clipboard.writeText($output.value);
486
+ webManager.utilities().showNotification('Signin link copied to clipboard', 'success');
487
+ } catch (error) {
488
+ $output.select();
489
+ document.execCommand('copy');
490
+ webManager.utilities().showNotification('Signin link copied to clipboard', 'success');
491
+ }
492
+ });
493
+ }
494
+
404
495
  // Connect Google provider
405
496
  async function connectGoogleProvider() {
406
497
  // Dynamic import of Firebase auth methods
@@ -863,6 +863,73 @@ badges:
863
863
  </div>
864
864
  </div>
865
865
  </div>
866
+
867
+ <!-- Generate Signin Link (advanced) -->
868
+ <div id="generate-signin-link-trigger" class="text-center">
869
+ <button type="button" class="btn btn-link btn-sm text-muted text-decoration-underline opacity-50" data-bs-toggle="modal" data-bs-target="#generate-signin-link-modal">
870
+ Generate signin link
871
+ </button>
872
+ </div>
873
+
874
+ <!-- Generate Signin Link Modal -->
875
+ <div class="modal fade" id="generate-signin-link-modal" tabindex="-1" aria-labelledby="generate-signin-link-title" aria-hidden="true">
876
+ <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
877
+ <div class="modal-content border border-danger">
878
+ <div class="modal-header">
879
+ <h5 class="modal-title text-danger" id="generate-signin-link-title">
880
+ {% uj_icon "triangle-exclamation", "fa-lg me-2" %}
881
+ Generate signin link
882
+ </h5>
883
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
884
+ </div>
885
+ <div class="modal-body">
886
+ <!-- Warning view -->
887
+ <div id="generate-signin-link-warning">
888
+ <div class="alert alert-danger">
889
+ <strong>This is a dangerous feature for advanced users.</strong>
890
+ <p class="mb-2 mt-2 small">
891
+ Generating a signin link creates a single URL that lets <strong>anyone who has it</strong> sign in to your account — no password, no 2FA, no email verification.
892
+ </p>
893
+ <ul class="small mb-0">
894
+ <li>Use it only to sign in to your own account on another browser or device.</li>
895
+ <li><strong>Never share, message, email, or paste this link anywhere.</strong> Anyone with the link gets full access to your account.</li>
896
+ <li>The link expires in 1 hour, but treat it as a live key until then.</li>
897
+ <li>If you suspect the link was exposed, sign out of all sessions immediately.</li>
898
+ </ul>
899
+ </div>
900
+
901
+ <p class="mb-2">Type the following phrase exactly to confirm you understand the risks:</p>
902
+ <p class="mb-2"><code id="signin-link-confirm-phrase">I will not share this link</code></p>
903
+
904
+ <input type="text" class="form-control" id="signin-link-confirm-input" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Type the phrase above">
905
+ </div>
906
+
907
+ <!-- Result view (hidden until generated) -->
908
+ <div id="generate-signin-link-result" class="d-none">
909
+ <div class="alert alert-warning small mb-3">
910
+ {% uj_icon "clock", "me-1" %}
911
+ This link expires in <strong>1 hour</strong>. Use it immediately and do not share it.
912
+ </div>
913
+ <label for="signin-link-output" class="form-label small text-muted mb-1">Your signin link:</label>
914
+ <div class="input-group">
915
+ <input type="text" class="form-control font-monospace small" id="signin-link-output" readonly>
916
+ <button type="button" class="btn btn-outline-primary" id="signin-link-copy-btn">
917
+ {% uj_icon "copy", "me-1" %}
918
+ <span class="button-text">Copy</span>
919
+ </button>
920
+ </div>
921
+ </div>
922
+ </div>
923
+ <div class="modal-footer">
924
+ <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
925
+ <button type="button" class="btn btn-danger" id="signin-link-generate-btn" disabled>
926
+ {% uj_icon "key", "me-1" %}
927
+ <span class="button-text">Generate link</span>
928
+ </button>
929
+ </div>
930
+ </div>
931
+ </div>
932
+ </div>
866
933
  </section>
867
934
 
868
935
  <!-- Connections Section -->
@@ -1026,7 +1093,7 @@ badges:
1026
1093
 
1027
1094
  <!-- Cancel Subscription (only visible for paid users) -->
1028
1095
  <div id="cancel-subscription-trigger" class="text-center" data-wm-bind="@show billing.buttons.cancel" hidden>
1029
- <button type="button" class="btn btn-link btn-sm text-muted text-decoration-underline cancel-trigger-link" data-bs-toggle="collapse" data-bs-target="#cancel-subscription-accordion" aria-expanded="false" aria-controls="cancel-subscription-accordion">
1096
+ <button type="button" class="btn btn-link btn-sm text-muted text-decoration-underline opacity-50" data-bs-toggle="collapse" data-bs-target="#cancel-subscription-accordion" aria-expanded="false" aria-controls="cancel-subscription-accordion">
1030
1097
  Cancel subscription
1031
1098
  </button>
1032
1099
  </div>
@@ -1195,14 +1262,19 @@ badges:
1195
1262
  <div class="card-body">
1196
1263
  <h5 class="card-title">Email preferences</h5>
1197
1264
 
1198
- <div class="form-check form-switch">
1199
- <input class="form-check-input" type="checkbox" id="marketing-emails">
1200
- <label class="form-check-label" for="marketing-emails">
1201
- Product updates, newsletters, and marketing communications
1202
- <small class="d-block text-muted">You can withdraw consent at any time.</small>
1203
- <small id="marketing-emails-grant-date" class="d-block text-muted d-none"></small>
1204
- </label>
1205
- </div>
1265
+ <form id="marketing-emails-form" novalidate>
1266
+ <div class="form-check form-switch mb-3">
1267
+ <input class="form-check-input" type="checkbox" id="marketing-emails" name="enabled">
1268
+ <label class="form-check-label" for="marketing-emails">
1269
+ Product updates, newsletters, and marketing communications
1270
+ <small class="d-block text-muted">You can withdraw consent at any time.</small>
1271
+ <small id="marketing-emails-grant-date" class="d-block text-muted d-none"></small>
1272
+ </label>
1273
+ </div>
1274
+ <button type="submit" class="btn btn-primary">
1275
+ <span class="button-text">Save preferences</span>
1276
+ </button>
1277
+ </form>
1206
1278
  </div>
1207
1279
  </div>
1208
1280
  </section>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.3.7",
3
+ "version": "1.3.10",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {