Step-by-Step Guide to building a custom two-factor authentication block for Gutenberg using Vanilla CSS shadow DOM style layers
Gutenberg Block Development: Two-Factor Authentication Component
This guide details the construction of a custom Gutenberg block for integrating two-factor authentication (2FA) within WordPress. We will focus on a robust implementation using Vanilla CSS and the Shadow DOM for style encapsulation, ensuring a clean separation of concerns and preventing style conflicts with the broader WordPress admin or frontend themes. This approach is critical for enterprise-level deployments where consistent UI and security are paramount.
I. Block Registration and Server-Side Logic (PHP)
The foundation of any Gutenberg block lies in its registration. For a 2FA block, we need server-side logic to handle the authentication process, generate codes, and verify user input. This involves registering a server-side rendering callback.
A. Plugin Structure and Registration
Create a simple WordPress plugin to house your block. The main plugin file will handle the registration.
1. Plugin File (`two-factor-auth-block.php`)
<?php
/**
* Plugin Name: Two-Factor Authentication Block
* Description: Custom Gutenberg block for 2FA integration.
* Version: 1.0.0
* Author: Antigravity
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: two-factor-auth-block
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Registers the block using the metadata loaded from the `block.json` file.
* Behind the scenes, it registers also all assets so they can be enqueued
* through the block editor in the corresponding `wp_enqueue_scripts` action.
*
* @see https://developer.wordpress.org/reference/functions/register_block_type/
*/
function two_factor_auth_block_init() {
register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'two_factor_auth_block_init' );
B. Block Metadata (`block.json`)
This file defines the block’s properties, including its name, category, and script/style dependencies. Crucially, it points to the build directory where our compiled assets will reside.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "antigravity/two-factor-auth-block",
"version": "1.0.0",
"title": "Two-Factor Authentication",
"category": "security",
"icon": "shield-alt",
"description": "Integrates a two-factor authentication input field.",
"attributes": {
"message": {
"type": "string",
"default": "Enter your 6-digit verification code."
}
},
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/index.css",
"style": "file:./build/style-index.css",
"render": "file:./render.php"
}
C. Server-Side Rendering (`render.php`)
This PHP file handles the server-side rendering of the block. It will output the HTML structure for the 2FA input, including a hidden field for nonce verification and a placeholder for the verification code input. The actual input field will be managed by JavaScript in the frontend.
<?php
/**
* PHP file to use when rendering the block type on the server.
*
* @package create-block
*/
// Ensure the nonce is valid.
if ( ! isset( $attributes['nonce'] ) || ! wp_verify_nonce( $attributes['nonce'], 'two_factor_auth_nonce_action' ) ) {
// In a real-world scenario, you'd handle this more gracefully,
// perhaps by logging an error or displaying a user-friendly message.
// For this example, we'll just output a placeholder.
echo '<div class="two-factor-auth-block-error">Security check failed.</div>';
return;
}
$message = isset( $attributes['message'] ) ? esc_html( $attributes['message'] ) : 'Enter your 6-digit verification code.';
$block_id = 'two-factor-auth-' . uniqid(); // Unique ID for Shadow DOM attachment.
?>
<div id="" class="two-factor-auth-wrapper">
<p><?php echo esc_html( $message ); ?></p>
<!-- The actual input field will be rendered by JavaScript -->
<div class="two-factor-auth-input-placeholder"></div>
<!-- Hidden nonce for client-side verification -->
<input type="hidden" name="two_factor_auth_nonce" value="" />
<!-- Hidden field for the actual code input, managed by JS -->
<input type="hidden" name="two_factor_auth_code" id="two_factor_auth_code_" />
</div>
II. Frontend JavaScript and Shadow DOM Integration
The frontend JavaScript is responsible for creating the interactive 2FA input field and attaching it to the Shadow DOM. This ensures that the input’s styling is isolated.
A. JavaScript Entry Point (`src/index.js`)
This file will be the main entry point for our block’s JavaScript. It will find instances of our block and initialize the Shadow DOM components.
import { registerBlockType } from '@wordpress/blocks';
import './style.scss'; // For editor styles
import './editor.scss'; // For frontend styles
import Edit from './edit';
import save from './save';
registerBlockType('antigravity/two-factor-auth-block', {
edit: Edit,
save: save,
});
// Initialize Shadow DOM components on frontend
document.addEventListener('DOMContentLoaded', () => {
const wrappers = document.querySelectorAll('.two-factor-auth-wrapper');
wrappers.forEach(wrapper => {
const placeholder = wrapper.querySelector('.two-factor-auth-input-placeholder');
if (placeholder) {
const blockId = wrapper.id;
const message = wrapper.querySelector('p').textContent;
const nonceInput = wrapper.querySelector('input[name="two_factor_auth_nonce"]');
const codeInput = wrapper.querySelector(`#two_factor_auth_code_${blockId}`);
if (placeholder && blockId && message && nonceInput && codeInput) {
const shadowRoot = placeholder.attachShadow({ mode: 'open' });
renderTwoFactorInput(shadowRoot, blockId, message, nonceInput.value, codeInput);
}
}
});
});
function renderTwoFactorInput(shadowRoot, blockId, message, nonce, codeInput) {
// Dynamically create elements within the Shadow DOM
const container = document.createElement('div');
container.className = 'two-factor-auth-shadow-container';
const inputGroup = document.createElement('div');
inputGroup.className = 'two-factor-auth-input-group';
// Create 6 input fields for digits
for (let i = 0; i < 6; i++) {
const input = document.createElement('input');
input.type = 'text';
input.maxLength = 1;
input.dataset.index = i;
input.className = 'two-factor-auth-digit-input';
input.id = `digit-${i}-${blockId}`;
input.addEventListener('input', (e) => {
const currentInput = e.target;
const nextInput = shadowRoot.getElementById(`digit-${parseInt(currentInput.dataset.index) + 1}-${blockId}`);
const prevInput = shadowRoot.getElementById(`digit-${parseInt(currentInput.dataset.index) - 1}-${blockId}`);
// Move to next input
if (currentInput.value.length === 1 && nextInput) {
nextInput.focus();
}
// Backspace handling
if (e.inputType === 'deleteContentBackward' && prevInput && currentInput.value.length === 0) {
prevInput.focus();
}
updateHiddenCodeInput(shadowRoot, blockId, codeInput);
});
inputGroup.appendChild(input);
}
// Append styles to Shadow DOM
const style = document.createElement('style');
style.textContent = `
.two-factor-auth-shadow-container {
font-family: sans-serif;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.two-factor-auth-input-group {
display: flex;
gap: 8px;
}
.two-factor-auth-digit-input {
width: 40px;
height: 40px;
text-align: center;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1.2em;
box-sizing: border-box; /* Include padding and border in the element's total width and height */
}
.two-factor-auth-digit-input:focus {
outline: none;
border-color: #0073aa; /* WordPress blue */
box-shadow: 0 0 0 1px #0073aa;
}
/* Basic responsive adjustments */
@media (max-width: 600px) {
.two-factor-auth-input-group {
gap: 5px;
}
.two-factor-auth-digit-input {
width: 35px;
height: 35px;
font-size: 1em;
}
}
`;
shadowRoot.appendChild(style);
shadowRoot.appendChild(container);
container.appendChild(inputGroup);
}
function updateHiddenCodeInput(shadowRoot, blockId, codeInput) {
let code = '';
const inputs = shadowRoot.querySelectorAll(`.two-factor-auth-digit-input`);
inputs.forEach(input => {
code += input.value;
});
codeInput.value = code;
// console.log('Updated hidden code:', code); // For debugging
}
B. Editor Component (`src/edit.js`)
The `edit.js` file defines how the block appears in the Gutenberg editor. For this block, it will render a preview of the 2FA input and allow editing of the message attribute.
import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText } from '@wordpress/block-editor';
import './editor.scss';
export default function Edit({ attributes, setAttributes }) {
const blockProps = useBlockProps();
const { message } = attributes;
const onChangeMessage = (newMessage) => {
setAttributes({ message: newMessage });
};
// In the editor, we'll simulate the input for preview purposes.
// The actual Shadow DOM rendering happens on the frontend.
return (
<div { ...blockProps }>
<RichText
tagName="p"
value={ message }
onChange={ onChangeMessage }
placeholder={ __('Enter your message...', 'two-factor-auth-block') }
allowedFormats={ ['core/bold', 'core/italic'] }
/>
<div className="two-factor-auth-editor-preview">
<p>{ message }</p>
<div className="two-factor-auth-input-group-preview">
{[...Array(6)].map((_, i) => (
<input
key={ i }
type="text"
maxLength="1"
className="two-factor-auth-digit-input-preview"
readOnly // Make it read-only in the editor
placeholder="•"
/>
))}
</div>
<p>[Verification Code Input Preview]</p>
</div>
</div>
);
}
C. Save Component (`src/save.js`)
The `save.js` file determines the static HTML that is saved to the database. Since our server-side rendering handles the actual output, this file will simply return `null` or a minimal wrapper.
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save({ attributes }) {
const blockProps = useBlockProps.save();
const { message } = attributes;
// The server-side rendering (render.php) handles the actual HTML output.
// This save function is primarily for defining what gets saved in the post_content.
// We'll save the message attribute, and the server-side rendering will use it.
// The nonce is generated server-side on each render, so we don't save it here.
return (
<div { ...blockProps }>
<RichText.Content tagName="p" value={ message } />
<!-- The actual input will be rendered by PHP on the server -->
<div className="two-factor-auth-input-placeholder"></div>
<!-- Hidden nonce and code input fields are also handled by PHP -->
</div>
);
}
III. Styling with Vanilla CSS and Shadow DOM Layers
The key to style encapsulation is leveraging the Shadow DOM. Styles defined within the Shadow DOM are scoped to that specific component and do not leak out. We’ll use separate CSS files for the editor and the frontend, with the core styles residing within the JavaScript for Shadow DOM injection.
A. Editor Styles (`src/editor.scss`)
These styles apply only within the Gutenberg editor.
.wp-block-antigravity-two-factor-auth-block {
border: 1px dashed #ccc;
padding: 15px;
margin-bottom: 15px;
}
.two-factor-auth-editor-preview {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border: 1px solid #eee;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.two-factor-auth-input-group-preview {
display: flex;
gap: 8px;
}
.two-factor-auth-digit-input-preview {
width: 40px;
height: 40px;
text-align: center;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1.2em;
background-color: #fff;
box-sizing: border-box;
}
B. Frontend Styles (`src/style.scss`)
These styles apply to the block on the frontend, outside the Shadow DOM.
.two-factor-auth-wrapper {
/* Styles for the wrapper element rendered by PHP */
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 15px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background-color: #fdfdfd;
}
.two-factor-auth-wrapper p {
margin-bottom: 0;
color: #333;
font-size: 0.95em;
}
.two-factor-auth-block-error {
color: red;
font-weight: bold;
padding: 10px;
border: 1px solid red;
background-color: #ffebeb;
border-radius: 4px;
}
C. Shadow DOM Styles (Injected via JavaScript)
As shown in `src/index.js`, styles are dynamically created and appended to the Shadow DOM’s `
` or directly to the shadow root. This ensures complete isolation. The CSS provided in the JavaScript example covers the input fields and their container within the Shadow DOM.IV. Build Process and Deployment
To compile the JavaScript and SCSS files into the `build` directory, you’ll need a build tool like `@wordpress/scripts`. Ensure you have Node.js and npm installed.
A. Install Dependencies
cd your-plugin-directory npm init -y npm install @wordpress/scripts --save-dev
B. Configure `package.json`
Add the following scripts to your `package.json` file:
{
"name": "two-factor-auth-block",
"version": "1.0.0",
"description": "Custom Gutenberg block for 2FA integration.",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"packages-update": "wp-scripts packages-update"
},
"keywords": ["wordpress", "gutenberg", "block", "2fa", "security"],
"author": "Antigravity",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@wordpress/scripts": "^27.0.0"
}
}
C. Build the Block
Run the build command to compile your assets:
npm run build
This will generate the necessary JavaScript and CSS files in the `build` directory, which are then referenced by `block.json`.
V. Security Considerations and Next Steps
This implementation provides a foundational 2FA input. For a production-ready solution, consider the following:
- Backend Verification Logic: The `render.php` only includes nonce verification. A real-world scenario requires a separate AJAX endpoint or form submission handler to verify the entered 6-digit code against a user’s 2FA secret (e.g., TOTP).
- User Management Integration: Link the 2FA secret generation and management to user profiles.
- Error Handling and Feedback: Implement clear user feedback for incorrect codes, expired codes, or system errors.
- Rate Limiting: Protect against brute-force attacks on the 2FA code input.
- Accessibility: Ensure the Shadow DOM elements and their interactions are fully accessible (ARIA attributes, keyboard navigation).
- Security Audits: Thoroughly audit the entire authentication flow for vulnerabilities.
By using Shadow DOM for styling, we’ve achieved a highly maintainable and conflict-free component that can be reliably integrated into any WordPress environment, upholding enterprise-grade security and UI consistency.