ultimate-jekyll-manager 0.0.281 → 0.0.283
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 +3 -0
- package/dist/assets/js/core/auth.js +37 -0
- package/dist/assets/js/pages/portal/email-preferences/index.js +185 -0
- package/dist/assets/js/pages/token/index.js +25 -2
- package/dist/defaults/dist/_layouts/blueprint/portal/email-preferences.html +16 -0
- package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/portal/email-preferences.html +108 -0
- package/dist/defaults/dist/pages/portal/email-preferences.md +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
17
17
|
---
|
|
18
18
|
## [Unreleased]
|
|
19
19
|
### Added
|
|
20
|
+
- Email preferences page (`/portal/account/email-preferences`) for unsubscribe/resubscribe from marketing emails
|
|
21
|
+
- Email masking on preferences page to prevent forwarded-email abuse (e.g., `ia***b@gm***.com`)
|
|
22
|
+
- HMAC signature verification for unsubscribe links to prevent forged requests
|
|
20
23
|
- Checkout page supports daily, weekly, monthly, and annually billing frequencies with selective UI visibility via wm-bindings
|
|
21
24
|
- Default billing frequency auto-selects the longest available term (annually > monthly > weekly > daily), with URL param override
|
|
22
25
|
- Auth state settles before any authorized fetches fire on checkout, preventing race conditions
|
|
@@ -26,6 +26,10 @@ export default function (Manager, options) {
|
|
|
26
26
|
unauthenticated
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
// LEGACY: Handle desktop app auth params (e.g. ?destination=appscheme://page&source=app)
|
|
30
|
+
// TODO: Remove this call AND the _legacyTranslateAppAuth function when legacy desktop app support is no longer needed
|
|
31
|
+
_legacyTranslateAppAuth();
|
|
32
|
+
|
|
29
33
|
// Track if we just signed out to avoid redirect loops
|
|
30
34
|
let justSignedOut = false;
|
|
31
35
|
|
|
@@ -243,3 +247,36 @@ async function sendUserSignupMetadata(user, webManager) {
|
|
|
243
247
|
// Don't throw - we don't want to block the signup flow
|
|
244
248
|
}
|
|
245
249
|
}
|
|
250
|
+
|
|
251
|
+
// LEGACY: Translate desktop app auth params to UJM format
|
|
252
|
+
// Legacy apps send: ?destination=appscheme://page&source=app&signout=true&cb=timestamp
|
|
253
|
+
// UJM expects: ?authReturnUrl=...&authSignout=true
|
|
254
|
+
// TODO: Remove this function AND its call above when legacy desktop app support is no longer needed
|
|
255
|
+
function _legacyTranslateAppAuth() {
|
|
256
|
+
const url = new URL(window.location.href);
|
|
257
|
+
const destination = url.searchParams.get('destination');
|
|
258
|
+
const source = url.searchParams.get('source');
|
|
259
|
+
|
|
260
|
+
if (source !== 'app' || !destination) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Chain through /token page to generate a custom token before redirecting to the app
|
|
265
|
+
const tokenPageUrl = new URL('/token', window.location.origin);
|
|
266
|
+
tokenPageUrl.searchParams.set('authReturnUrl', destination);
|
|
267
|
+
url.searchParams.set('authReturnUrl', tokenPageUrl.toString());
|
|
268
|
+
|
|
269
|
+
// Translate signout param
|
|
270
|
+
if (url.searchParams.get('signout') === 'true') {
|
|
271
|
+
url.searchParams.set('authSignout', 'true');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Clean up legacy params and update URL
|
|
275
|
+
url.searchParams.delete('destination');
|
|
276
|
+
url.searchParams.delete('source');
|
|
277
|
+
url.searchParams.delete('signout');
|
|
278
|
+
url.searchParams.delete('cb');
|
|
279
|
+
window.history.replaceState({}, '', url.toString());
|
|
280
|
+
|
|
281
|
+
console.log('[Auth] Translated legacy app params:', url.toString());
|
|
282
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Preferences Page JavaScript
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Libraries
|
|
6
|
+
import { FormManager } from '__main_assets__/js/libs/form-manager.js';
|
|
7
|
+
import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js';
|
|
8
|
+
import fetch from 'wonderful-fetch';
|
|
9
|
+
|
|
10
|
+
let webManager = null;
|
|
11
|
+
|
|
12
|
+
// Module
|
|
13
|
+
export default (Manager) => {
|
|
14
|
+
return new Promise(async function (resolve) {
|
|
15
|
+
// Shortcuts
|
|
16
|
+
webManager = Manager.webManager;
|
|
17
|
+
|
|
18
|
+
// Initialize when DOM is ready
|
|
19
|
+
await webManager.dom().ready();
|
|
20
|
+
|
|
21
|
+
setupForm();
|
|
22
|
+
|
|
23
|
+
// Resolve after initialization
|
|
24
|
+
return resolve();
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Decode a base64-encoded URL parameter
|
|
29
|
+
function decodeParam(encoded) {
|
|
30
|
+
if (!encoded) {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
return atob(decodeURIComponent(encoded));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Mask an email address: show first 2 and last char of local part, first 2 chars of domain
|
|
42
|
+
// e.g. "ian.wiedenman+test-unsub@gmail.com" → "ia***************b@gm***.com"
|
|
43
|
+
function maskEmail(email) {
|
|
44
|
+
const [local, domain] = email.split('@');
|
|
45
|
+
|
|
46
|
+
if (!local || !domain) {
|
|
47
|
+
return '***@***.***';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const domainParts = domain.split('.');
|
|
51
|
+
const domainName = domainParts.slice(0, -1).join('.');
|
|
52
|
+
const tld = domainParts.slice(-1)[0];
|
|
53
|
+
|
|
54
|
+
const maskedLocal = local.length <= 3
|
|
55
|
+
? local[0] + '*'.repeat(local.length - 1)
|
|
56
|
+
: local.slice(0, 2) + '*'.repeat(local.length - 3) + local.slice(-1);
|
|
57
|
+
|
|
58
|
+
const maskedDomain = domainName.length <= 2
|
|
59
|
+
? domainName
|
|
60
|
+
: domainName.slice(0, 2) + '*'.repeat(domainName.length - 2);
|
|
61
|
+
|
|
62
|
+
return `${maskedLocal}@${maskedDomain}.${tld}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Setup form handling
|
|
66
|
+
function setupForm() {
|
|
67
|
+
const url = new URL(window.location.href);
|
|
68
|
+
|
|
69
|
+
// Parse and decode URL parameters
|
|
70
|
+
const email = decodeParam(url.searchParams.get('email'));
|
|
71
|
+
const asmId = decodeParam(url.searchParams.get('asmId'));
|
|
72
|
+
const templateId = decodeParam(url.searchParams.get('templateId'));
|
|
73
|
+
const sig = url.searchParams.get('sig') || '';
|
|
74
|
+
|
|
75
|
+
// DOM elements
|
|
76
|
+
const $description = document.getElementById('email-preferences-description');
|
|
77
|
+
const $error = document.getElementById('email-preferences-error');
|
|
78
|
+
const $errorMessage = document.getElementById('email-preferences-error-message');
|
|
79
|
+
const $content = document.getElementById('email-preferences-content');
|
|
80
|
+
const $address = document.getElementById('email-preferences-address');
|
|
81
|
+
const $submit = document.getElementById('email-preferences-submit');
|
|
82
|
+
const $infoUnsubscribe = document.getElementById('email-preferences-info-unsubscribe');
|
|
83
|
+
const $infoResubscribe = document.getElementById('email-preferences-info-resubscribe');
|
|
84
|
+
const $actionButtons = document.querySelectorAll('[data-action]');
|
|
85
|
+
|
|
86
|
+
// Validate required parameters
|
|
87
|
+
if (!email || !asmId || !sig) {
|
|
88
|
+
$description.textContent = 'Something went wrong.';
|
|
89
|
+
$errorMessage.textContent = 'This link is invalid or expired. Please try clicking the link in your email again.';
|
|
90
|
+
$error.hidden = false;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Populate UI
|
|
95
|
+
$description.textContent = 'Confirm your email address to update your email preferences.';
|
|
96
|
+
$address.textContent = maskEmail(email);
|
|
97
|
+
$content.hidden = false;
|
|
98
|
+
|
|
99
|
+
// Track current action
|
|
100
|
+
let currentAction = 'unsubscribe';
|
|
101
|
+
|
|
102
|
+
// Action toggle buttons
|
|
103
|
+
$actionButtons.forEach($btn => {
|
|
104
|
+
$btn.addEventListener('click', () => {
|
|
105
|
+
currentAction = $btn.dataset.action;
|
|
106
|
+
|
|
107
|
+
// Update active state
|
|
108
|
+
$actionButtons.forEach($b => $b.classList.remove('active'));
|
|
109
|
+
$btn.classList.add('active');
|
|
110
|
+
|
|
111
|
+
// Toggle info messages
|
|
112
|
+
$infoUnsubscribe.hidden = currentAction !== 'unsubscribe';
|
|
113
|
+
$infoResubscribe.hidden = currentAction !== 'resubscribe';
|
|
114
|
+
|
|
115
|
+
// Update submit button
|
|
116
|
+
if (currentAction === 'unsubscribe') {
|
|
117
|
+
$submit.className = 'btn btn-danger w-100 mb-4';
|
|
118
|
+
$submit.querySelector('.button-text').innerHTML = `${getPrerenderedIcon('bell-slash', 'me-2')}Unsubscribe`;
|
|
119
|
+
} else {
|
|
120
|
+
$submit.className = 'btn btn-success w-100 mb-4';
|
|
121
|
+
$submit.querySelector('.button-text').innerHTML = `${getPrerenderedIcon('bell', 'me-2')}Resubscribe`;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Initialize FormManager
|
|
127
|
+
const formManager = new FormManager('#email-preferences-form', {
|
|
128
|
+
autoReady: false,
|
|
129
|
+
allowResubmit: false,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Custom validation: confirm email matches
|
|
133
|
+
formManager.on('validation', ({ data, setError }) => {
|
|
134
|
+
if (data.email_confirm.toLowerCase().trim() !== email.toLowerCase().trim()) {
|
|
135
|
+
setError('email_confirm', 'Email does not match. Please enter the email address this was sent to.');
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Submit handler
|
|
140
|
+
formManager.on('submit', async ({ data }) => {
|
|
141
|
+
const action = currentAction;
|
|
142
|
+
|
|
143
|
+
trackEmailPreference(action);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await fetch(`${webManager.getApiUrl()}/backend-manager/marketing/email-preferences`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
response: 'json',
|
|
149
|
+
body: {
|
|
150
|
+
email: email,
|
|
151
|
+
asmId: asmId,
|
|
152
|
+
action: action,
|
|
153
|
+
sig: sig,
|
|
154
|
+
},
|
|
155
|
+
timeout: 30000,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (action === 'unsubscribe') {
|
|
159
|
+
formManager.showSuccess('You have been successfully unsubscribed. You will no longer receive these emails.');
|
|
160
|
+
} else {
|
|
161
|
+
formManager.showSuccess('You have been successfully resubscribed. You will start receiving these emails again.');
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
webManager.sentry().captureException(new Error('Email preferences error', { cause: error }));
|
|
165
|
+
throw new Error('An error occurred while processing your request. Please try again.');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Ready
|
|
170
|
+
formManager.ready();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Tracking
|
|
174
|
+
function trackEmailPreference(action) {
|
|
175
|
+
gtag('event', `email_${action}`, {
|
|
176
|
+
content_type: 'email_preferences',
|
|
177
|
+
});
|
|
178
|
+
fbq('trackCustom', action === 'unsubscribe' ? 'EmailUnsubscribe' : 'EmailResubscribe', {
|
|
179
|
+
content_name: 'Email Preferences',
|
|
180
|
+
});
|
|
181
|
+
ttq.track('ViewContent', {
|
|
182
|
+
content_id: `email-${action}`,
|
|
183
|
+
content_type: 'product',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -47,11 +47,24 @@ export default function (Manager) {
|
|
|
47
47
|
|
|
48
48
|
// Handle redirect or URL update
|
|
49
49
|
if (authReturnUrl) {
|
|
50
|
-
// Redirect to return URL with token
|
|
50
|
+
// Redirect to return URL with token
|
|
51
51
|
updateStatus('Redirecting...');
|
|
52
52
|
const returnUrl = new URL(authReturnUrl);
|
|
53
53
|
returnUrl.searchParams.set('authToken', token);
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
// LEGACY: Reformat token for desktop app deep links
|
|
56
|
+
// TODO: Remove this block when legacy desktop app support is no longer needed
|
|
57
|
+
_legacyTranslateTokenRedirect(returnUrl, token);
|
|
58
|
+
|
|
59
|
+
const redirectUrl = returnUrl.toString();
|
|
60
|
+
console.log('[Token] Redirecting to:', redirectUrl);
|
|
61
|
+
|
|
62
|
+
// Show retry button after a delay in case the redirect was cancelled (e.g. custom protocol dialog)
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
updateStatus('If you were not redirected, <a href="' + redirectUrl + '">click here to try again</a>.');
|
|
65
|
+
}, 3000);
|
|
66
|
+
|
|
67
|
+
window.location.href = redirectUrl;
|
|
55
68
|
} else {
|
|
56
69
|
// Add token to current URL (for browser extensions)
|
|
57
70
|
// Extension background will detect this and close the tab
|
|
@@ -104,4 +117,14 @@ export default function (Manager) {
|
|
|
104
117
|
$status.classList.add('d-none');
|
|
105
118
|
}
|
|
106
119
|
}
|
|
120
|
+
|
|
121
|
+
// LEGACY: Reformat token for desktop app deep links
|
|
122
|
+
// Legacy desktop apps expect ?payload={"token":"X"} instead of ?authToken=X for custom protocol URLs
|
|
123
|
+
// TODO: Remove this function AND its call above when legacy desktop app support is no longer needed
|
|
124
|
+
function _legacyTranslateTokenRedirect(returnUrl, token) {
|
|
125
|
+
if (returnUrl.protocol !== 'http:' && returnUrl.protocol !== 'https:') {
|
|
126
|
+
returnUrl.searchParams.delete('authToken');
|
|
127
|
+
returnUrl.searchParams.set('payload', JSON.stringify({ token: token }));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
107
130
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
### ALL PAGES ###
|
|
3
|
+
layout: themes/[ site.theme.id ]/frontend/pages/portal/email-preferences
|
|
4
|
+
|
|
5
|
+
### REGULAR PAGES ###
|
|
6
|
+
meta:
|
|
7
|
+
title: "Email Preferences - {{ site.brand.name }}"
|
|
8
|
+
description: "Manage your email preferences for {{ site.brand.name }}."
|
|
9
|
+
breadcrumb: "Email Preferences"
|
|
10
|
+
|
|
11
|
+
### MISC ###
|
|
12
|
+
sitemap:
|
|
13
|
+
include: false
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{{ content | uj_content_format }}
|
package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/portal/email-preferences.html
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
### ALL PAGES ###
|
|
3
|
+
layout: themes/[ site.theme.id ]/frontend/core/cover
|
|
4
|
+
|
|
5
|
+
### PAGE CONFIG ###
|
|
6
|
+
prerender_icons:
|
|
7
|
+
- name: "bell-slash"
|
|
8
|
+
- name: "bell"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<section class="col-12 col-md-8 col-lg-6 col-xl-5 mw-sm">
|
|
12
|
+
<div class="card border-0 shadow-lg">
|
|
13
|
+
<div class="card-body p-3 p-md-5">
|
|
14
|
+
<!-- Logo -->
|
|
15
|
+
<div class="text-center mb-3">
|
|
16
|
+
<div class="avatar avatar-xl">
|
|
17
|
+
<img src="{{ site.brand.images.brandmark }}?cb={{ site.uj.cache_breaker }}" alt="{{ site.brand.name }} Logo"/>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- Header -->
|
|
22
|
+
<div class="text-center mb-4">
|
|
23
|
+
<h1 class="h3 mb-2">Email Preferences</h1>
|
|
24
|
+
<p class="text-muted" id="email-preferences-description">Loading your preferences...</p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Error state (hidden by default) -->
|
|
28
|
+
<div id="email-preferences-error" class="text-center" hidden>
|
|
29
|
+
<div class="alert alert-danger mb-4">
|
|
30
|
+
{% uj_icon "triangle-exclamation", "me-1" %}
|
|
31
|
+
<span id="email-preferences-error-message">Invalid or missing parameters.</span>
|
|
32
|
+
</div>
|
|
33
|
+
<a href="/" class="btn btn-outline-adaptive">
|
|
34
|
+
{% uj_icon "arrow-left", "me-1" %}
|
|
35
|
+
Back to Home
|
|
36
|
+
</a>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<!-- Form (hidden until params are validated by JS) -->
|
|
40
|
+
<div id="email-preferences-content" hidden>
|
|
41
|
+
<!-- Email display -->
|
|
42
|
+
<div class="bg-body-tertiary rounded-3 p-3 mb-4 text-center">
|
|
43
|
+
<small class="text-muted d-block mb-1">Managing preferences for</small>
|
|
44
|
+
<strong id="email-preferences-address" class="text-break"></strong>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<!-- Email Preferences Form -->
|
|
48
|
+
<form id="email-preferences-form" autocomplete="off" onsubmit="return false">
|
|
49
|
+
<!-- Action selector -->
|
|
50
|
+
<div class="mb-4">
|
|
51
|
+
<div class="d-flex gap-2">
|
|
52
|
+
<button type="button" class="btn btn-outline-adaptive flex-fill active" data-action="unsubscribe">
|
|
53
|
+
{% uj_icon "bell-slash", "me-1" %}
|
|
54
|
+
Unsubscribe
|
|
55
|
+
</button>
|
|
56
|
+
<button type="button" class="btn btn-outline-adaptive flex-fill" data-action="resubscribe">
|
|
57
|
+
{% uj_icon "bell", "me-1" %}
|
|
58
|
+
Resubscribe
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Info message (changes based on action) -->
|
|
64
|
+
<div class="alert alert-warning small mb-4" id="email-preferences-info-unsubscribe">
|
|
65
|
+
{% uj_icon "circle-info", "me-1" %}
|
|
66
|
+
You will stop receiving emails of this type. Other important account emails may still be sent.
|
|
67
|
+
</div>
|
|
68
|
+
<div class="alert alert-success small mb-4" id="email-preferences-info-resubscribe" hidden>
|
|
69
|
+
{% uj_icon "circle-info", "me-1" %}
|
|
70
|
+
You will start receiving these emails again.
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="mb-4 text-start">
|
|
74
|
+
<label for="email_confirm" class="form-label fw-semibold">
|
|
75
|
+
Confirm your email address <span class="text-danger">*</span>
|
|
76
|
+
</label>
|
|
77
|
+
<input type="email" class="form-control form-control-md" id="email_confirm" name="email_confirm"
|
|
78
|
+
placeholder="Type your email to confirm"
|
|
79
|
+
autocomplete="email"
|
|
80
|
+
autofocus
|
|
81
|
+
required
|
|
82
|
+
disabled>
|
|
83
|
+
<div class="invalid-feedback"></div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- Honeypot field (bot detection) -->
|
|
87
|
+
<div class="form-group mb-3" style="display: none;" aria-hidden="true">
|
|
88
|
+
<input type="text" class="form-control" name="honey" tabindex="-1" autocomplete="off">
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<button type="submit" class="btn btn-danger w-100 mb-4" id="email-preferences-submit" disabled>
|
|
92
|
+
<span class="button-text">Unsubscribe</span>
|
|
93
|
+
</button>
|
|
94
|
+
</form>
|
|
95
|
+
|
|
96
|
+
<!-- Back to Home -->
|
|
97
|
+
<div class="mt-3 pt-3 border-top text-center">
|
|
98
|
+
<a href="/" class="btn btn-outline-adaptive btn-sm">
|
|
99
|
+
{% uj_icon "arrow-left", "me-1" %}
|
|
100
|
+
Back to Home
|
|
101
|
+
</a>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
{{ content | uj_content_format }}
|