Skip to content

Commit 4c6a1c8

Browse files
Andrew Bakerclaude
andcommitted
feat: grace logins, plugin slot fix, explain modal alignment, save JS bug fix
- Fix: active plugin was cloudscale-code-block (v1.8.86) while all deploys went to inactive cloudscale-devtools slot — swapped to correct active slot - Fix: undefined `slug` variable in hide login save success handler caused ReferenceError after every successful save, showing false failure alert - Fix: Explain modal badge alignment — badge now always centered at top of each card, removing flex-wrap inconsistency on narrow mobile screens - Feat: Grace logins UI — number input in 2FA Settings panel, AJAX save handler, and JS payload; allows N logins before 2FA is enforced per user - Tests: session-persistence.spec.js — 4 Playwright tests confirm 30-day persistent cookie (expires > 0) vs default session cookie (expires = -1) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b4cef93 commit 4c6a1c8

File tree

4 files changed

+232
-10
lines changed

4 files changed

+232
-10
lines changed

assets/cs-login.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
login_slug: document.getElementById( 'cs-login-slug' )?.value.trim() || '',
4949
method: document.querySelector( 'input[name="cs_devtools_2fa_method"]:checked' )?.value || 'off',
5050
force_admins: document.getElementById( 'cs-2fa-force' )?.checked ? '1' : '0',
51+
grace_logins: document.getElementById( 'cs-2fa-grace-logins' )?.value || '0',
5152
session_duration: document.getElementById( 'cs-session-duration' )?.value || 'default',
5253
bf_enabled: document.getElementById( 'cs-bf-enabled' )?.checked ? '1' : '0',
5354
bf_attempts: document.getElementById( 'cs-bf-attempts' )?.value || '5',
@@ -68,9 +69,7 @@
6869
urlEl.href = res.data.login_url;
6970
urlEl.textContent = res.data.login_url;
7071
}
71-
// Update slug-base preview
72-
const slugInput = document.getElementById( 'cs-login-slug' );
73-
if ( slugInput ) slugInput.value = slug;
72+
// (slug input already has the current value — no update needed)
7473
} else {
7574
alert( res.data || 'Save failed.' );
7675
}

