diff --git a/.env.example b/.env.example
index bcc84a0..4d21c9b 100644
--- a/.env.example
+++ b/.env.example
@@ -13,5 +13,10 @@ EMIT_SECRET=
SMTP_USERNAME=
SMTP_PASSWORD=
+# Email OTP on sign-in. Leave true; only the exact value "false" makes sign-in
+# password only (logged as OTP_BYPASSED, shown on the login page and top bar).
+# Applied to app/config.php by the php container on every start.
+OTP_REQUIRED=true
+
# Port to expose the web app on (default 80)
HTTP_PORT=80
diff --git a/app/assets/utils/otp_policy.php b/app/assets/utils/otp_policy.php
new file mode 100644
index 0000000..69303b2
--- /dev/null
+++ b/app/assets/utils/otp_policy.php
@@ -0,0 +1,35 @@
+= 100;
+
+
+
+
+
+ OTP off
+
+
+
+
diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php
index c4daaf7..e9c07fa 100644
--- a/app/login/api/engine/login_confirm.php
+++ b/app/login/api/engine/login_confirm.php
@@ -58,6 +58,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
+require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Load session state written by login_otp.php ───────────────────────
$data["username"] = $_SESSION["login_data"]['username'];
@@ -100,9 +101,15 @@ $_SESSION["diff"] = $otp_diff_minutes;
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
-// so they never receive or enter an OTP. Admin/owner always go through this check.
+// so they never receive or enter an OTP. Admin/owner always go through this check,
+// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
+// on the OTP screen when the switch was turned off.
if (empty($_SESSION['skip_otp'])) {
- if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
+ if (!otp_required()) {
+ if (!empty($user_id)) {
+ otp_log_bypass($user_id, 'login_confirm');
+ }
+ } elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
diff --git a/app/login/api/engine/login_otp.php b/app/login/api/engine/login_otp.php
index 46c4994..dbee542 100644
--- a/app/login/api/engine/login_otp.php
+++ b/app/login/api/engine/login_otp.php
@@ -62,6 +62,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
+require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
@@ -253,11 +254,15 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
exit(json_encode($answer));
}
- // ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
- // Owners always require 2FA. Invited users (license='user') require 2FA only
+ // ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
+ // OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
+ // logs the sign-in as a bypass (see assets/utils/otp_policy.php).
+ // Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
// if their role in this company is admin or owner; staff/viewer go straight in.
- $requires_otp = true;
- if (($r['license'] ?? 'owner') !== 'owner') {
+ $requires_otp = otp_required();
+ if (!$requires_otp) {
+ otp_log_bypass($user_id, 'login_otp');
+ } elseif (($r['license'] ?? 'owner') !== 'owner') {
$sth_role = $pdo1->prepare(
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
);
diff --git a/app/login/index.php b/app/login/index.php
index ba06d41..f040332 100644
--- a/app/login/index.php
+++ b/app/login/index.php
@@ -1,6 +1,7 @@