ultimate-jekyll-manager 1.3.8 → 1.3.11
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 +33 -0
- package/dist/assets/css/pages/account/index.scss +0 -10
- package/dist/assets/js/pages/account/sections/notifications.js +52 -54
- package/dist/assets/js/pages/account/sections/security.js +91 -0
- package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/account/index.html +82 -10
- package/package.json +1 -1
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.11] - 2026-05-24
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **Account page UI polish.** Three small consistency fixes on `/account`: (1) Notifications "Save preferences" button now has the `floppy-disk` icon to match the profile section's "Save Changes" button. (2) Generate signin link modal no longer shows a redundant Cancel button in its footer — the X in the modal header already dismisses it. (3) Connections "Manage connections" card no longer renders a divider between the section title and the first item (dropped `list-group list-group-flush` from `#connections-list`); the per-item `border-top` already handles inter-item separation, matching the Sign-in methods card's pattern.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
## [1.3.10] - 2026-05-24
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
37
|
+
- **`.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.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
## [1.3.9] - 2026-05-24
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **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.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **`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.
|
|
49
|
+
|
|
17
50
|
---
|
|
18
51
|
## [1.3.8] - 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;
|
|
@@ -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
|
-
*
|
|
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 $
|
|
16
|
-
if (!$
|
|
24
|
+
const $form = document.getElementById(FORM_ID);
|
|
25
|
+
if (!$form) {
|
|
17
26
|
return;
|
|
18
27
|
}
|
|
19
|
-
|
|
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
|
-
|
|
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,72 @@ 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-danger" id="signin-link-generate-btn" disabled>
|
|
925
|
+
{% uj_icon "key", "me-1" %}
|
|
926
|
+
<span class="button-text">Generate link</span>
|
|
927
|
+
</button>
|
|
928
|
+
</div>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
866
932
|
</section>
|
|
867
933
|
|
|
868
934
|
<!-- Connections Section -->
|
|
@@ -874,7 +940,7 @@ badges:
|
|
|
874
940
|
<h5 class="card-title">Manage connections</h5>
|
|
875
941
|
<p class="text-muted mb-0">Connect your external accounts to access additional features.</p>
|
|
876
942
|
|
|
877
|
-
<div id="connections-list"
|
|
943
|
+
<div id="connections-list">
|
|
878
944
|
<!-- Loading state -->
|
|
879
945
|
<div id="connections-loading" class="text-center py-3">
|
|
880
946
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
|
@@ -1026,7 +1092,7 @@ badges:
|
|
|
1026
1092
|
|
|
1027
1093
|
<!-- Cancel Subscription (only visible for paid users) -->
|
|
1028
1094
|
<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
|
|
1095
|
+
<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
1096
|
Cancel subscription
|
|
1031
1097
|
</button>
|
|
1032
1098
|
</div>
|
|
@@ -1195,14 +1261,20 @@ badges:
|
|
|
1195
1261
|
<div class="card-body">
|
|
1196
1262
|
<h5 class="card-title">Email preferences</h5>
|
|
1197
1263
|
|
|
1198
|
-
<
|
|
1199
|
-
<
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1264
|
+
<form id="marketing-emails-form" novalidate>
|
|
1265
|
+
<div class="form-check form-switch mb-3">
|
|
1266
|
+
<input class="form-check-input" type="checkbox" id="marketing-emails" name="enabled">
|
|
1267
|
+
<label class="form-check-label" for="marketing-emails">
|
|
1268
|
+
Product updates, newsletters, and marketing communications
|
|
1269
|
+
<small class="d-block text-muted">You can withdraw consent at any time.</small>
|
|
1270
|
+
<small id="marketing-emails-grant-date" class="d-block text-muted d-none"></small>
|
|
1271
|
+
</label>
|
|
1272
|
+
</div>
|
|
1273
|
+
<button type="submit" class="btn btn-primary">
|
|
1274
|
+
{% uj_icon "floppy-disk", "me-2" %}
|
|
1275
|
+
<span class="button-text">Save preferences</span>
|
|
1276
|
+
</button>
|
|
1277
|
+
</form>
|
|
1206
1278
|
</div>
|
|
1207
1279
|
</div>
|
|
1208
1280
|
</section>
|