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 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: 282px;
87
- min-width: 282px;
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
- url.searchParams.set('authReturnUrl', window.location.href);
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.active,
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(String(value ?? ''))}">${renderCellValue(value)}</td>`;
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(String(value))}">${webManager.utilities().escapeHTML(new Date(value * 1000).toLocaleDateString())}</span>`;
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-link p-0" type="button" data-bs-toggle="dropdown">
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;
@@ -426,7 +426,7 @@
426
426
  // Backend Layout Components
427
427
  // ============================================
428
428
  .sidebar {
429
- width: 282px;
429
+ width: 283px;
430
430
 
431
431
  .sidebar-logo {
432
432
  height: 60px; // Fixed height for logo section
@@ -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">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {