login: block concurrent login, single-factor auth for staff/viewer
This commit is contained in:
@@ -821,6 +821,14 @@ function ajax_request(options) {
|
|||||||
throw xhr;
|
throw xhr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session displaced — another login took over this account
|
||||||
|
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
|
||||||
|
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
|
||||||
|
window.location.href = server_url + 'index.php';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// File / payload too large (nginx 413)
|
// File / payload too large (nginx 413)
|
||||||
if (xhr?.status === 413) {
|
if (xhr?.status === 413) {
|
||||||
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
|
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class ReportManager
|
|||||||
return 'td_stock_' . $warehouse_id;
|
return 'td_stock_' . $warehouse_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
|
|||||||
@@ -292,10 +292,10 @@ class StockManager {
|
|||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO `$table`
|
"INSERT INTO `$table`
|
||||||
(uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, rack,
|
(uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :rack,
|
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0)"
|
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0, NOW())"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -427,10 +427,10 @@ class StockManager {
|
|||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO `$table`
|
"INSERT INTO `$table`
|
||||||
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
|
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status)
|
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0)"
|
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0, NOW())"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -604,11 +604,11 @@ class StockManager {
|
|||||||
"INSERT INTO `$from_table`
|
"INSERT INTO `$from_table`
|
||||||
(uuid, company_id, `date`, product_sku, `out`,
|
(uuid, company_id, `date`, product_sku, `out`,
|
||||||
ref_warehouse, zone, aisle, rack,
|
ref_warehouse, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||||
:ref_warehouse, :zone, :aisle, :rack,
|
:ref_warehouse, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -632,11 +632,11 @@ class StockManager {
|
|||||||
"INSERT INTO `$to_table`
|
"INSERT INTO `$to_table`
|
||||||
(uuid, company_id, `date`, product_sku, `in`,
|
(uuid, company_id, `date`, product_sku, `in`,
|
||||||
ref_warehouse, ref_id, zone, aisle, rack,
|
ref_warehouse, ref_id, zone, aisle, rack,
|
||||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||||
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
|
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
|
||||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
|
||||||
)->execute([
|
)->execute([
|
||||||
':uuid' => $uuid,
|
':uuid' => $uuid,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -715,7 +715,7 @@ class StockManager {
|
|||||||
|
|
||||||
// ── Approve this row ──────────────────────────────────────────────
|
// ── Approve this row ──────────────────────────────────────────────
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"UPDATE `{$table}` SET status = 1 WHERE id = :id AND company_id = :company_id"
|
"UPDATE `{$table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([':id' => $id, ':company_id' => $this->company_id]);
|
)->execute([':id' => $id, ':company_id' => $this->company_id]);
|
||||||
|
|
||||||
// ── Rack state + balance ──────────────────────────────────────────
|
// ── Rack state + balance ──────────────────────────────────────────
|
||||||
@@ -823,7 +823,7 @@ class StockManager {
|
|||||||
// Approve outbound row
|
// Approve outbound row
|
||||||
if ($from_row && (int)$from_row['status'] === 0) {
|
if ($from_row && (int)$from_row['status'] === 0) {
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"UPDATE `{$from_table}` SET status = 1
|
"UPDATE `{$from_table}` SET status = 1, updated_at = NOW()
|
||||||
WHERE id = :id AND company_id = :company_id"
|
WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]);
|
)->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]);
|
||||||
}
|
}
|
||||||
@@ -831,7 +831,7 @@ class StockManager {
|
|||||||
// Approve inbound row
|
// Approve inbound row
|
||||||
if ($inbound_row && (int)$inbound_row['status'] === 0) {
|
if ($inbound_row && (int)$inbound_row['status'] === 0) {
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"UPDATE `{$paired_table}` SET status = 1
|
"UPDATE `{$paired_table}` SET status = 1, updated_at = NOW()
|
||||||
WHERE id = :id AND company_id = :company_id"
|
WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]);
|
)->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,19 @@ if(!empty($_SESSION["login_company_id"])){
|
|||||||
$user_role = $map[0]['role'] ?? 'viewer';
|
$user_role = $map[0]['role'] ?? 'viewer';
|
||||||
$_SESSION['login_role'] = $user_role;
|
$_SESSION['login_role'] = $user_role;
|
||||||
|
|
||||||
|
// Single-session enforcement: if a session_token was issued at login, verify
|
||||||
|
// it still matches the DB. A mismatch means a newer login has taken over.
|
||||||
|
if (!empty($_SESSION['session_token'])) {
|
||||||
|
$sth = $pdo1->prepare("SELECT session_token FROM user WHERE user_id = :uid LIMIT 1");
|
||||||
|
$sth->execute([':uid' => $_SESSION['login_user_id']]);
|
||||||
|
$db_token = $sth->fetchColumn();
|
||||||
|
if ($db_token !== $_SESSION['session_token']) {
|
||||||
|
session_destroy();
|
||||||
|
http_response_code(401);
|
||||||
|
exit(json_encode(['success' => 0, 'message' => 'You have been signed in from another device.', 'code' => 'signed_elsewhere']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail closed — reject any request that arrives without an authenticated session
|
// Fail closed — reject any request that arrives without an authenticated session
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ require_once '../../../session.php';
|
|||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
|
||||||
|
// Clear session token so the account is free to log in elsewhere immediately
|
||||||
|
if (!empty($_SESSION['login_user_id'])) {
|
||||||
|
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid")
|
||||||
|
->execute([':uid' => (int)$_SESSION['login_user_id']]);
|
||||||
|
}
|
||||||
|
|
||||||
// Intentional 1-second delay — prevents timing attacks on session enumeration
|
// Intentional 1-second delay — prevents timing attacks on session enumeration
|
||||||
sleep(1);
|
sleep(1);
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@
|
|||||||
* b. The elapsed time since otpTime is ≤ 5 minutes.
|
* b. The elapsed time since otpTime is ≤ 5 minutes.
|
||||||
* Fail either → return "Wrong OTP! Please try again."
|
* Fail either → return "Wrong OTP! Please try again."
|
||||||
* 5. On success:
|
* 5. On success:
|
||||||
* a. session_regenerate_id(true) — prevents session fixation attack by
|
* a. Check session_token in DB — if non-NULL, another session is active;
|
||||||
|
* reject login with "account already logged in" message.
|
||||||
|
* b. session_regenerate_id(true) — prevents session fixation attack by
|
||||||
* issuing a new session ID and deleting the old one.
|
* issuing a new session ID and deleting the old one.
|
||||||
* b. Generate a fresh CSRF token and store in session.
|
* c. Generate a fresh CSRF token and store in session.
|
||||||
* c. Write the authenticated login session keys:
|
* d. Write the authenticated login session keys:
|
||||||
* login_status=1, login_username, login_name, login_surname,
|
* login_status=1, login_username, login_name, login_surname,
|
||||||
* login_company_id (from user's default_company).
|
* login_company_id (from user's default_company).
|
||||||
* 6. Return { success: 1, message: "Login Complete!" }.
|
* 6. Return { success: 1, message: "Login Complete!" }.
|
||||||
@@ -93,15 +95,43 @@ $otp_diff_minutes = $otp_diff_seconds / 60.0;
|
|||||||
$_SESSION["now"] = $now;
|
$_SESSION["now"] = $now;
|
||||||
$_SESSION["diff"] = $otp_diff_minutes;
|
$_SESSION["diff"] = $otp_diff_minutes;
|
||||||
|
|
||||||
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
// ── Step 4: Block login if another session is already active ─────────────────
|
||||||
// Fails if either the code doesn't match OR more than 5 minutes have elapsed
|
// If session_token is non-NULL AND was set within the last 8 hours, another
|
||||||
// since the OTP was issued. The two conditions are intentionally combined in one
|
// session is active — reject. Tokens older than 8 hours are treated as
|
||||||
// error message to avoid leaking whether the code was correct but expired.
|
// abandoned (browser crash, PHP GC expiry, etc.) and cleared automatically.
|
||||||
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
$sth_token = $pdo1->prepare("SELECT session_token, session_token_at FROM user WHERE user_id = :uid LIMIT 1");
|
||||||
|
$sth_token->execute([':uid' => $user_id]);
|
||||||
|
$token_row = $sth_token->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$existing_token = $token_row['session_token'] ?? null;
|
||||||
|
if (!empty($existing_token)) {
|
||||||
|
$token_age_hours = PHP_INT_MAX;
|
||||||
|
if (!empty($token_row['session_token_at'])) {
|
||||||
|
$token_age_hours = (time() - strtotime($token_row['session_token_at'])) / 3600;
|
||||||
|
}
|
||||||
|
if ($token_age_hours < 8) {
|
||||||
|
$answer["message"] = "This account is currently logged in on another device. Please wait for the other session to end.";
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
// Stale token — clear it and proceed with login
|
||||||
|
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid")
|
||||||
|
->execute([':uid' => $user_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4b: 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.
|
||||||
|
if (empty($_SESSION['skip_otp'])) {
|
||||||
|
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||||
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Step 4c: Claim session — write token so no one else can log in ────────────
|
||||||
|
$session_token = bin2hex(random_bytes(32));
|
||||||
|
$pdo1->prepare("UPDATE user SET session_token = :token, session_token_at = NOW() WHERE user_id = :uid")
|
||||||
|
->execute([':token' => $session_token, ':uid' => $user_id]);
|
||||||
|
|
||||||
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
|
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
|
||||||
// session_regenerate_id(true) issues a brand-new session ID and deletes the old
|
// session_regenerate_id(true) issues a brand-new session ID and deletes the old
|
||||||
// session file, preventing session fixation attacks where an attacker pre-sets
|
// session file, preventing session fixation attacks where an attacker pre-sets
|
||||||
@@ -118,6 +148,7 @@ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|||||||
// These keys are read by db_auth.php on every subsequent request to gate access.
|
// These keys are read by db_auth.php on every subsequent request to gate access.
|
||||||
// login_company_id is the user's default_company — used to scope all DB queries.
|
// login_company_id is the user's default_company — used to scope all DB queries.
|
||||||
$_SESSION["login_status"] = 1;
|
$_SESSION["login_status"] = 1;
|
||||||
|
$_SESSION['session_token'] = $session_token;
|
||||||
$_SESSION["login_user_id"] = (int)$temp["user_id"];
|
$_SESSION["login_user_id"] = (int)$temp["user_id"];
|
||||||
$_SESSION["login_username"] = $temp["username"];
|
$_SESSION["login_username"] = $temp["username"];
|
||||||
$_SESSION["login_name"] = $temp["name"];
|
$_SESSION["login_name"] = $temp["name"];
|
||||||
@@ -125,6 +156,9 @@ $_SESSION["login_surname"] = $temp["surname"];
|
|||||||
$_SESSION["login_company_id"] = $temp["default_company"];
|
$_SESSION["login_company_id"] = $temp["default_company"];
|
||||||
$_SESSION["login_profile_picture"] = $temp["profile_picture"] ?? '';
|
$_SESSION["login_profile_picture"] = $temp["profile_picture"] ?? '';
|
||||||
$_SESSION["login_license"] = $temp["license"] ?? 'user';
|
$_SESSION["login_license"] = $temp["license"] ?? 'user';
|
||||||
|
// Required by db_auth.php's per-request OTP integrity check. For skip_otp users
|
||||||
|
// (staff/viewer) this was never written by login_otp.php, so we set it here.
|
||||||
|
$_SESSION["otp"] = $otp;
|
||||||
// license='owner' means the user holds their own subscription — use user.app_access.
|
// license='owner' means the user holds their own subscription — use user.app_access.
|
||||||
// license='user' means they were invited — use company_map_user.app_access instead.
|
// license='user' means they were invited — use company_map_user.app_access instead.
|
||||||
$_SESSION["login_app_access"] = $temp["app_access"] ?? 'wms';
|
$_SESSION["login_app_access"] = $temp["app_access"] ?? 'wms';
|
||||||
|
|||||||
@@ -253,6 +253,31 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
|
||||||
|
// 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') {
|
||||||
|
$sth_role = $pdo1->prepare(
|
||||||
|
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth_role->execute([':cid' => (int)($r['default_company'] ?? 0), ':uid' => (int)$r['user_id']]);
|
||||||
|
$role_for_otp = ($sth_role->fetch(PDO::FETCH_ASSOC))['role'] ?? 'viewer';
|
||||||
|
$requires_otp = in_array($role_for_otp, ['admin', 'owner'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$requires_otp) {
|
||||||
|
$_SESSION = [];
|
||||||
|
$_SESSION['login_data'] = $data;
|
||||||
|
$_SESSION['login_user_id'] = $user_id;
|
||||||
|
$_SESSION['otpTime'] = time();
|
||||||
|
$_SESSION['skip_otp'] = true;
|
||||||
|
$answer['success'] = 1;
|
||||||
|
$answer['skip_otp'] = true;
|
||||||
|
$answer['message'] = 'Login Complete!';
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 6: Generate 6-digit TOTP ────────────────────────────────────────
|
// ── Step 6: Generate 6-digit TOTP ────────────────────────────────────────
|
||||||
// The secret key is the user's current password hash, so the OTP is unique
|
// The secret key is the user's current password hash, so the OTP is unique
|
||||||
// per user and automatically invalidated if the password changes.
|
// per user and automatically invalidated if the password changes.
|
||||||
|
|||||||
@@ -108,6 +108,17 @@
|
|||||||
action: 'read',
|
action: 'read',
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
|
|
||||||
|
if (res.skip_otp) {
|
||||||
|
// Staff/viewer — no OTP required, confirm session directly
|
||||||
|
return ajax_request({
|
||||||
|
url: "<?php echo $server_url?>login/api/engine/login_confirm.php",
|
||||||
|
data: { json: JSON.stringify({ otp: '', action: 'read' }) },
|
||||||
|
onSuccess: function() {
|
||||||
|
window.location.href = "<?php echo $server_url?>index.php";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
window.location.href = "<?php echo $server_url?>index.php";
|
window.location.href = "<?php echo $server_url?>index.php";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ CREATE TABLE IF NOT EXISTS `user` (
|
|||||||
`verify_expires_at` datetime DEFAULT NULL,
|
`verify_expires_at` datetime DEFAULT NULL,
|
||||||
`login_attempts` int(11) NOT NULL DEFAULT 0,
|
`login_attempts` int(11) NOT NULL DEFAULT 0,
|
||||||
`locked_until` datetime DEFAULT NULL,
|
`locked_until` datetime DEFAULT NULL,
|
||||||
|
`session_token` varchar(64) DEFAULT NULL,
|
||||||
|
`session_token_at` datetime DEFAULT NULL,
|
||||||
PRIMARY KEY (`user_id`),
|
PRIMARY KEY (`user_id`),
|
||||||
UNIQUE KEY `username` (`username`),
|
UNIQUE KEY `username` (`username`),
|
||||||
UNIQUE KEY `email` (`email`),
|
UNIQUE KEY `email` (`email`),
|
||||||
@@ -522,9 +524,11 @@ CREATE TABLE IF NOT EXISTS `td_stock` (
|
|||||||
`description` text NOT NULL DEFAULT '',
|
`description` text NOT NULL DEFAULT '',
|
||||||
`status` int(3) NOT NULL DEFAULT 0,
|
`status` int(3) NOT NULL DEFAULT 0,
|
||||||
`log` mediumtext NOT NULL DEFAULT '{}',
|
`log` mediumtext NOT NULL DEFAULT '{}',
|
||||||
|
`updated_at` datetime DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `company_id` (`company_id`),
|
KEY `company_id` (`company_id`),
|
||||||
KEY `product_id` (`product_sku`)
|
KEY `product_id` (`product_sku`),
|
||||||
|
KEY `updated_at` (`updated_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||||
", 'td_stock (base template)');
|
", 'td_stock (base template)');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user