cs-code-block.php

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Plugin Name: CloudScale DevTools
44
* Plugin URI: https://andrewbaker.ninja
55
* Description: Developer toolkit with syntax-highlighted code blocks, SQL query tool, code migrator, site monitor, and login security (passkeys, TOTP, email 2FA, hide login URL).
6-
* Version: 1.8.90
6+
* Version: 1.8.95
77
* Author: Andrew Baker
88
* Author URI: https://andrewbaker.ninja
99
* License: GPL-2.0-or-later
@@ -38,7 +38,7 @@
3838
*/
3939
class CloudScale_DevTools {
4040

41-
const VERSION = '1.8.90';
41+
const VERSION = '1.8.95';
4242
const HLJS_VERSION = '11.11.1';
4343
const HLJS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/';
4444
const TOOLS_SLUG = 'cloudscale-devtools';
@@ -775,6 +775,14 @@ public static function register_settings() {
775775
'sanitize_callback' => function ( $v ) { return '1' === $v ? '1' : '0'; },
776776
'default' => '0',
777777
] );
778+
register_setting( 'cs_devtools_login_settings', 'cs_devtools_2fa_grace_logins', [
779+
'type' => 'string',
780+
'sanitize_callback' => static function ( $v ) {
781+
$n = (int) $v;
782+
return ( $n >= 0 && $n <= 10 ) ? (string) $n : '0';
783+
},
784+
'default' => '0',
785+
] );
778786
register_setting( 'cs_devtools_login_settings', 'cs_devtools_session_duration', [
779787
'type' => 'string',
780788
'sanitize_callback' => static function ( $v ) {
@@ -1184,10 +1192,10 @@ private static function render_explain_btn( string $id, string $title, array $it
11841192
$bdr = $is_on ? '#1a7a34' : ( $is_opt ? '#c3c4c7' : '#2271b1' );
11851193
?>
11861194
<div style="border:1px solid #e0e0e0;border-radius:6px;padding:12px 14px;margin-bottom:10px">
1187-
<div style="display:flex;align-items:center;gap:10px;margin-bottom:5px;flex-wrap:wrap">
1188-
<strong style="font-size:13px"><?php echo esc_html( $item['name'] ); ?></strong>
1189-
<span style="background:<?php echo esc_attr( $bg ); ?>;color:<?php echo esc_attr( $col ); ?>;border:1px solid <?php echo esc_attr( $bdr ); ?>;border-radius:4px;font-size:11px;font-weight:600;padding:1px 8px;white-space:nowrap"><?php echo esc_html( $rec ); ?></span>
1195+
<div style="text-align:center;margin-bottom:6px">
1196+
<span style="display:inline-block;background:<?php echo esc_attr( $bg ); ?>;color:<?php echo esc_attr( $col ); ?>;border:1px solid <?php echo esc_attr( $bdr ); ?>;border-radius:4px;font-size:11px;font-weight:600;padding:1px 8px;white-space:nowrap"><?php echo esc_html( $rec ); ?></span>
11901197
</div>
1198+
<strong style="display:block;font-size:13px;margin-bottom:4px"><?php echo esc_html( $item['name'] ); ?></strong>
11911199
<p style="margin:0;color:#50575e;font-size:12px;line-height:1.5;white-space:pre-line"><?php echo esc_html( $item['desc'] ); ?></p>
11921200
</div>
11931201
<?php endforeach; ?>
@@ -1777,6 +1785,7 @@ function dismiss() { clearInterval( t ); if ( modal ) modal.style.display = 'non
17771785
[ 'name' => 'Email Code', 'rec' => 'Optional', 'desc' => 'Requires users to enter a code sent to their email after each password login. Works out of the box with no app required.' ],
17781786
[ 'name' => 'Authenticator App (TOTP)', 'rec' => 'Recommended', 'desc' => 'Each user configures their own authenticator app. Most secure option — works without internet or email.' ],
17791787
[ 'name' => 'Force 2FA for Admins', 'rec' => 'Recommended', 'desc' => 'Blocks administrator-role users from accessing the dashboard until they have set up 2FA. Strongly recommended on any multi-user site.' ],
1788+
[ 'name' => 'Grace Logins', 'rec' => 'Advanced', 'desc' => "Allows a user to log in up to N times before 2FA is required. The counter is per-user and never resets automatically.\n\nDefault is 0 (2FA always required from the first login).\n\nTip for automated test accounts: set to 1. Playwright and similar tools cannot complete a real 2FA challenge, so granting one grace login lets a test account authenticate for setup steps without disabling 2FA site-wide." ],
17801789
] ); ?>
17811790
</div>
17821791
<div class="cs-panel-body">
@@ -1822,6 +1831,16 @@ function dismiss() { clearInterval( t ); if ( modal ) modal.style.display = 'non
18221831
</div>
18231832
</div>
18241833

1834+
<?php $grace_logins = (int) get_option( 'cs_devtools_2fa_grace_logins', '0' ); ?>
1835+
<div class="cs-field-row" style="margin-top:16px">
1836+
<div class="cs-field">
1837+
<label class="cs-label" for="cs-2fa-grace-logins"><?php esc_html_e( 'Grace logins before 2FA is required:', 'cloudscale-devtools' ); ?></label>
1838+
<input type="number" id="cs-2fa-grace-logins" class="cs-input" min="0" max="10"
1839+
value="<?php echo esc_attr( $grace_logins ); ?>" style="max-width:100px">
1840+
<span class="cs-hint"><?php esc_html_e( 'Allow N logins without 2FA per user. 0 = 2FA required from first login. For automated test accounts use 1.', 'cloudscale-devtools' ); ?></span>
1841+
</div>
1842+
</div>
1843+
18251844
<div style="margin-top:16px;display:flex;align-items:center;gap:10px">
18261845
<button type="button" class="cs-btn-primary" id="cs-2fa-save">💾 <?php esc_html_e( 'Save 2FA Settings', 'cloudscale-devtools' ); ?></button>
18271846
<span class="cs-settings-saved" id="cs-2fa-saved">✓ <?php esc_html_e( 'Saved', 'cloudscale-devtools' ); ?></span>
@@ -4503,6 +4522,17 @@ public static function login_2fa_intercept( $user, string $username, string $pas
45034522
return $user;
45044523
}
45054524

4525+
// Grace logins: allow up to N logins without 2FA being set up.
4526+
// Useful for automated test accounts or newly invited users.
4527+
$grace_limit = (int) get_option( 'cs_devtools_2fa_grace_logins', '0' );
4528+
if ( $grace_limit > 0 ) {
4529+
$grace_count = (int) get_user_meta( $user->ID, 'cs_devtools_2fa_grace_count', true );
4530+
if ( $grace_count < $grace_limit ) {
4531+
update_user_meta( $user->ID, 'cs_devtools_2fa_grace_count', $grace_count + 1 );
4532+
return $user; // Skip 2FA — grace login consumed.
4533+
}
4534+
}
4535+
45064536
// Avoid triggering 2FA during a 2FA verification POST itself.
45074537
$action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
45084538
if ( $action === 'cs_devtools_2fa' ) {
@@ -4923,6 +4953,11 @@ public static function ajax_login_save(): void {
49234953
update_option( 'cs_devtools_brute_force_attempts', (string) $bf_attempts );
49244954
update_option( 'cs_devtools_brute_force_lockout', (string) $bf_lockout );
49254955

4956+
// Grace logins
4957+
$grace_logins = isset( $_POST['grace_logins'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['grace_logins'] ) ) : 0;
4958+
if ( $grace_logins < 0 || $grace_logins > 10 ) { $grace_logins = 0; }
4959+
update_option( 'cs_devtools_2fa_grace_logins', (string) $grace_logins );
4960+
49264961
$new_url = $hide === '1' && $slug ? home_url( '/' . $slug . '/' ) : wp_login_url();
49274962
wp_send_json_success( [ 'login_url' => $new_url ] );
49284963
}

readme.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Tags: code block, syntax highlighting, gutenberg block, dark mode, highlight.js
44
Requires at least: 6.0
55
Tested up to: 6.7
66
Requires PHP: 7.4
7-
Stable tag: 1.8.90
7+
Stable tag: 1.8.95
88
License: GPLv2 or later
99
License URI: https://www.gnu.org/licenses/gpl-2.0.html
1010

@@ -79,7 +79,7 @@ Yes. Press Enter to run the query. Use Shift+Enter to insert a newline. Ctrl+Ent
7979

8080
== Changelog ==
8181

82-
= 1.8.90 =
82+
= 1.8.95 =
8383
* Fixed: session cookie hook was wrong — login_form_login is a display hook that never fires on a successful login POST; moved to login_init so the persistent-cookie flag is set before WordPress processes credentials
8484

8585
= 1.8.89 =

tests/session-persistence.spec.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* Session Persistence — focused Playwright test
3+
*
4+
* Verifies that when a custom session duration is configured, the WordPress
5+
* auth cookie is written as a persistent cookie (explicit expiry, not expire=0)
6+
* so it survives a browser kill / swipe-up on mobile.
7+
*
8+
* Uses cs_devtools_test (admin) — no separate admin credentials needed.
9+
*
10+
* Run: npx playwright test tests/session-persistence.spec.js --headed
11+
*/
12+
13+
const { test, expect, request } = require('@playwright/test');
14+
15+
const SITE = process.env.WP_SITE || 'https://andrewbaker.ninja';
16+
const TEST_USER = process.env.WP_TEST_USER || 'cs_session_test';
17+
const TEST_PASS = process.env.WP_TEST_PASS || 'SessionTest2026!';
18+
19+
const LOGIN_URL = `${SITE}/wp-login.php`;
20+
const SETTINGS_URL = `${SITE}/wp-admin/tools.php?page=cloudscale-devtools&tab=login`;
21+
22+
/** Add the WP test cookie (browser compatibility check) */
23+
async function addWpTestCookie( ctx ) {
24+
await ctx.addCookies( [ {
25+
name: 'wordpress_test_cookie',
26+
value: 'WP Cookie check',
27+
domain: new URL( SITE ).hostname,
28+
path: '/',
29+
secure: true,
30+
httpOnly: false,
31+
sameSite: 'Lax',
32+
} ] );
33+
}
34+
35+
/** Log in and return — throws if login fails */
36+
async function wpLogin( page, user, pass ) {
37+
await addWpTestCookie( page.context() );
38+
await page.goto( LOGIN_URL, { waitUntil: 'domcontentloaded' } );
39+
await page.fill( '#user_login', user );
40+
await page.fill( '#user_pass', pass );
41+
await Promise.all( [
42+
page.waitForNavigation( { waitUntil: 'domcontentloaded', timeout: 30_000 } ),
43+
page.click( '#wp-submit' ),
44+
] );
45+
if ( page.url().includes( 'wp-login.php' ) ) {
46+
const err = await page.locator( '#login_error' ).textContent().catch( () => 'unknown' );
47+
throw new Error( `Login failed: ${err.trim()}` );
48+
}
49+
}
50+
51+
// ─────────────────────────────────────────────────────────────────────────────
52+
// 1. Baseline — confirm default WP sessions ARE session cookies (expire = -1)
53+
// ─────────────────────────────────────────────────────────────────────────────
54+
test( 'Baseline — default WP session is a session cookie (expires=-1)', async ( { browser } ) => {
55+
// Make sure session duration is set to "default" first.
56+
const ctx = await browser.newContext( { ignoreHTTPSErrors: true } );
57+
const page = await ctx.newPage();
58+
await wpLogin( page, TEST_USER, TEST_PASS );
59+
60+
// Navigate to settings and set duration to "default".
61+
await page.goto( SETTINGS_URL );
62+
await page.locator( '#cs-session-duration' ).selectOption( 'default' );
63+
await page.locator( '#cs-session-save' ).click();
64+
await expect( page.locator( '#cs-session-saved' ) ).toBeVisible( { timeout: 5_000 } );
65+
console.log( '✅ Session duration set to default.' );
66+
67+
// Log out, then log back in fresh so the new setting applies.
68+
await page.goto( `${SITE}/wp-login.php?action=logout` );
69+
const confirmLogout = page.locator( 'a[href*="action=logout"]' );
70+
if ( await confirmLogout.isVisible( { timeout: 3_000 } ).catch( () => false ) ) {
71+
await confirmLogout.click();
72+
}
73+
await page.waitForTimeout( 500 );
74+
await wpLogin( page, TEST_USER, TEST_PASS );
75+
76+
const cookies = await ctx.cookies();
77+
const authCook = cookies.find( c => c.name.startsWith( 'wordpress_logged_in_' ) );
78+
console.log( ` Auth cookie expires = ${authCook?.expires}` );
79+
80+
// With default WP (no remember-me), expire should be -1 (session cookie).
81+
expect( authCook ).toBeTruthy();
82+
expect( authCook.expires ).toBe( -1 );
83+
console.log( '✅ Confirmed: default login is a session cookie (expires=-1).' );
84+
await ctx.close();
85+
} );
86+
87+
// ─────────────────────────────────────────────────────────────────────────────
88+
// 2. With custom duration — cookie must be persistent (expires > 0)
89+
// ─────────────────────────────────────────────────────────────────────────────
90+
test( 'Custom 30-day session — auth cookie must be persistent (expires > 0)', async ( { browser } ) => {
91+
const ctx = await browser.newContext( { ignoreHTTPSErrors: true } );
92+
const page = await ctx.newPage();
93+
await wpLogin( page, TEST_USER, TEST_PASS );
94+
95+
// Set 30-day session.
96+
await page.goto( SETTINGS_URL );
97+
await page.locator( '#cs-session-duration' ).selectOption( '30' );
98+
await page.locator( '#cs-session-save' ).click();
99+
await expect( page.locator( '#cs-session-saved' ) ).toBeVisible( { timeout: 5_000 } );
100+
console.log( '✅ Session duration set to 30 days.' );
101+
102+
// Log out and back in so the new cookie is issued.
103+
await page.goto( `${SITE}/wp-login.php?action=logout` );
104+
const confirmLogout = page.locator( 'a[href*="action=logout"]' );
105+
if ( await confirmLogout.isVisible( { timeout: 3_000 } ).catch( () => false ) ) {
106+
await confirmLogout.click();
107+
}
108+
await page.waitForTimeout( 500 );
109+
await wpLogin( page, TEST_USER, TEST_PASS );
110+
111+
const cookies = await ctx.cookies();
112+
const authCook = cookies.find( c => c.name.startsWith( 'wordpress_logged_in_' ) );
113+
console.log( ` Auth cookie expires = ${authCook?.expires} (0 = session cookie, >0 = persistent)` );
114+
115+
expect( authCook ).toBeTruthy();
116+
// -1 or 0 means session cookie — this is the bug we're fixing.
117+
expect( authCook.expires ).toBeGreaterThan( 0 );
118+
119+
const expiryDate = new Date( authCook.expires * 1000 );
120+
const daysFromNow = ( authCook.expires - Date.now() / 1000 ) / 86400;
121+
console.log( ` Expires: ${expiryDate.toISOString()} (~${Math.round( daysFromNow )} days from now)` );
122+
expect( daysFromNow ).toBeGreaterThan( 25 ); // should be ~30 days
123+
console.log( '✅ Auth cookie is persistent — survives browser kill/swipe-up.' );
124+
await ctx.close();
125+
} );
126+
127+
// ─────────────────────────────────────────────────────────────────────────────
128+
// 3. Simulate browser kill — new context with saved cookies stays logged in
129+
// ─────────────────────────────────────────────────────────────────────────────
130+
test( 'Simulated browser kill — re-opening with saved cookies stays logged in', async ( { browser } ) => {
131+
// First context: log in with 30-day session and grab cookies.
132+
const ctx1 = await browser.newContext( { ignoreHTTPSErrors: true } );
133+
const page1 = await ctx1.newPage();
134+
await wpLogin( page1, TEST_USER, TEST_PASS );
135+
136+
// Ensure 30-day duration is set.
137+
await page1.goto( SETTINGS_URL );
138+
await page1.locator( '#cs-session-duration' ).selectOption( '30' );
139+
await page1.locator( '#cs-session-save' ).click();
140+
await expect( page1.locator( '#cs-session-saved' ) ).toBeVisible( { timeout: 5_000 } );
141+
142+
// Re-login to get the fresh persistent cookie.
143+
await page1.goto( `${SITE}/wp-login.php?action=logout` );
144+
const confirmLogout = page1.locator( 'a[href*="action=logout"]' );
145+
if ( await confirmLogout.isVisible( { timeout: 3_000 } ).catch( () => false ) ) {
146+
await confirmLogout.click();
147+
}
148+
await page1.waitForTimeout( 500 );
149+
await wpLogin( page1, TEST_USER, TEST_PASS );
150+
151+
const cookies = await ctx1.cookies();
152+
const authCook = cookies.find( c => c.name.startsWith( 'wordpress_logged_in_' ) );
153+
expect( authCook?.expires ).toBeGreaterThan( 0 );
154+
console.log( ` Captured persistent cookie: ${authCook?.name}, expires ${new Date( authCook.expires * 1000 ).toISOString()}` );
155+
156+
// "Kill" the browser — close context 1.
157+
await ctx1.close();
158+
console.log( ' Browser context closed (simulating swipe-up / app kill).' );
159+
160+
// "Reopen" — new context with the saved cookies (simulating restored session).
161+
const ctx2 = await browser.newContext( { ignoreHTTPSErrors: true } );
162+
await ctx2.addCookies( cookies );
163+
const page2 = await ctx2.newPage();
164+
await page2.goto( `${SITE}/wp-admin/`, { waitUntil: 'domcontentloaded' } );
165+
166+
// Should land in wp-admin, not be redirected to login.
167+
const finalUrl = page2.url();
168+
console.log( ` After "reopen", landed at: ${finalUrl}` );
169+
expect( finalUrl ).not.toContain( 'wp-login.php' );
170+
expect( finalUrl ).toContain( 'wp-admin' );
171+
console.log( '✅ Session survived simulated browser kill — still logged in.' );
172+
await ctx2.close();
173+
} );
174+
175+
// ─────────────────────────────────────────────────────────────────────────────
176+
// Cleanup — restore default session duration
177+
// ─────────────────────────────────────────────────────────────────────────────
178+
test( 'Cleanup — restore default session duration', async ( { browser } ) => {
179+
const ctx = await browser.newContext( { ignoreHTTPSErrors: true } );
180+
const page = await ctx.newPage();
181+
await wpLogin( page, TEST_USER, TEST_PASS );
182+
await page.goto( SETTINGS_URL );
183+
await page.locator( '#cs-session-duration' ).selectOption( 'default' );
184+
await page.locator( '#cs-session-save' ).click();
185+
await expect( page.locator( '#cs-session-saved' ) ).toBeVisible( { timeout: 5_000 } );
186+
console.log( '✅ Session duration restored to default.' );
187+
await ctx.close();
188+
} );

0 commit comments

Comments
 (0)