diff --git a/app/Config/Events.php b/app/Config/Events.php
index 07332c3d9ef5e10c109c3274bbc286156021f6d5..6d7f32bbf041449127d4ff5a4f89a4f07966febb 100644
--- a/app/Config/Events.php
+++ b/app/Config/Events.php
@@ -28,7 +28,6 @@ use CodeIgniter\Exceptions\FrameworkException;
  */
 
 Events::on('pre_system', static function () {
-    // @phpstan-ignore-next-line
     if (ENVIRONMENT !== 'testing') {
         if (ini_get('zlib.output_compression')) {
             throw FrameworkException::forEnabledZlibOutputCompression();
@@ -46,8 +45,6 @@ Events::on('pre_system', static function () {
      * Debug Toolbar Listeners.
      * --------------------------------------------------------------------
      * If you delete, they will no longer be collected.
-     *
-     * @phpstan-ignore-next-line
      */
     if (CI_DEBUG && ! is_cli()) {
         Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect');
diff --git a/modules/Auth/Config/Auth.php b/modules/Auth/Config/Auth.php
index e9017374296b69c0594e20a687b217111ea63b9f..24ef0c89a9b29df70fedace4c3581e6cc62a9395 100644
--- a/modules/Auth/Config/Auth.php
+++ b/modules/Auth/Config/Auth.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Modules\Auth\Config;
 
 use CodeIgniter\Shield\Authentication\Actions\ActionInterface;
+use CodeIgniter\Shield\Authentication\Actions\Email2FA;
 use CodeIgniter\Shield\Config\Auth as ShieldAuth;
 use Modules\Auth\Models\UserModel;
 
@@ -75,6 +76,14 @@ class Auth extends ShieldAuth
      */
     public bool $allowRegistration = true;
 
+    /**
+     * --------------------------------------------------------------------
+     * Allow Two-Factor Authentication
+     * --------------------------------------------------------------------
+     * Determines whether email 2FA is enabled.
+     */
+    public bool $enable2FA = false;
+
     /**
      * --------------------------------------------------------------------
      * Welcome Link Lifetime
@@ -108,6 +117,8 @@ class Auth extends ShieldAuth
 
     public function __construct()
     {
+        parent::__construct();
+
         $adminGateway = config('Admin')
             ->gateway;
 
@@ -116,6 +127,12 @@ class Auth extends ShieldAuth
             'login' => $adminGateway,
             'logout' => $adminGateway,
         ];
+
+        // FIXME: enable2FA config can only be updated in the .env
+        // Using the settings service to have it set in the db causes infinite loop.
+        if ($this->enable2FA) {
+            $this->actions['login'] = Email2FA::class;
+        }
     }
 
     /**
diff --git a/modules/Auth/Language/en/Auth.php b/modules/Auth/Language/en/Auth.php
index 6928cf9b10fa8c3fd3ba766c1063fab806edcf4a..09e3cd6b0bb7426a23339a416528b828915357fc 100644
--- a/modules/Auth/Language/en/Auth.php
+++ b/modules/Auth/Language/en/Auth.php
@@ -80,6 +80,10 @@ return [
         'episodes.manage-publications' => 'Can publish/unpublish episodes and posts of podcast #{id}.',
         'episodes.manage-comments' => 'Can create/remove episode comments of podcast #{id}.',
     ],
+
+    // missing keys
+    'code' => 'Your 6-digit code',
+
     'notEnoughPrivilege' => 'You do not have sufficient permissions to access that page.',
     'set_password' => 'Set your password',
 
diff --git a/phpstan.neon b/phpstan.neon
index 8e79ac0600b4c0a7aafd10195b4de8b89f517e3d..4c46c3faafac82cea6b46b9bb22e609d684d426f 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -20,6 +20,11 @@ parameters:
         - app/Views/*
         - modules/*/Views/*
         - themes/*
+    dynamicConstantNames:
+        - APP_NAMESPACE
+        - CI_DEBUG
+        - ENVIRONMENT
+        - SODIUM_LIBRARY_VERSION
     ignoreErrors:
         - '#Cannot access property [\$a-z_]+ on ((array\|)?object)#'
         - '#^Call to an undefined method CodeIgniter\\Database\\ConnectionInterface#'
diff --git a/public/index.php b/public/index.php
index cc758a8b11a8470e3ac42702414b51684702f020..b80d9fbf47ee5188f0c7f6ca00770535a20a529d 100644
--- a/public/index.php
+++ b/public/index.php
@@ -6,6 +6,18 @@ use CodeIgniter\Config\DotEnv;
 use Config\Paths;
 use Config\Services;
 
+// Check PHP version.
+$minPhpVersion = '8.0'; // If you update this, don't forget to update `spark`.
+if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
+    $message = sprintf(
+        'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s',
+        $minPhpVersion,
+        PHP_VERSION
+    );
+
+    exit($message);
+}
+
 // Path to the front controller (this file)
 define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR);
 
diff --git a/public/media/site/index.html b/public/media/site/index.html
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/rector.php b/rector.php
index 881a5ebde1fd0ba5ac96b561657ad5dc8a3a56cf..96dad228bcd29d6766f4af7f3ab9a4577ae4ba2f 100644
--- a/rector.php
+++ b/rector.php
@@ -10,6 +10,7 @@ use Rector\CodingStyle\Rector\Stmt\NewlineAfterStatementRector;
 use Rector\CodingStyle\Rector\String_\SymplifyQuoteEscapeRector;
 use Rector\Config\RectorConfig;
 use Rector\Core\ValueObject\PhpVersion;
+use Rector\DeadCode\Rector\If_\UnwrapFutureCompatibleIfPhpVersionRector;
 use Rector\EarlyReturn\Rector\If_\ChangeOrIfContinueToMultiContinueRector;
 use Rector\EarlyReturn\Rector\If_\ChangeOrIfReturnToEarlyReturnRector;
 use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector;
@@ -61,6 +62,7 @@ return static function (RectorConfig $rectorConfig): void {
         UnSpreadOperatorRector::class,
         ExplicitMethodCallOverMagicGetSetRector::class,
         RemoveExtraParametersRector::class,
+        UnwrapFutureCompatibleIfPhpVersionRector::class,
 
         // skip rule in specific directory
         StringClassNameToClassConstantRector::class => [
diff --git a/spark b/spark
index 225422aace746f0fbf24260668959ed9869b6508..306cf95732be2a5859e9ec04e38f32a40085ded4 100644
--- a/spark
+++ b/spark
@@ -26,6 +26,18 @@ if (strpos(PHP_SAPI, 'cgi') === 0) {
     exit("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n");
 }
 
+// Check PHP version.
+$minPhpVersion = '8.0'; // If you update this, don't forget to update `public/index.php`.
+if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
+    $message = sprintf(
+        'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s',
+        $minPhpVersion,
+        PHP_VERSION
+    );
+
+    exit($message);
+}
+
 // We want errors to be shown when using it from the CLI.
 error_reporting(-1);
 ini_set('display_errors', '1');
diff --git a/themes/cp_auth/email_2fa_show.php b/themes/cp_auth/email_2fa_show.php
index ddedc530642f1e57f7483e5ee6faf136cafe3a38..9e70a42a4547ef5c63757b2300bad9b0a1b3abed 100644
--- a/themes/cp_auth/email_2fa_show.php
+++ b/themes/cp_auth/email_2fa_show.php
@@ -1,38 +1,26 @@
+<?= helper('form') ?>
 <?= $this->extend(config('Auth')->views['layout']) ?>
 
 <?= $this->section('title') ?><?= lang('Auth.email2FATitle') ?> <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
 
-<div class="container p-5 d-flex justify-content-center">
-    <div class="shadow-sm card col-12 col-md-5">
-        <div class="card-body">
-            <h5 class="mb-5 card-title"><?= lang('Auth.email2FATitle') ?></h5>
+<form action="<?= url_to('auth-action-handle') ?>" method="POST" class="flex flex-col w-full gap-y-4">
+    <?= csrf_field() ?>
 
-            <p><?= lang('Auth.confirmEmailAddress') ?></p>
+    <Forms.Field
+        name="email"
+        label="<?= lang('Auth.email') ?>"
+        helper="<?= lang('Auth.confirmEmailAddress') ?>"
+        required="true"
+        type="email"
+        inputmode="email"
+        autocomplete="email"
+        value="<?= $user->email ?>"
+    />
 
-            <?php if (session('error')) : ?>
-                <div class="alert alert-danger"><?= session('error') ?></div>
-            <?php endif ?>
+    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.send') ?></Button>
+</form>
 
-            <form action="<?= url_to('auth-action-handle') ?>" method="post">
-                <?= csrf_field() ?>
-
-                <!-- Email -->
-                <div class="mb-2">
-                    <input type="email" class="form-control" name="email"
-                        inputmode="email" autocomplete="email" placeholder="<?= lang('Auth.email') ?>"
-                        <?php /** @var \CodeIgniter\Shield\Entities\User $user */ ?>
-                        value="<?= old('email', $user->email) ?>" required />
-                </div>
-
-                <div class="m-3 mx-auto d-grid col-8">
-                    <button type="submit" class="btn btn-primary btn-block"><?= lang('Auth.send') ?></button>
-                </div>
-
-            </form>
-        </div>
-    </div>
-</div>
 
 <?= $this->endSection() ?>
diff --git a/themes/cp_auth/email_2fa_verify.php b/themes/cp_auth/email_2fa_verify.php
index e9dad7f40cfa36c846df27e75ba9695317c45d7d..18c6715624515c2e4753240e0be7cf9e41a8c493 100644
--- a/themes/cp_auth/email_2fa_verify.php
+++ b/themes/cp_auth/email_2fa_verify.php
@@ -1,36 +1,26 @@
+<?= helper('form') ?>
 <?= $this->extend(config('Auth')->views['layout']) ?>
 
-<?= $this->section('title') ?><?= lang('Auth.email2FATitle') ?> <?= $this->endSection() ?>
+<?= $this->section('title') ?><?= lang('Auth.emailEnterCode') ?> <?= $this->endSection() ?>
 
 <?= $this->section('content') ?>
 
-<div class="container p-5 d-flex justify-content-center">
-    <div class="shadow-sm card col-12 col-md-5">
-        <div class="card-body">
-            <h5 class="mb-5 card-title"><?= lang('Auth.emailEnterCode') ?></h5>
-
-            <p><?= lang('Auth.emailConfirmCode') ?></p>
-
-            <?php if (session('error') !== null) : ?>
-            <div class="alert alert-danger"><?= session('error') ?></div>
-            <?php endif ?>
-
-            <form action="<?= url_to('auth-action-verify') ?>" method="post">
-                <?= csrf_field() ?>
-
-                <!-- Code -->
-                <div class="mb-2">
-                    <input type="number" class="form-control" name="token" placeholder="000000"
-                        inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" required />
-                </div>
-
-                <div class="m-3 mx-auto d-grid col-8">
-                    <button type="submit" class="btn btn-primary btn-block"><?= lang('Auth.confirm') ?></button>
-                </div>
-
-            </form>
-        </div>
-    </div>
-</div>
+<form action="<?= url_to('auth-action-verify') ?>" method="POST" class="flex flex-col w-full gap-y-4">
+    <?= csrf_field() ?>
+
+    <Forms.Field
+        name="token"
+        label="<?= lang('Auth.code') ?>"
+        helper="<?= lang('Auth.emailConfirmCode') ?>"
+        pattern="[0-9]*"
+        placeholder="000000"
+        required="true"
+        type="number"
+        inputmode="numeric"
+        autocomplete="one-time-code"
+    />
+
+    <Button variant="primary" type="submit" class="self-end"><?= lang('Auth.confirm') ?></Button>
+</form>
 
 <?= $this->endSection() ?>