ultimate-jekyll-manager 1.1.7 → 1.1.9
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 +20 -0
- package/dist/assets/css/core/_utilities.scss +10 -2
- package/dist/assets/js/core/auth.js +9 -1
- package/dist/assets/js/libs/auth.js +48 -0
- package/dist/assets/js/pages/account/sections/billing.js +1 -1
- package/dist/assets/js/pages/admin/firebase/index.js +2 -2
- package/dist/assets/js/pages/admin/users/index.js +109 -1
- package/dist/assets/themes/classy/css/layout/_navigation.scss +1 -1
- package/dist/defaults/dist/_layouts/blueprint/admin/users/index.html +62 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,26 @@ 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.1.9] - 2026-04-23
|
|
19
|
+
### Added
|
|
20
|
+
- Admin users page: "Sign in as user" dropdown option that calls BEM `POST /backend-manager/user/token` to generate a custom auth token, then shows a modal with the sign-in URL (copy button + open-in-new-tab button)
|
|
21
|
+
- Modal opens immediately in a loading state while the token is generated, then swaps to ready/error state
|
|
22
|
+
- Auth signin page: handle `authCustomToken` URL param via Firebase `signInWithCustomToken`, redirecting to `authReturnUrl` (validated) or `/dashboard`
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Billing section: cancel subscription button now appears for suspended paid subscriptions (previously hidden). Logic updated to `isPaid && rawStatus !== 'cancelled' && !resolved.cancelling` so it correctly shows for active, trialing, and suspended paid subs, while hiding for free users, already-cancelled subs, and subs with pending cancellation
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Admin users table: dropdown trigger button restyled using `btn-outline-adaptive rounded-circle` for a cleaner look
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
## [1.1.8] - 2026-04-22
|
|
32
|
+
### Changed
|
|
33
|
+
- Widen backend sidebar from 282px to 283px so inner content (after `p-3` horizontal padding) clears the 250px minimum required by Google AdSense units
|
|
34
|
+
- Apply same 283px width to mobile offcanvas sidebar (`#mobileSidebar`) via `--bs-offcanvas-width` to override Bootstrap's default 400px
|
|
35
|
+
- Simplify admin firebase page cell rendering: drop redundant `String()` wrapping around values passed to `escapeHTML()` (already coerces to string internally)
|
|
36
|
+
|
|
17
37
|
---
|
|
18
38
|
## [1.1.7] - 2026-04-10
|
|
19
39
|
### Changed
|
|
@@ -82,9 +82,17 @@ button *, a * {
|
|
|
82
82
|
// ============================================
|
|
83
83
|
// Sidebar
|
|
84
84
|
// ============================================
|
|
85
|
+
// Inner content area MUST be > 250px to fit a 250px-wide Google AdSense unit.
|
|
86
|
+
// Math: 250px content + 2 × 16px (p-3 horizontal padding) + 1px safety = 283px outer.
|
|
85
87
|
.sidebar {
|
|
86
|
-
width:
|
|
87
|
-
min-width:
|
|
88
|
+
width: 283px;
|
|
89
|
+
min-width: 283px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Mobile offcanvas sidebar — override Bootstrap's default 400px.
|
|
93
|
+
// Same math as above: 283px outer → ~251px inner content after p-3 padding.
|
|
94
|
+
#mobileSidebar {
|
|
95
|
+
--bs-offcanvas-width: 283px;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
.sidebar-logo {
|
|
@@ -152,7 +152,15 @@ function updateAuthLinks() {
|
|
|
152
152
|
|
|
153
153
|
$link.addEventListener('click', (e) => {
|
|
154
154
|
const url = new URL($link.href, window.location.origin);
|
|
155
|
-
|
|
155
|
+
const currentUrl = new URL(window.location.href);
|
|
156
|
+
const existingReturnUrl = currentUrl.searchParams.get('authReturnUrl');
|
|
157
|
+
|
|
158
|
+
if (existingReturnUrl) {
|
|
159
|
+
url.searchParams.set('authReturnUrl', existingReturnUrl);
|
|
160
|
+
} else if (!authPaths.includes(currentUrl.pathname)) {
|
|
161
|
+
url.searchParams.set('authReturnUrl', window.location.href);
|
|
162
|
+
}
|
|
163
|
+
|
|
156
164
|
$link.href = url.toString();
|
|
157
165
|
});
|
|
158
166
|
} catch (e) {}
|
|
@@ -22,6 +22,12 @@ export default function () {
|
|
|
22
22
|
// Check for authSignout parameter first
|
|
23
23
|
await handleAuthSignout();
|
|
24
24
|
|
|
25
|
+
// Check for authCustomToken parameter (admin impersonation / custom token sign-in)
|
|
26
|
+
const customTokenHandled = await handleCustomTokenSignin();
|
|
27
|
+
if (customTokenHandled) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
// Initialize the appropriate form based on the page (with autoReady: false)
|
|
26
32
|
initializePageForm();
|
|
27
33
|
|
|
@@ -209,6 +215,48 @@ export default function () {
|
|
|
209
215
|
}
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
async function handleCustomTokenSignin() {
|
|
219
|
+
const url = new URL(window.location.href);
|
|
220
|
+
const customToken = url.searchParams.get('authCustomToken');
|
|
221
|
+
|
|
222
|
+
if (!customToken) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
console.log('[Auth] Signing in with custom token');
|
|
228
|
+
|
|
229
|
+
const { getAuth, signInWithCustomToken } = await import('@firebase/auth');
|
|
230
|
+
const auth = getAuth();
|
|
231
|
+
|
|
232
|
+
const userCredential = await signInWithCustomToken(auth, customToken);
|
|
233
|
+
console.log('[Auth] Custom token sign-in successful:', userCredential.user.email || userCredential.user.uid);
|
|
234
|
+
|
|
235
|
+
trackLogin('custom-token', userCredential.user);
|
|
236
|
+
|
|
237
|
+
const authReturnUrl = url.searchParams.get('authReturnUrl');
|
|
238
|
+
const redirectTo = authReturnUrl && webManager.isValidRedirectUrl(authReturnUrl)
|
|
239
|
+
? authReturnUrl
|
|
240
|
+
: '/dashboard';
|
|
241
|
+
|
|
242
|
+
window.location.href = redirectTo;
|
|
243
|
+
return true;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
webManager.sentry().captureException(new Error('Custom token sign-in error', { cause: error }));
|
|
246
|
+
console.error('[Auth] Custom token sign-in failed:', error);
|
|
247
|
+
|
|
248
|
+
const url = new URL(window.location.href);
|
|
249
|
+
url.searchParams.delete('authCustomToken');
|
|
250
|
+
window.history.replaceState({}, document.title, url.toString());
|
|
251
|
+
|
|
252
|
+
webManager.utilities().showNotification(
|
|
253
|
+
`Custom token sign-in failed: ${error.message || 'Invalid or expired token'}`,
|
|
254
|
+
{ type: 'danger', timeout: 8000 }
|
|
255
|
+
);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
212
260
|
async function handleAuthSignout() {
|
|
213
261
|
const url = new URL(window.location.href);
|
|
214
262
|
const authSignout = url.searchParams.get('authSignout');
|
|
@@ -140,7 +140,7 @@ function buildBillingState(account) {
|
|
|
140
140
|
upgrade: !isPaid || rawStatus === 'cancelled',
|
|
141
141
|
change: resolved.active,
|
|
142
142
|
manage: isPaid && rawStatus !== 'cancelled',
|
|
143
|
-
cancel: resolved.
|
|
143
|
+
cancel: isPaid && rawStatus !== 'cancelled' && !resolved.cancelling,
|
|
144
144
|
},
|
|
145
145
|
},
|
|
146
146
|
};
|
|
@@ -303,7 +303,7 @@ function renderDocuments() {
|
|
|
303
303
|
|
|
304
304
|
columns.forEach((col) => {
|
|
305
305
|
const value = getNestedValue(doc.data, col);
|
|
306
|
-
cells += `<td class="small text-truncate" style="max-width: 180px;" title="${webManager.utilities().escapeHTML(
|
|
306
|
+
cells += `<td class="small text-truncate" style="max-width: 180px;" title="${webManager.utilities().escapeHTML(value ?? '')}">${renderCellValue(value)}</td>`;
|
|
307
307
|
});
|
|
308
308
|
|
|
309
309
|
cells += `<td>
|
|
@@ -408,7 +408,7 @@ function renderCellValue(value) {
|
|
|
408
408
|
if (typeof value === 'number') {
|
|
409
409
|
// Check if it looks like a UNIX timestamp (reasonable range)
|
|
410
410
|
if (value > 1000000000 && value < 10000000000) {
|
|
411
|
-
return `<span title="${webManager.utilities().escapeHTML(
|
|
411
|
+
return `<span title="${webManager.utilities().escapeHTML(value)}">${webManager.utilities().escapeHTML(new Date(value * 1000).toLocaleDateString())}</span>`;
|
|
412
412
|
}
|
|
413
413
|
return webManager.utilities().escapeHTML(value.toLocaleString());
|
|
414
414
|
}
|
|
@@ -172,7 +172,7 @@ function renderUsers() {
|
|
|
172
172
|
<td class="text-muted small">${updatedText}</td>
|
|
173
173
|
<td>
|
|
174
174
|
<div class="dropdown">
|
|
175
|
-
<button class="btn btn-sm btn-
|
|
175
|
+
<button class="btn btn-sm btn-adaptive rounded-circle" type="button" data-bs-toggle="dropdown">
|
|
176
176
|
${getPrerenderedIcon('ellipsis-vertical', 'fa-sm')}
|
|
177
177
|
</button>
|
|
178
178
|
<ul class="dropdown-menu dropdown-menu-end">
|
|
@@ -192,6 +192,10 @@ function renderUsers() {
|
|
|
192
192
|
${getPrerenderedIcon('fire', 'fa-sm me-2')}
|
|
193
193
|
View in Explorer
|
|
194
194
|
</a></li>
|
|
195
|
+
<li><a class="dropdown-item small btn-signin-as" href="#">
|
|
196
|
+
${getPrerenderedIcon('right-to-bracket', 'fa-sm me-2')}
|
|
197
|
+
Sign in as user
|
|
198
|
+
</a></li>
|
|
195
199
|
<li><hr class="dropdown-divider"></li>
|
|
196
200
|
<li><a class="dropdown-item small text-danger btn-delete-user" href="#">
|
|
197
201
|
${getPrerenderedIcon('trash', 'fa-sm me-2')}
|
|
@@ -223,6 +227,11 @@ function renderUsers() {
|
|
|
223
227
|
window.location.href = `/admin/firebase?collection=users&doc=${uid}`;
|
|
224
228
|
});
|
|
225
229
|
|
|
230
|
+
$row.querySelector('.btn-signin-as').addEventListener('click', (e) => {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
signInAsUser(uid, email);
|
|
233
|
+
});
|
|
234
|
+
|
|
226
235
|
$row.querySelector('.btn-delete-user').addEventListener('click', (e) => {
|
|
227
236
|
e.preventDefault();
|
|
228
237
|
deleteUser(uid, email);
|
|
@@ -269,6 +278,105 @@ function viewUser(uid, userData) {
|
|
|
269
278
|
modal.show();
|
|
270
279
|
}
|
|
271
280
|
|
|
281
|
+
async function signInAsUser(uid, email) {
|
|
282
|
+
openSignInAsModalLoading(email);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const response = await authorizedFetch(`${webManager.getApiUrl()}/backend-manager/user/token`, {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
timeout: 30000,
|
|
288
|
+
response: 'json',
|
|
289
|
+
tries: 1,
|
|
290
|
+
log: true,
|
|
291
|
+
body: { uid: uid },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const token = response?.token;
|
|
295
|
+
if (!token) {
|
|
296
|
+
throw new Error('No token returned from server');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const signinUrl = new URL('/signin', window.location.origin);
|
|
300
|
+
signinUrl.searchParams.set('authSignout', 'true');
|
|
301
|
+
signinUrl.searchParams.set('authCustomToken', token);
|
|
302
|
+
signinUrl.searchParams.set('authReturnUrl', '/dashboard');
|
|
303
|
+
|
|
304
|
+
showSignInAsModalReady(email, signinUrl.toString());
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error('Failed to create sign-in link:', error);
|
|
307
|
+
showSignInAsModalError(error.message || 'Unknown error');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function openSignInAsModalLoading(email) {
|
|
312
|
+
const $loading = document.getElementById('signin-as-loading');
|
|
313
|
+
const $ready = document.getElementById('signin-as-ready');
|
|
314
|
+
const $error = document.getElementById('signin-as-error');
|
|
315
|
+
const $loadingEmail = document.getElementById('signin-as-loading-email');
|
|
316
|
+
const $navigateBtn = document.getElementById('btn-signin-as-navigate');
|
|
317
|
+
|
|
318
|
+
if ($loading) $loading.classList.remove('d-none');
|
|
319
|
+
if ($ready) $ready.classList.add('d-none');
|
|
320
|
+
if ($error) $error.classList.add('d-none');
|
|
321
|
+
if ($navigateBtn) $navigateBtn.classList.add('d-none');
|
|
322
|
+
if ($loadingEmail) $loadingEmail.textContent = email;
|
|
323
|
+
|
|
324
|
+
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('signin-as-modal'));
|
|
325
|
+
modal.show();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function showSignInAsModalReady(email, urlString) {
|
|
329
|
+
const $loading = document.getElementById('signin-as-loading');
|
|
330
|
+
const $ready = document.getElementById('signin-as-ready');
|
|
331
|
+
const $error = document.getElementById('signin-as-error');
|
|
332
|
+
const $email = document.getElementById('signin-as-email');
|
|
333
|
+
const $url = document.getElementById('signin-as-url');
|
|
334
|
+
const $copyBtn = document.getElementById('btn-signin-as-copy');
|
|
335
|
+
const $navigateBtn = document.getElementById('btn-signin-as-navigate');
|
|
336
|
+
|
|
337
|
+
if ($loading) $loading.classList.add('d-none');
|
|
338
|
+
if ($error) $error.classList.add('d-none');
|
|
339
|
+
if ($ready) $ready.classList.remove('d-none');
|
|
340
|
+
if ($navigateBtn) $navigateBtn.classList.remove('d-none');
|
|
341
|
+
if ($email) $email.textContent = email;
|
|
342
|
+
if ($url) $url.value = urlString;
|
|
343
|
+
|
|
344
|
+
if ($copyBtn) {
|
|
345
|
+
$copyBtn.onclick = async () => {
|
|
346
|
+
await navigator.clipboard.writeText(urlString).catch(() => {});
|
|
347
|
+
const originalHTML = $copyBtn.innerHTML;
|
|
348
|
+
$copyBtn.innerHTML = `${getPrerenderedIcon('circle-check', 'fa-sm')}`;
|
|
349
|
+
$copyBtn.classList.add('btn-success');
|
|
350
|
+
$copyBtn.classList.remove('btn-outline-adaptive');
|
|
351
|
+
setTimeout(() => {
|
|
352
|
+
$copyBtn.innerHTML = originalHTML;
|
|
353
|
+
$copyBtn.classList.remove('btn-success');
|
|
354
|
+
$copyBtn.classList.add('btn-outline-adaptive');
|
|
355
|
+
}, 1500);
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if ($navigateBtn) {
|
|
360
|
+
$navigateBtn.onclick = () => {
|
|
361
|
+
window.open(urlString, '_blank', 'noopener');
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function showSignInAsModalError(message) {
|
|
367
|
+
const $loading = document.getElementById('signin-as-loading');
|
|
368
|
+
const $ready = document.getElementById('signin-as-ready');
|
|
369
|
+
const $error = document.getElementById('signin-as-error');
|
|
370
|
+
const $errorMessage = document.getElementById('signin-as-error-message');
|
|
371
|
+
const $navigateBtn = document.getElementById('btn-signin-as-navigate');
|
|
372
|
+
|
|
373
|
+
if ($loading) $loading.classList.add('d-none');
|
|
374
|
+
if ($ready) $ready.classList.add('d-none');
|
|
375
|
+
if ($error) $error.classList.remove('d-none');
|
|
376
|
+
if ($navigateBtn) $navigateBtn.classList.add('d-none');
|
|
377
|
+
if ($errorMessage) $errorMessage.textContent = message;
|
|
378
|
+
}
|
|
379
|
+
|
|
272
380
|
async function deleteUser(uid, email) {
|
|
273
381
|
if (!confirm(`Delete user ${email} (${uid})?\n\nThis will permanently delete their account and cannot be undone.`)) {
|
|
274
382
|
return;
|
|
@@ -28,6 +28,10 @@ prerender_icons:
|
|
|
28
28
|
- name: "trash"
|
|
29
29
|
- name: "ellipsis-vertical"
|
|
30
30
|
- name: "copy"
|
|
31
|
+
- name: "right-to-bracket"
|
|
32
|
+
- name: "circle-check"
|
|
33
|
+
- name: "arrow-up-right-from-square"
|
|
34
|
+
- name: "circle-xmark"
|
|
31
35
|
---
|
|
32
36
|
|
|
33
37
|
<!-- Page Header Actions -->
|
|
@@ -181,6 +185,64 @@ prerender_icons:
|
|
|
181
185
|
</div>
|
|
182
186
|
</div>
|
|
183
187
|
|
|
188
|
+
<!-- Sign In As User Modal -->
|
|
189
|
+
<div class="modal fade" id="signin-as-modal" tabindex="-1" aria-labelledby="signin-as-modal-label" aria-hidden="true">
|
|
190
|
+
<div class="modal-dialog modal-dialog-centered">
|
|
191
|
+
<div class="modal-content">
|
|
192
|
+
<div class="modal-header">
|
|
193
|
+
<h6 class="modal-title" id="signin-as-modal-label">Sign in as user</h6>
|
|
194
|
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<!-- Loading State -->
|
|
198
|
+
<div class="modal-body text-center py-5" id="signin-as-loading">
|
|
199
|
+
<div class="spinner-border text-primary mb-3" role="status">
|
|
200
|
+
<span class="visually-hidden">Loading...</span>
|
|
201
|
+
</div>
|
|
202
|
+
<h5 class="mb-2">Generating sign-in link</h5>
|
|
203
|
+
<p class="text-muted mb-0">
|
|
204
|
+
Creating a one-time token for <strong id="signin-as-loading-email" class="text-body"></strong>...
|
|
205
|
+
</p>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<!-- Ready State -->
|
|
209
|
+
<div class="modal-body text-center d-none" id="signin-as-ready">
|
|
210
|
+
<div class="rounded-circle d-inline-flex align-items-center justify-content-center p-3 bg-success bg-opacity-10 mx-auto mb-3">
|
|
211
|
+
{% uj_icon "circle-check", "fa-2xl text-success" %}
|
|
212
|
+
</div>
|
|
213
|
+
<h5 class="mb-2">Sign-in link ready</h5>
|
|
214
|
+
<p class="text-muted mb-3">
|
|
215
|
+
A one-time sign-in link for <strong id="signin-as-email" class="text-body"></strong> has been generated. Open it in an incognito window to avoid signing out of your admin session.
|
|
216
|
+
</p>
|
|
217
|
+
<div class="input-group mb-2">
|
|
218
|
+
<input type="text" class="form-control form-control-sm font-monospace" id="signin-as-url" readonly>
|
|
219
|
+
<button type="button" class="btn btn-sm btn-outline-adaptive" id="btn-signin-as-copy">
|
|
220
|
+
{% uj_icon "copy", "fa-sm" %}
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
<small class="text-muted d-block">The link contains a short-lived custom token</small>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<!-- Error State -->
|
|
227
|
+
<div class="modal-body text-center py-5 d-none" id="signin-as-error">
|
|
228
|
+
<div class="rounded-circle d-inline-flex align-items-center justify-content-center p-3 bg-danger bg-opacity-10 mx-auto mb-3">
|
|
229
|
+
{% uj_icon "circle-xmark", "fa-2xl text-danger" %}
|
|
230
|
+
</div>
|
|
231
|
+
<h5 class="mb-2">Failed to generate link</h5>
|
|
232
|
+
<p class="text-muted mb-0" id="signin-as-error-message"></p>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div class="modal-footer">
|
|
236
|
+
<button type="button" class="btn btn-sm btn-outline-adaptive" data-bs-dismiss="modal">Cancel</button>
|
|
237
|
+
<button type="button" class="btn btn-sm btn-adaptive d-none" id="btn-signin-as-navigate">
|
|
238
|
+
{% uj_icon "arrow-up-right-from-square", "fa-sm me-1" %}
|
|
239
|
+
Open in new tab
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
184
246
|
<!-- User Edit Modal -->
|
|
185
247
|
<div class="modal fade" id="user-edit-modal" tabindex="-1" aria-labelledby="user-edit-modal-label" aria-hidden="true">
|
|
186
248
|
<div class="modal-dialog">
|