modify classed and comments
This commit is contained in:
@@ -3,11 +3,18 @@
|
||||
/**
|
||||
* ContactManager
|
||||
*
|
||||
* Encapsulates contact and contact type operations:
|
||||
* - Soft-delete with downstream validation
|
||||
* Handles all CRUD and soft-delete operations for contacts and contact types.
|
||||
*
|
||||
* Note: Methods that modify data do NOT manage their own DB transactions.
|
||||
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed.
|
||||
* Method order:
|
||||
* Master file basis → get/save/delete for contact types and contacts
|
||||
* Transaction basis → (none — contacts are master data only)
|
||||
* Report basis → (none — reporting is handled by ReportManager)
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* No user input is ever interpolated directly into a query string.
|
||||
*/
|
||||
class ContactManager {
|
||||
|
||||
@@ -24,8 +31,15 @@ class ContactManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a contact_id is referenced in any active td_stock_* row.
|
||||
* Returns true if blocking stock exists.
|
||||
* Check whether a contact is referenced by any active stock transaction
|
||||
* across all td_stock_* warehouse tables.
|
||||
*
|
||||
* Used as a pre-delete guard to prevent orphaning stock records.
|
||||
* Scans information_schema to discover all td_stock_* tables dynamically.
|
||||
* Table names from information_schema are backtick-quoted for safety.
|
||||
*
|
||||
* @param int $contact_id The md_contact.id to check.
|
||||
* @return bool true if at least one stock row references this contact, false if clear.
|
||||
*/
|
||||
private function hasActiveStock(int $contact_id): bool {
|
||||
|
||||
@@ -38,6 +52,8 @@ class ContactManager {
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
foreach ($tables as $table) {
|
||||
// table name comes from information_schema (trusted system table),
|
||||
// and is backtick-quoted — no user input reaches the table identifier.
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `$table`
|
||||
WHERE company_id = :company_id
|
||||
@@ -56,7 +72,17 @@ class ContactManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a log entry array for audit context.
|
||||
* Build a standard audit log entry array for append-to-JSON log columns.
|
||||
*
|
||||
* Captures the acting user_id, current datetime, session login time,
|
||||
* and a short action label (e.g. 'delete', 'update').
|
||||
*
|
||||
* Usage:
|
||||
* $log[] = $this->buildLogEntry('delete');
|
||||
* $params[':log'] = json_encode($log);
|
||||
*
|
||||
* @param string $action Short label describing the operation (e.g. 'delete').
|
||||
* @return array Associative array ready to be appended to a log array.
|
||||
*/
|
||||
private function buildLogEntry(string $action): array {
|
||||
return [
|
||||
@@ -70,126 +96,15 @@ class ContactManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Contact type
|
||||
// MASTER FILE BASIS — Contact Type
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft-delete a contact type.
|
||||
* Return all contact types for the company.
|
||||
*
|
||||
* Blocks if any md_contact references this type.
|
||||
* Used to populate dropdowns and the contact type listing page.
|
||||
*
|
||||
* @throws Exception if contact type not found or has dependent contacts
|
||||
*/
|
||||
public function deleteContactType(int $type_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, contact_type, `log` FROM md_contact_type
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $type_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Contact type not found.");
|
||||
}
|
||||
|
||||
// Block if any contact references this type
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_contact
|
||||
WHERE company_id = :company_id
|
||||
AND contact_type = :type_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':type_id' => $type_id,
|
||||
]);
|
||||
|
||||
if ($sth->fetchColumn() > 0) {
|
||||
throw new Exception(
|
||||
"Cannot delete — contact type \"{$row['contact_type']}\" " .
|
||||
"still has contacts assigned to it."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_contact_type
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $type_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Contact
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft-delete a contact.
|
||||
*
|
||||
* Blocks if the contact is referenced in any active td_stock_* row.
|
||||
*
|
||||
* @throws Exception if contact not found or referenced in active stock
|
||||
*/
|
||||
public function deleteContact(int $contact_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, contact_name, `log` FROM md_contact
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $contact_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Contact not found.");
|
||||
}
|
||||
|
||||
// Block if contact is referenced in any active stock transaction
|
||||
if ($this->hasActiveStock($contact_id)) {
|
||||
throw new Exception(
|
||||
"Cannot delete — \"{$row['contact_name']}\" " .
|
||||
"is referenced in active stock transactions."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_contact
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $contact_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Contact type queries
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full contact type list.
|
||||
* @return array All rows from md_contact_type for this company.
|
||||
*/
|
||||
public function getContactTypeList(): array
|
||||
{
|
||||
@@ -202,7 +117,12 @@ class ContactManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single contact type row by ID.
|
||||
* Fetch a single contact type row by its primary key.
|
||||
*
|
||||
* Used to pre-fill the edit form on the manage contact type page.
|
||||
*
|
||||
* @param int $id The md_contact_type.id to fetch.
|
||||
* @return array|false Associative row, or false if not found.
|
||||
*/
|
||||
public function getContactTypeById(int $id): array|false
|
||||
{
|
||||
@@ -215,9 +135,16 @@ class ContactManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a contact type.
|
||||
* Pass $data['id'] > 0 for update, 0 for insert.
|
||||
* Insert a new contact type or update an existing one.
|
||||
*
|
||||
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
|
||||
* The $logging array is appended to the row's JSON log column
|
||||
* (caller constructs this from session/request context).
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, contact_type, description, status.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
*/
|
||||
public function saveContactType(array $data, array $logging): void
|
||||
{
|
||||
@@ -259,12 +186,78 @@ class ContactManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a contact type by negating its company_id.
|
||||
*
|
||||
* Blocks deletion if any md_contact row is still assigned to this type,
|
||||
* preventing orphaned contacts.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $type_id The md_contact_type.id to delete.
|
||||
* @throws Exception If the type is not found or has contacts assigned to it.
|
||||
*/
|
||||
public function deleteContactType(int $type_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, contact_type, `log` FROM md_contact_type
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $type_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Contact type not found.");
|
||||
}
|
||||
|
||||
// Block if any contact references this type
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_contact
|
||||
WHERE company_id = :company_id
|
||||
AND contact_type = :type_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':type_id' => $type_id,
|
||||
]);
|
||||
|
||||
if ($sth->fetchColumn() > 0) {
|
||||
throw new Exception(
|
||||
"Cannot delete — contact type \"{$row['contact_type']}\" " .
|
||||
"still has contacts assigned to it."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_contact_type
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $type_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Contact queries
|
||||
// MASTER FILE BASIS — Contact
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full contact list.
|
||||
* Return all contacts for the company.
|
||||
*
|
||||
* Used to populate the contact listing page and bulk dropdowns.
|
||||
*
|
||||
* @return array All rows from md_contact for this company.
|
||||
*/
|
||||
public function getContactList(): array
|
||||
{
|
||||
@@ -277,7 +270,12 @@ class ContactManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single contact row by ID.
|
||||
* Fetch a single contact row by its primary key.
|
||||
*
|
||||
* Used to pre-fill the edit form on the manage contact page.
|
||||
*
|
||||
* @param int $id The md_contact.id to fetch.
|
||||
* @return array|false Associative row, or false if not found.
|
||||
*/
|
||||
public function getContactById(int $id): array|false
|
||||
{
|
||||
@@ -290,7 +288,13 @@ class ContactManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts by name keyword — for autocomplete.
|
||||
* Search contacts by name keyword — for live autocomplete on stock forms.
|
||||
*
|
||||
* Returns up to 50 matches. The keyword is safely bound as a LIKE parameter;
|
||||
* no wildcard escaping is needed here since '%' wrapping is the intended behaviour.
|
||||
*
|
||||
* @param string $keyword Partial contact name to match.
|
||||
* @return array Matching md_contact rows.
|
||||
*/
|
||||
public function searchContact(string $keyword): array
|
||||
{
|
||||
@@ -307,11 +311,21 @@ class ContactManager {
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Insert or update a contact.
|
||||
* Pass $data['id'] > 0 for update, 0 for insert.
|
||||
* Insert a new contact or update an existing one.
|
||||
*
|
||||
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
|
||||
* $contact_image is the resolved filename/path from FileUploader — may be
|
||||
* the existing image when no new file was uploaded.
|
||||
* The $logging array is appended to the row's JSON log column.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, contact_name, tax_id, organization, branch,
|
||||
* contact_type, billing_address, shipping_location,
|
||||
* shipping_address, remark, status.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @param string $contact_image Stored filename for the contact's profile image.
|
||||
*/
|
||||
public function saveContact(array $data, array $logging, string $contact_image): void
|
||||
{
|
||||
@@ -370,4 +384,57 @@ class ContactManager {
|
||||
)->execute($params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a contact by negating its company_id.
|
||||
*
|
||||
* Blocks deletion if the contact is referenced in any active stock
|
||||
* transaction across all td_stock_* warehouse tables, preventing
|
||||
* broken foreign key references in transaction history.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $contact_id The md_contact.id to delete.
|
||||
* @throws Exception If the contact is not found or has active stock references.
|
||||
*/
|
||||
public function deleteContact(int $contact_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, contact_name, `log` FROM md_contact
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $contact_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Contact not found.");
|
||||
}
|
||||
|
||||
// Block if contact is referenced in any active stock transaction
|
||||
if ($this->hasActiveStock($contact_id)) {
|
||||
throw new Exception(
|
||||
"Cannot delete — \"{$row['contact_name']}\" " .
|
||||
"is referenced in active stock transactions."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_contact
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $contact_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,84 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* FileUploader
|
||||
*
|
||||
* Handles secure file upload, validation, cleanup, and rollback for
|
||||
* the WMS application. Designed to be used with any form that accepts
|
||||
* image or PDF attachments (product images, contact photos, etc.).
|
||||
*
|
||||
* Typical usage flow:
|
||||
* 1. Instantiate with the absolute server path to the upload directory.
|
||||
* 2. Call cleanup() to delete files the user removed in the UI.
|
||||
* 3. Call upload() to validate and move new files from $_FILES.
|
||||
* 4. If DB write succeeds, call buildFileString() to persist the final CSV.
|
||||
* 5. If DB write fails, call rollbackUploads() to delete the newly uploaded files.
|
||||
*
|
||||
* Example:
|
||||
* $uploader = new FileUploader('/var/www/app/uploads/products/');
|
||||
* $uploader->cleanup($existing_files_csv, $kept_files_csv);
|
||||
* $errors = $uploader->upload('product_image');
|
||||
* if (empty($errors)) {
|
||||
* $file_string = $uploader->buildFileString($kept_files_csv);
|
||||
* // save $file_string to DB...
|
||||
* } else {
|
||||
* $uploader->rollbackUploads();
|
||||
* }
|
||||
*
|
||||
* Security:
|
||||
* - Validates both file extension and MIME type via finfo (double-check).
|
||||
* - Sanitises original filename before use; replaces non-alphanumeric characters with '_'.
|
||||
* - Generates a unique filename with uniqid() to prevent overwrite attacks.
|
||||
* - Sets uploaded files to 0644 permissions (web-readable, not executable).
|
||||
* - Rejects any file exceeding the 5MB size limit.
|
||||
*/
|
||||
class FileUploader {
|
||||
|
||||
private $target_dir;
|
||||
private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'];
|
||||
private $max_size = 5 * 1024 * 1024; // 5MB
|
||||
private $uploaded_files = []; // newly uploaded filenames
|
||||
private $deleted_files = []; // files we deleted from disk
|
||||
private $uploaded_files = []; // filenames of files successfully uploaded in this request
|
||||
private $deleted_files = []; // filenames of files deleted from disk in this request
|
||||
|
||||
/**
|
||||
* Initialise the uploader for a specific directory.
|
||||
*
|
||||
* Creates the directory if it does not exist (recursive, 0755).
|
||||
* Throws if the directory cannot be created or is not writable.
|
||||
*
|
||||
* @param string $target_dir Absolute path to the upload directory (trailing slash optional).
|
||||
* @throws Exception If the directory cannot be created or is not writable.
|
||||
*/
|
||||
public function __construct($target_dir) {
|
||||
$this->target_dir = rtrim($target_dir, '/') . '/';
|
||||
$this->ensureDirectory();
|
||||
}
|
||||
|
||||
private function ensureDirectory() {
|
||||
if (!is_dir($this->target_dir)) {
|
||||
if (!mkdir($this->target_dir, 0755, true)) {
|
||||
throw new Exception("Failed to create directory: " . $this->target_dir);
|
||||
}
|
||||
}
|
||||
if (!is_writable($this->target_dir)) {
|
||||
throw new Exception("Target directory is not writable: " . $this->target_dir);
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public — cleanup
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove files that user deleted in the UI
|
||||
* Delete files that the user removed in the UI.
|
||||
*
|
||||
* Compare the original comma-separated file list ($existing_csv) against
|
||||
* the files the user chose to keep ($keep_csv). Any file in $existing but
|
||||
* not in $keep is deleted from disk.
|
||||
*
|
||||
* Call this BEFORE upload() so that freed space is available and the
|
||||
* $deleted_files list is populated before any rollback is needed.
|
||||
*
|
||||
* @param string $existing_csv Comma-separated filenames previously stored in DB.
|
||||
* @param string $keep_csv Comma-separated filenames the user kept in the form.
|
||||
*/
|
||||
public function cleanup($existing_csv, $keep_csv) {
|
||||
if (empty($existing_csv)) return;
|
||||
|
||||
$existing = array_filter(array_map('trim', explode(',', $existing_csv)));
|
||||
$keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : [];
|
||||
$keep = !empty($keep_csv)
|
||||
? array_filter(array_map('trim', explode(',', $keep_csv)))
|
||||
: [];
|
||||
|
||||
foreach ($existing as $file) {
|
||||
if (!in_array($file, $keep)) {
|
||||
@@ -45,16 +91,32 @@ class FileUploader {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public — upload
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload new files from $_FILES
|
||||
* Returns array of errors (empty = success)
|
||||
* Validate and move uploaded files from $_FILES to the target directory.
|
||||
*
|
||||
* Handles both single-file and multi-file inputs transparently.
|
||||
* Each file is validated for:
|
||||
* - PHP upload error code (UPLOAD_ERR_OK)
|
||||
* - File size (≤ max_size, default 5MB)
|
||||
* - File extension (whitelist)
|
||||
* - MIME type via finfo (double-check against extension spoofing)
|
||||
*
|
||||
* Successfully uploaded files are tracked in $this->uploaded_files and
|
||||
* can be rolled back via rollbackUploads() if the DB write later fails.
|
||||
*
|
||||
* @param string $field_name The $_FILES key (HTML input name attribute).
|
||||
* @return array Array of human-readable error strings. Empty array = all files accepted.
|
||||
*/
|
||||
public function upload($field_name) {
|
||||
if (!isset($_FILES[$field_name])) return [];
|
||||
|
||||
$errors = [];
|
||||
|
||||
// Normalize single file to array structure
|
||||
// Normalise single-file $_FILES entry to array structure
|
||||
$files = $_FILES[$field_name];
|
||||
if (!is_array($files['name'])) {
|
||||
$files['name'] = [$files['name']];
|
||||
@@ -75,16 +137,19 @@ class FileUploader {
|
||||
$file_size = $files['size'][$key];
|
||||
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
|
||||
// Size check
|
||||
if ($file_size > $this->max_size) {
|
||||
$errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extension whitelist check
|
||||
if (!in_array($extension, $this->allowed_extensions)) {
|
||||
$errors[] = "{$name}: .{$extension} is not allowed";
|
||||
continue;
|
||||
}
|
||||
|
||||
// MIME type check via finfo (cannot be spoofed by renaming)
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $tmp_name);
|
||||
finfo_close($finfo);
|
||||
@@ -94,16 +159,15 @@ class FileUploader {
|
||||
continue;
|
||||
}
|
||||
|
||||
// FILE NAME SANITIZATION + UNIQUE ID
|
||||
// Sanitise original filename and generate a unique destination name
|
||||
$original = pathinfo($name, PATHINFO_FILENAME);
|
||||
$original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original); // sanitize
|
||||
$file_id = $original . "_" . uniqid() . "." . $extension;
|
||||
|
||||
$original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original);
|
||||
$file_id = $original . '_' . uniqid() . '.' . $extension;
|
||||
$destination = $this->target_dir . $file_id;
|
||||
|
||||
if (move_uploaded_file($tmp_name, $destination)) {
|
||||
$this->uploaded_files[] = $file_id;
|
||||
chmod($destination, 0644);
|
||||
chmod($destination, 0644); // web-readable, not executable
|
||||
} else {
|
||||
$errors[] = "{$name}: failed to save";
|
||||
}
|
||||
@@ -112,24 +176,46 @@ class FileUploader {
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public — result helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build final CSV string for DB
|
||||
* Build the final comma-separated filename string for DB storage.
|
||||
*
|
||||
* Merges the files the user kept ($keep_csv) with any newly uploaded
|
||||
* files from this request, and returns them as a single CSV string.
|
||||
* Pass this return value to the DB column that stores file references.
|
||||
*
|
||||
* @param string $keep_csv Comma-separated filenames the user kept in the form.
|
||||
* @return string Final comma-separated file list ready for DB storage.
|
||||
*/
|
||||
public function buildFileString($keep_csv) {
|
||||
$keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : [];
|
||||
$keep = !empty($keep_csv)
|
||||
? array_filter(array_map('trim', explode(',', $keep_csv)))
|
||||
: [];
|
||||
$final = array_merge($keep, $this->uploaded_files);
|
||||
return implode(",", array_filter($final));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build final CSV string for DB as single file
|
||||
* Return the list of filenames successfully uploaded in this request.
|
||||
*
|
||||
* Useful when only a single image field is expected and you need to
|
||||
* retrieve the stored filename directly without building a CSV string.
|
||||
*
|
||||
* @return array Flat array of uploaded filenames (may be empty).
|
||||
*/
|
||||
public function getUploadedFiles() {
|
||||
return $this->uploaded_files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback uploaded files if DB fails
|
||||
* Delete all files uploaded in this request — call if the DB write fails.
|
||||
*
|
||||
* Ensures no orphaned files remain on disk when a transaction is rolled back.
|
||||
* Safe to call multiple times (idempotent if files are already gone).
|
||||
* After rollback, $this->uploaded_files is cleared.
|
||||
*/
|
||||
public function rollbackUploads() {
|
||||
foreach ($this->uploaded_files as $file) {
|
||||
@@ -141,6 +227,35 @@ class FileUploader {
|
||||
$this->uploaded_files = [];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the target directory if it does not exist, and verify it is writable.
|
||||
*
|
||||
* @throws Exception If mkdir fails or the directory is not writable.
|
||||
*/
|
||||
private function ensureDirectory() {
|
||||
if (!is_dir($this->target_dir)) {
|
||||
if (!mkdir($this->target_dir, 0755, true)) {
|
||||
throw new Exception("Failed to create directory: " . $this->target_dir);
|
||||
}
|
||||
}
|
||||
if (!is_writable($this->target_dir)) {
|
||||
throw new Exception("Target directory is not writable: " . $this->target_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PHP upload error code into a human-readable message.
|
||||
*
|
||||
* Covers all UPLOAD_ERR_* constants defined by PHP.
|
||||
* Falls back to a generic message for unknown codes.
|
||||
*
|
||||
* @param int $code A UPLOAD_ERR_* constant value.
|
||||
* @return string Human-readable error description.
|
||||
*/
|
||||
private function getUploadError($code) {
|
||||
$messages = [
|
||||
UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")",
|
||||
@@ -154,9 +269,3 @@ class FileUploader {
|
||||
return $messages[$code] ?? "unknown error (code {$code})";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
?>
|
||||
@@ -3,47 +3,77 @@
|
||||
/**
|
||||
* PasswordManager
|
||||
*
|
||||
* Handles all password operations using zxcvbn-php for strength enforcement.
|
||||
* Designed to be reused across:
|
||||
* - Profile: change password (requires current password verification)
|
||||
* - Login: forced password reset (no current password needed)
|
||||
* - Future: forgot password / admin reset flows
|
||||
* Handles all password-related operations using zxcvbn-php for strength enforcement.
|
||||
* Designed to be reused across multiple flows:
|
||||
* - Profile page: authenticated user changes their own password (requires current password).
|
||||
* - Login forced reset: system-initiated change, no current password needed.
|
||||
* - Future: admin reset, forgot password (delegate to PasswordResetManager for OTP).
|
||||
*
|
||||
* Method order:
|
||||
* Core public API → checkStrength, change, forceSet
|
||||
* HTTP handlers → handleCheck, handleChange (thin wrappers for AJAX endpoints)
|
||||
* Private helpers → loadZxcvbn, fetchUser, enforceStrength, persist
|
||||
*
|
||||
* Usage:
|
||||
* $pm = new PasswordManager($pdo1, $include_url);
|
||||
* $pm = new PasswordManager($pdo, $include_url);
|
||||
*
|
||||
* // Check strength only (for live UI feedback)
|
||||
* // Live strength check (AJAX feedback while typing):
|
||||
* $result = $pm->checkStrength('mypassword', ['john', 'john@example.com']);
|
||||
*
|
||||
* // Change password (profile page — verifies current password)
|
||||
* // Authenticated user changing their own password:
|
||||
* $pm->change($user_id, $current_password, $new_password, $confirm_password);
|
||||
*
|
||||
* // Force set password (admin reset / login forced reset — no current password)
|
||||
* // Forced set (admin reset / login forced reset — skips current password check):
|
||||
* $pm->forceSet($user_id, $new_password, $confirm_password);
|
||||
*
|
||||
* Security:
|
||||
* - Passwords are hashed with PASSWORD_BCRYPT (cost factor PHP default = 10).
|
||||
* - zxcvbn score ≥ 3 ("safely unguessable") is required before any hash is written.
|
||||
* - User's own name, username, and email are passed to zxcvbn as penalty inputs.
|
||||
* - All DB queries use PDO prepared statements with bound parameters.
|
||||
* - AJAX handler methods output JSON via json_encode (XSS-safe for string values).
|
||||
*/
|
||||
class PasswordManager {
|
||||
|
||||
private $pdo;
|
||||
private $zxcvbn_path;
|
||||
|
||||
/** Minimum zxcvbn score required (0–4). 3 = "safely unguessable" */
|
||||
/** Minimum zxcvbn score required (0–4). 3 = "safely unguessable". */
|
||||
const MIN_SCORE = 3;
|
||||
|
||||
/**
|
||||
* @param PDO $pdo PDO connection to the wms.user table database.
|
||||
* @param string $include_url Absolute server path to the app root directory,
|
||||
* used to locate the zxcvbn-php autoloader.
|
||||
* Example: '/var/www/html/wms'
|
||||
*/
|
||||
public function __construct($pdo, string $include_url) {
|
||||
$this->pdo = $pdo;
|
||||
$this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: strength check (used by live AJAX feedback endpoint)
|
||||
// Core public API
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run zxcvbn strength analysis.
|
||||
* Run a zxcvbn password strength analysis and return structured feedback.
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $user_inputs Personal data to penalise (name, email, username…)
|
||||
* @return array ['score' => 0-4, 'warning' => string, 'suggestions' => array]
|
||||
* Used by the live AJAX strength indicator while the user types.
|
||||
* Pass user-specific strings as $user_inputs so zxcvbn penalises
|
||||
* passwords that contain the user's own name, email, or username.
|
||||
*
|
||||
* Score meanings:
|
||||
* 0 — too guessable (online attack in < 100 guesses)
|
||||
* 1 — very guessable (online attack in < 1000 guesses)
|
||||
* 2 — somewhat guessable (offline attack in < 1M guesses)
|
||||
* 3 — safely unguessable (offline attack in < 100M guesses) ← MIN_SCORE
|
||||
* 4 — very unguessable (offline attack in > 100M guesses)
|
||||
*
|
||||
* @param string $password The password string to analyse.
|
||||
* @param array $user_inputs Strings to penalise if found in the password
|
||||
* (e.g. name, email, username).
|
||||
* @return array Keys: score (int 0–4), warning (string), suggestions (array of strings).
|
||||
*/
|
||||
public function checkStrength(string $password, array $user_inputs = []): array {
|
||||
|
||||
@@ -59,20 +89,28 @@ class PasswordManager {
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: change password (profile — verifies current password)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Change password for an authenticated user.
|
||||
* Verifies current password before applying the new one.
|
||||
* Change the password for an authenticated user.
|
||||
*
|
||||
* @throws InvalidArgumentException on validation failure (safe to show user)
|
||||
* @throws RuntimeException on DB failure (log internally, show generic message)
|
||||
* Verifies the current password before applying the new one.
|
||||
* Use this on the profile settings page where the user is already logged in
|
||||
* and must prove knowledge of their existing password.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Validates all fields are present and new passwords match.
|
||||
* 2. Fetches the user record and verifies $current_password via password_verify().
|
||||
* 3. Runs zxcvbn strength enforcement (throws if score < MIN_SCORE).
|
||||
* 4. Hashes and persists the new password.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id of the user changing their password.
|
||||
* @param string $current_password The user's current password for verification.
|
||||
* @param string $new_password The desired new password.
|
||||
* @param string $confirm_password Must match $new_password exactly.
|
||||
* @throws \InvalidArgumentException On validation failure (message is safe to show the user).
|
||||
* @throws \RuntimeException On DB failure (log internally, show generic message).
|
||||
*/
|
||||
public function change(int $user_id, string $current_password, string $new_password, string $confirm_password): void {
|
||||
|
||||
// ── Basic field validation ────────────────────────────────
|
||||
if (empty($current_password) || empty($new_password) || empty($confirm_password)) {
|
||||
throw new \InvalidArgumentException('All password fields are required.');
|
||||
}
|
||||
@@ -81,31 +119,35 @@ class PasswordManager {
|
||||
throw new \InvalidArgumentException('New passwords do not match.');
|
||||
}
|
||||
|
||||
// ── Load user record ──────────────────────────────────────
|
||||
$user = $this->fetchUser($user_id);
|
||||
|
||||
// ── Verify current password ───────────────────────────────
|
||||
if (!password_verify($current_password, $user['password'])) {
|
||||
throw new \InvalidArgumentException('Current password is incorrect.');
|
||||
}
|
||||
|
||||
// ── Strength check ────────────────────────────────────────
|
||||
$this->enforceStrength($new_password, $user);
|
||||
|
||||
// ── Hash and persist ──────────────────────────────────────
|
||||
$this->persist($user_id, $new_password);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: force set password (admin reset / login forced reset)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force-set a new password without requiring the current password.
|
||||
* Use for: admin-initiated reset, forgot-password flow, first-login forced change.
|
||||
* Force-set a new password without verifying the current one.
|
||||
*
|
||||
* @throws InvalidArgumentException on validation failure
|
||||
* @throws RuntimeException on DB failure
|
||||
* Use for flows where the current password is unavailable or irrelevant:
|
||||
* - Admin-initiated password reset
|
||||
* - First-login forced password change
|
||||
* - Forgot-password flow after OTP verification (via PasswordResetManager)
|
||||
*
|
||||
* Steps:
|
||||
* 1. Validates new password fields are present and match.
|
||||
* 2. Fetches the user record (needed for zxcvbn personalisation).
|
||||
* 3. Runs zxcvbn strength enforcement.
|
||||
* 4. Hashes and persists the new password.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id to reset.
|
||||
* @param string $new_password The desired new password.
|
||||
* @param string $confirm_password Must match $new_password exactly.
|
||||
* @throws \InvalidArgumentException On validation failure.
|
||||
* @throws \RuntimeException On DB failure.
|
||||
*/
|
||||
public function forceSet(int $user_id, string $new_password, string $confirm_password): void {
|
||||
|
||||
@@ -124,14 +166,23 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: HTTP handlers (call from thin API endpoint files)
|
||||
// HTTP handlers (thin AJAX endpoint wrappers)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle AJAX strength-check request and echo JSON response.
|
||||
* Endpoint: setting/api/engine/check_password.php
|
||||
* Handle an AJAX strength-check request and echo a JSON response.
|
||||
*
|
||||
* Expected $data keys: password
|
||||
* Called by: setting/api/engine/check_password.php
|
||||
*
|
||||
* Expected $data keys:
|
||||
* password (string) — the password string to analyse
|
||||
*
|
||||
* Response JSON keys:
|
||||
* success (int 1), score (int -1 if empty, else 0–4), feedback (string)
|
||||
*
|
||||
* Outputs JSON and calls exit. Safe against XSS — all output via json_encode.
|
||||
*
|
||||
* @param array $data Request data array (typically from $_POST or decoded JSON body).
|
||||
*/
|
||||
public function handleCheck(array $data): void {
|
||||
|
||||
@@ -154,10 +205,22 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX change-password request and echo JSON response.
|
||||
* Endpoint: setting/api/engine/change_password.php
|
||||
* Handle an AJAX change-password request and echo a JSON response.
|
||||
*
|
||||
* Expected $data keys: current_password, new_password, confirm_password
|
||||
* Called by: setting/api/engine/change_password.php
|
||||
*
|
||||
* Expected $data keys:
|
||||
* current_password, new_password, confirm_password (all strings)
|
||||
*
|
||||
* On success: destroys the session (forces re-login with new password),
|
||||
* returns { success: 1, message: "..." }
|
||||
* On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." }
|
||||
* On other Exception (system error): HTTP 500, generic message (error logged server-side)
|
||||
*
|
||||
* Outputs JSON and calls exit. Safe against XSS — all output via json_encode.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id of the currently authenticated user.
|
||||
* @param array $data Request data array containing the password fields.
|
||||
*/
|
||||
public function handleChange(int $user_id, array $data): void {
|
||||
|
||||
@@ -170,6 +233,9 @@ class PasswordManager {
|
||||
$data['confirm_password'] ?? ''
|
||||
);
|
||||
|
||||
// Destroy session: user must re-authenticate with new password.
|
||||
// The db_auth OTP would invalidate naturally on next request
|
||||
// (hash changed), but explicit destroy is immediate.
|
||||
session_destroy();
|
||||
echo json_encode(['success' => 1, 'message' => 'Password changed successfully.']);
|
||||
|
||||
@@ -190,6 +256,15 @@ class PasswordManager {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load the zxcvbn-php autoloader.
|
||||
*
|
||||
* Called lazily (only when a strength check is actually needed).
|
||||
* Throws a RuntimeException if the autoloader file is missing,
|
||||
* so misconfiguration is caught clearly rather than as a silent failure.
|
||||
*
|
||||
* @throws \RuntimeException If the autoloader file is not found.
|
||||
*/
|
||||
private function loadZxcvbn(): void {
|
||||
if (!file_exists($this->zxcvbn_path)) {
|
||||
throw new \RuntimeException('zxcvbn autoloader not found at: ' . $this->zxcvbn_path);
|
||||
@@ -197,6 +272,16 @@ class PasswordManager {
|
||||
require_once $this->zxcvbn_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user record by user_id from the wms.user table.
|
||||
*
|
||||
* Returns the full user row including the hashed password (needed for
|
||||
* password_verify) and personal fields (needed for zxcvbn penalisation).
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id to fetch.
|
||||
* @return array Associative user row: user_id, username, name, surname, email, password.
|
||||
* @throws \RuntimeException If no user is found for the given ID.
|
||||
*/
|
||||
private function fetchUser(int $user_id): array {
|
||||
$sth = $this->pdo->prepare(
|
||||
'SELECT user_id, username, name, surname, email, password
|
||||
@@ -213,8 +298,16 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run zxcvbn and throw if score is below MIN_SCORE.
|
||||
* Passes personal fields so zxcvbn penalises name/email use.
|
||||
* Run zxcvbn strength check and throw if the score is below MIN_SCORE.
|
||||
*
|
||||
* Passes user personal fields (name, surname, username, email) to zxcvbn
|
||||
* so that passwords containing the user's own details are penalised.
|
||||
* The first actionable feedback string from zxcvbn is included in the
|
||||
* exception message shown to the user.
|
||||
*
|
||||
* @param string $password The new password to evaluate.
|
||||
* @param array $user User row from fetchUser() — provides personalisation data.
|
||||
* @throws \InvalidArgumentException If score < MIN_SCORE, with zxcvbn feedback.
|
||||
*/
|
||||
private function enforceStrength(string $password, array $user): void {
|
||||
|
||||
@@ -234,10 +327,16 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash and write the new password to the DB.
|
||||
* The OTP session will invalidate automatically on the next
|
||||
* request because db_auth.php re-derives the OTP from the
|
||||
* stored password hash — changing it forces re-login.
|
||||
* Hash the password with bcrypt and write it to the wms.user table.
|
||||
*
|
||||
* Uses PASSWORD_BCRYPT with PHP's default cost factor.
|
||||
* After this write, the OTP-based auth in db_auth.php will invalidate
|
||||
* on the next request because it re-derives the OTP from the stored hash —
|
||||
* changing the hash implicitly forces a fresh login.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id to update.
|
||||
* @param string $password The plain-text new password (will be hashed here).
|
||||
* @throws \RuntimeException If the UPDATE affected 0 rows (user not found or no change).
|
||||
*/
|
||||
private function persist(int $user_id, string $password): void {
|
||||
$hashed = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
/**
|
||||
* PasswordResetManager
|
||||
*
|
||||
* Handles the full OTP-based password reset flow.
|
||||
* Handles the full OTP-based password reset flow for the WMS application.
|
||||
* Reusable across:
|
||||
* - Profile page: "Forgot your current password?" (authenticated user)
|
||||
* - Login page: "Forgot password?" (unauthenticated user)
|
||||
* - Login page: "Forgot password?" (unauthenticated user — user_id resolved by caller)
|
||||
*
|
||||
* Flow:
|
||||
* 1. requestOtp($user_id) — generate OTP, email it, store in session
|
||||
* 2. confirmReset($user_id, ...) — verify OTP, call PasswordManager::forceSet()
|
||||
* Reset flow:
|
||||
* 1. requestOtp($user_id, $company_id) — generate TOTP, email it, store in session.
|
||||
* 2. confirmReset($user_id, $otp, ...) — verify OTP, call PasswordManager::forceSet().
|
||||
*
|
||||
* HTTP handler methods for thin endpoint wrappers:
|
||||
* handleRequestOtp($user_id)
|
||||
* The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
|
||||
* scoped to a 3-minute time step. It cannot be replayed after the window expires.
|
||||
* A human-readable reference number (6 uppercase letters) is also generated and emailed
|
||||
* so the user can confirm they received the correct OTP request.
|
||||
*
|
||||
* HTTP handler methods for thin AJAX endpoint wrappers:
|
||||
* handleRequestOtp($user_id, $company_id)
|
||||
* handleConfirmReset($user_id, $data)
|
||||
*
|
||||
* Session keys used (prefixed to avoid collision with login OTP):
|
||||
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
|
||||
* reset_otp, reset_otp_time, reset_reference, reset_user_id
|
||||
*
|
||||
* Security:
|
||||
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
|
||||
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
|
||||
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
|
||||
* - Session is fully destroyed on successful reset, forcing re-authentication.
|
||||
* - All DB queries use PDO prepared statements with bound parameters.
|
||||
* - AJAX handler methods output JSON via json_encode (XSS-safe).
|
||||
*/
|
||||
class PasswordResetManager {
|
||||
|
||||
@@ -27,15 +40,17 @@ class PasswordResetManager {
|
||||
private $SMTP;
|
||||
private $pinkey;
|
||||
|
||||
/** OTP validity window in minutes — matches login OTP */
|
||||
/** OTP validity window in minutes — matches the login OTP window. */
|
||||
const OTP_EXPIRY_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* @param PDO $pdo1 Main DB (user table)
|
||||
* @param PDO $pdo2 Company DB (smtp_setting table)
|
||||
* @param string $include_url Absolute server path to app root (for requires)
|
||||
* @param array $SMTP System default SMTP config from config.php
|
||||
* @param string $pinkey Encryption key from config.php
|
||||
* @param PDO $pdo1 PDO connection to the wms database (user table).
|
||||
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
|
||||
* @param string $include_url Absolute server path to the app root (for require_once paths).
|
||||
* Example: '/var/www/html/wms'
|
||||
* @param array $SMTP System default SMTP configuration array from config.php.
|
||||
* Used as fallback when the company has no custom SMTP.
|
||||
* @param string $pinkey Encryption key from config.php, passed to the mailer.
|
||||
*/
|
||||
public function __construct($pdo1, $pdo2, string $include_url, array $SMTP, string $pinkey) {
|
||||
$this->pdo1 = $pdo1;
|
||||
@@ -46,22 +61,29 @@ class PasswordResetManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: request OTP
|
||||
// Core public API
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate OTP, send it to the user's registered email, store in session.
|
||||
* Generate a TOTP, send it to the user's registered email, and store it in session.
|
||||
*
|
||||
* @param int $user_id From session (profile) or looked-up by email/username (login)
|
||||
* @param int $company_id For SMTP fallback lookup
|
||||
* @return array ['masked_email' => string, 'reference' => string]
|
||||
* The OTP is derived from the user's current password hash so it is unique per user
|
||||
* and automatically invalidated if the password is changed by any other means.
|
||||
* A reference number (6 uppercase letters) is included in the email so the user
|
||||
* can verify the request is legitimate.
|
||||
*
|
||||
* @throws \RuntimeException if user not found or email missing
|
||||
* @throws \Exception if mailer fails (mailer calls exit internally)
|
||||
* Call this from the "request OTP" endpoint. The user_id must be resolved by the
|
||||
* caller (from $_SESSION for authenticated flows, or from a username/email lookup
|
||||
* for unauthenticated login-page flows).
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id whose password is being reset.
|
||||
* @param int $company_id Used to look up company SMTP settings (0 = use system default).
|
||||
* @return array Keys: masked_email (string), reference (string 6-letter code).
|
||||
* @throws \RuntimeException If user not found, email missing, or mailer fails.
|
||||
*/
|
||||
public function requestOtp(int $user_id, int $company_id = 0): array {
|
||||
|
||||
// ── Fetch user ────────────────────────────────────────────
|
||||
// Fetch user — need email (to send to) and password hash (for OTP derivation)
|
||||
$sth = $this->pdo1->prepare(
|
||||
'SELECT user_id, email, password FROM user WHERE user_id = :id LIMIT 1'
|
||||
);
|
||||
@@ -72,12 +94,12 @@ class PasswordResetManager {
|
||||
throw new \RuntimeException('No email address found for this account.');
|
||||
}
|
||||
|
||||
// ── Generate OTP ──────────────────────────────────────────
|
||||
// Generate 6-digit TOTP and a human-readable 6-letter reference number
|
||||
$otp_time = time();
|
||||
$otp = $this->generateOTP($user['password'], $otp_time);
|
||||
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
|
||||
|
||||
// ── Send email ────────────────────────────────────────────
|
||||
// Send via the mailer module (uses company SMTP or falls back to system default)
|
||||
require_once $this->include_url . '/assets/utils/module/mailer.php';
|
||||
|
||||
$mailer = new mailer(['pdo1' => $this->pdo1, 'pdo2' => $this->pdo2]);
|
||||
@@ -97,81 +119,94 @@ class PasswordResetManager {
|
||||
'key' => $this->pinkey,
|
||||
]);
|
||||
|
||||
// ── Store in session ──────────────────────────────────────
|
||||
// Persist in session so confirmReset() can verify against it
|
||||
$_SESSION['reset_otp'] = $otp;
|
||||
$_SESSION['reset_otp_time'] = $otp_time;
|
||||
$_SESSION['reset_reference'] = $reference_number;
|
||||
$_SESSION['reset_user_id'] = $user_id;
|
||||
|
||||
// ── Return masked email for UI display ────────────────────
|
||||
return [
|
||||
'masked_email' => $this->maskEmail($user['email']),
|
||||
'reference' => $reference_number,
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: confirm reset
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify OTP then force-set new password via PasswordManager.
|
||||
* Verify the OTP and force-set a new password via PasswordManager.
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param string $otp_input
|
||||
* @param string $new_password
|
||||
* @param string $confirm_password
|
||||
* Validates:
|
||||
* - Active session with a stored OTP and timestamp.
|
||||
* - Session reset_user_id matches the $user_id being reset (prevents cross-user reuse).
|
||||
* - OTP is within the OTP_EXPIRY_MINUTES window.
|
||||
* - Submitted OTP matches the stored value exactly.
|
||||
*
|
||||
* @throws \InvalidArgumentException OTP invalid/expired, passwords weak/mismatch
|
||||
* @throws \RuntimeException DB or session state error
|
||||
* On success:
|
||||
* - Delegates to PasswordManager::forceSet() for strength enforcement and hashing.
|
||||
* - Clears reset session keys and destroys the full session (forces re-login).
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id being reset.
|
||||
* @param string $otp_input The OTP submitted by the user.
|
||||
* @param string $new_password The desired new password.
|
||||
* @param string $confirm_password Must match $new_password exactly.
|
||||
* @throws \InvalidArgumentException OTP missing, expired, incorrect, or passwords invalid.
|
||||
* @throws \RuntimeException Session integrity error or DB failure.
|
||||
*/
|
||||
public function confirmReset(int $user_id, string $otp_input, string $new_password, string $confirm_password): void {
|
||||
|
||||
// ── Validate session state ────────────────────────────────
|
||||
// Validate reset session exists
|
||||
if (empty($_SESSION['reset_otp']) || empty($_SESSION['reset_otp_time'])) {
|
||||
throw new \InvalidArgumentException('No active reset request. Please request a new OTP.');
|
||||
}
|
||||
|
||||
// ── Verify ownership ──────────────────────────────────────
|
||||
// Verify this OTP belongs to the user making the request (prevents session swap attacks)
|
||||
if ((int)$_SESSION['reset_user_id'] !== $user_id) {
|
||||
throw new \RuntimeException('Invalid reset request.');
|
||||
}
|
||||
|
||||
// ── Check expiry ──────────────────────────────────────────
|
||||
// Check OTP has not expired
|
||||
$elapsed_minutes = (time() - (int)$_SESSION['reset_otp_time']) / 60;
|
||||
if ($elapsed_minutes > self::OTP_EXPIRY_MINUTES) {
|
||||
$this->clearSession();
|
||||
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
|
||||
}
|
||||
|
||||
// ── Verify OTP value ──────────────────────────────────────
|
||||
// Verify OTP value
|
||||
if (trim($otp_input) !== $_SESSION['reset_otp']) {
|
||||
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
|
||||
}
|
||||
|
||||
// ── Force-set password via PasswordManager ────────────────
|
||||
// Delegate to PasswordManager for strength enforcement and persistence
|
||||
require_once $this->include_url . '/assets/utils/classes/PasswordManager.php';
|
||||
|
||||
$pm = new PasswordManager($this->pdo1, $this->include_url);
|
||||
$pm->forceSet($user_id, $new_password, $confirm_password);
|
||||
|
||||
// ── Clear full session on success ────────────────────────
|
||||
// Destroys the login session so the user must re-authenticate
|
||||
// with their new password. The OTP in db_auth would invalidate
|
||||
// naturally on next request anyway (hash changed), but clearing
|
||||
// here is immediate and explicit.
|
||||
// Clear reset keys and destroy session — user must log in with new password.
|
||||
// The OTP in db_auth would invalidate naturally (hash changed), but
|
||||
// explicit destroy is immediate and leaves no dangling session state.
|
||||
$this->clearSession();
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: HTTP handlers (thin endpoint wrappers call these)
|
||||
// HTTP handlers (thin AJAX endpoint wrappers)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle AJAX request-OTP call and echo JSON.
|
||||
* Endpoint: setting/api/engine/request_reset_otp.php
|
||||
* login/api/engine/request_reset_otp.php
|
||||
* Handle an AJAX request-OTP call and echo a JSON response.
|
||||
*
|
||||
* Called by:
|
||||
* setting/api/engine/request_reset_otp.php (profile page)
|
||||
* login/api/engine/request_reset_otp.php (login page)
|
||||
*
|
||||
* On success: { success: 1, masked_email: "...", reference: "ABCDEF" }
|
||||
* On RuntimeException (user/mail error): HTTP 500, { success: 0, message: "..." }
|
||||
* On other Exception: HTTP 500, generic message (error logged server-side)
|
||||
*
|
||||
* Outputs JSON and calls exit. XSS-safe — all output via json_encode.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id to send the OTP to.
|
||||
* @param int $company_id Company SMTP scope (0 = use system default).
|
||||
*/
|
||||
public function handleRequestOtp(int $user_id, int $company_id = 0): void {
|
||||
|
||||
@@ -200,11 +235,23 @@ class PasswordResetManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX confirm-reset call and echo JSON.
|
||||
* Endpoint: setting/api/engine/reset_password_otp.php
|
||||
* login/api/engine/reset_password_otp.php
|
||||
* Handle an AJAX confirm-reset call and echo a JSON response.
|
||||
*
|
||||
* Expected $data keys: otp, new_password, confirm_password
|
||||
* Called by:
|
||||
* setting/api/engine/reset_password_otp.php (profile page)
|
||||
* login/api/engine/reset_password_otp.php (login page)
|
||||
*
|
||||
* Expected $data keys:
|
||||
* otp (string), new_password (string), confirm_password (string)
|
||||
*
|
||||
* On success: { success: 1, message: "Password reset successfully." }
|
||||
* On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." }
|
||||
* On other Exception: HTTP 500, generic message (error logged server-side)
|
||||
*
|
||||
* Outputs JSON and calls exit. XSS-safe — all output via json_encode.
|
||||
*
|
||||
* @param int $user_id The wms.user.user_id being reset.
|
||||
* @param array $data Request data array with otp, new_password, confirm_password.
|
||||
*/
|
||||
public function handleConfirmReset(int $user_id, array $data): void {
|
||||
|
||||
@@ -236,6 +283,24 @@ class PasswordResetManager {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a TOTP-style numeric OTP from a secret key and timestamp.
|
||||
*
|
||||
* Implements a simplified TOTP algorithm (RFC 6238 subset):
|
||||
* - Counter = floor(time / time_step) — changes every $time_step seconds (default 180)
|
||||
* - HMAC-SHA1 over an 8-byte counter using $secret_key
|
||||
* - Dynamic truncation to extract a 31-bit value
|
||||
* - Modulo 10^$length to produce a $length-digit OTP (default 6)
|
||||
*
|
||||
* The OTP is derived from $secret_key, so using the user's password hash
|
||||
* means the OTP is unique per user and automatically invalidated on password change.
|
||||
*
|
||||
* @param string $secret_key HMAC key — use user's password hash for per-user uniqueness.
|
||||
* @param int $otp_time Unix timestamp at which the OTP was generated (store in session).
|
||||
* @param int $time_step Window size in seconds (default 180 = 3 minutes per step).
|
||||
* @param int $length Number of digits in the OTP (default 6).
|
||||
* @return string Zero-padded OTP string of $length digits.
|
||||
*/
|
||||
private function generateOTP(string $secret_key, int $otp_time, int $time_step = 180, int $length = 6): string {
|
||||
$counter = floor($otp_time / $time_step);
|
||||
$data = pack('NN', 0, $counter);
|
||||
@@ -246,6 +311,16 @@ class PasswordResetManager {
|
||||
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a positive integer into a base-26 uppercase letter string.
|
||||
*
|
||||
* Used to turn the numeric reference OTP into a human-friendly 6-letter
|
||||
* reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email.
|
||||
* The result is left-padded with 'A' to always return a 6-character string.
|
||||
*
|
||||
* @param int $num Positive integer to convert.
|
||||
* @return string 6-character uppercase string (e.g. "AAAABC").
|
||||
*/
|
||||
private function numberToLetters(int $num): string {
|
||||
$result = '';
|
||||
while ($num > 0) {
|
||||
@@ -256,6 +331,19 @@ class PasswordResetManager {
|
||||
return str_pad($result, 6, 'A', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an email address for display in the UI.
|
||||
*
|
||||
* Shows the first 2 characters of the local part, replaces the remainder
|
||||
* with asterisks, and keeps the full domain. The masked email is returned
|
||||
* to the frontend as confirmation that the OTP was sent to the right address
|
||||
* without revealing it in full.
|
||||
*
|
||||
* Example: "john.doe@example.com" → "jo******@example.com"
|
||||
*
|
||||
* @param string $email The full email address to mask.
|
||||
* @return string Masked email address.
|
||||
*/
|
||||
private function maskEmail(string $email): string {
|
||||
$at = strpos($email, '@');
|
||||
return substr($email, 0, 2)
|
||||
@@ -263,6 +351,13 @@ class PasswordResetManager {
|
||||
. substr($email, $at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove reset-related keys from the current session.
|
||||
*
|
||||
* Called on OTP expiry (to invalidate the request) and on successful
|
||||
* reset (before session_destroy). Does not destroy the full session —
|
||||
* only the 4 reset-specific keys are unset.
|
||||
*/
|
||||
private function clearSession(): void {
|
||||
unset(
|
||||
$_SESSION['reset_otp'],
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
/**
|
||||
* ProductManager
|
||||
*
|
||||
* Encapsulates product and product category operations:
|
||||
* - Soft-delete with downstream validation
|
||||
* Handles all CRUD and soft-delete operations for products and product categories.
|
||||
*
|
||||
* Note: Methods that modify data do NOT manage their own DB transactions.
|
||||
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed.
|
||||
* Method order:
|
||||
* Master file basis → get/save/delete for product categories and products
|
||||
* Transaction basis → (none — products are master data only)
|
||||
* Report basis → getRackOccupancy (cross-warehouse physical inventory view)
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* No user input is ever interpolated directly into a query string.
|
||||
*/
|
||||
class ProductManager {
|
||||
|
||||
@@ -24,8 +31,16 @@ class ProductManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a SKU has any active stock across all td_stock_* tables.
|
||||
* Returns the first blocking table name found, or null if clear.
|
||||
* Scan all td_stock_* warehouse tables for any active stock row
|
||||
* that references the given SKU.
|
||||
*
|
||||
* Used as a pre-delete guard on products: a product cannot be removed
|
||||
* while stock exists for it in any warehouse.
|
||||
* Table names come from information_schema (trusted system table)
|
||||
* and are backtick-quoted — no user input reaches the identifier.
|
||||
*
|
||||
* @param string $sku The product SKU to search for.
|
||||
* @return string|null The first blocking table name found, or null if clear.
|
||||
*/
|
||||
private function findActiveStock(string $sku): ?string {
|
||||
|
||||
@@ -56,7 +71,17 @@ class ProductManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a log entry array for audit context.
|
||||
* Build a standard audit log entry array for append-to-JSON log columns.
|
||||
*
|
||||
* Captures the acting user_id, current datetime, session login time,
|
||||
* and a short action label (e.g. 'delete', 'update').
|
||||
*
|
||||
* Usage:
|
||||
* $log[] = $this->buildLogEntry('delete');
|
||||
* $params[':log'] = json_encode($log);
|
||||
*
|
||||
* @param string $action Short label describing the operation (e.g. 'delete').
|
||||
* @return array Associative array ready to be appended to a log array.
|
||||
*/
|
||||
private function buildLogEntry(string $action): array {
|
||||
return [
|
||||
@@ -70,180 +95,17 @@ class ProductManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Product category
|
||||
// MASTER FILE BASIS — Product Category
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft-delete a product category.
|
||||
* Return all product categories with their product count.
|
||||
*
|
||||
* Blocks if any md_product references this category.
|
||||
* Used to populate the category listing page and category dropdowns.
|
||||
* The LEFT JOIN count lets the UI show how many products are in each category.
|
||||
*
|
||||
* @throws Exception if category not found or has dependent products
|
||||
*/
|
||||
public function deleteCategory(int $category_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, category, `log` FROM md_product_category
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $category_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Product category not found.");
|
||||
}
|
||||
|
||||
// Block if any product references this category
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_product
|
||||
WHERE company_id = :company_id
|
||||
AND category = :category_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':category_id' => $category_id,
|
||||
]);
|
||||
|
||||
if ($sth->fetchColumn() > 0) {
|
||||
throw new Exception(
|
||||
"Cannot delete — category \"{$row['category']}\" " .
|
||||
"still has products assigned to it."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_product_category
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $category_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Product
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft-delete a product.
|
||||
*
|
||||
* Blocks if the product SKU has any active stock across any td_stock_* table.
|
||||
*
|
||||
* @throws Exception if product not found or has active stock
|
||||
*/
|
||||
public function deleteProduct(int $product_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, product_name, sku, `log` FROM md_product
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $product_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Product not found.");
|
||||
}
|
||||
|
||||
// Block if active stock exists for this SKU in any warehouse
|
||||
$blocking_table = $this->findActiveStock($row['sku']);
|
||||
if ($blocking_table !== null) {
|
||||
throw new Exception(
|
||||
"Cannot delete — \"{$row['product_name']}\" " .
|
||||
"still has active stock in the system."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_product
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $product_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Product queries
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full product list with current warehouse balance.
|
||||
*/
|
||||
public function getProductList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
|
||||
FROM md_product a
|
||||
LEFT JOIN warehouse_balance b
|
||||
ON a.company_id = b.company_id
|
||||
AND a.sku = b.product_sku
|
||||
WHERE a.company_id = :company_id
|
||||
GROUP BY a.id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single product row by ID.
|
||||
*/
|
||||
public function getProductById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_product
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products by SKU keyword — for autocomplete.
|
||||
*/
|
||||
public function searchProduct(string $keyword): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_product
|
||||
WHERE company_id = :company_id
|
||||
AND sku LIKE :keyword
|
||||
LIMIT 50"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':keyword' => '%' . $keyword . '%',
|
||||
]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Product category queries
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full category list with product count per category.
|
||||
* @return array All md_product_category rows for this company,
|
||||
* each augmented with a 'product_count' field.
|
||||
*/
|
||||
public function getCategoryList(): array
|
||||
{
|
||||
@@ -261,7 +123,12 @@ class ProductManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single category row by ID.
|
||||
* Fetch a single product category row by its primary key.
|
||||
*
|
||||
* Used to pre-fill the edit form on the manage category page.
|
||||
*
|
||||
* @param int $id The md_product_category.id to fetch.
|
||||
* @return array|false Associative row, or false if not found.
|
||||
*/
|
||||
public function getCategoryById(int $id): array|false
|
||||
{
|
||||
@@ -274,11 +141,16 @@ class ProductManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a product category.
|
||||
* Pass $data['id'] > 0 for update, 0 for insert.
|
||||
* Insert a new product category or update an existing one.
|
||||
*
|
||||
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
|
||||
* The $logging array is appended to the row's JSON log column.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @throws Exception on validation failure
|
||||
* @param array $data Keys: id, category, slug, description, status.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @throws Exception On validation failure (e.g. duplicate slug).
|
||||
*/
|
||||
public function saveCategory(array $data, array $logging): void
|
||||
{
|
||||
@@ -322,15 +194,153 @@ class ProductManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a product category by negating its company_id.
|
||||
*
|
||||
* Blocks deletion if any md_product row is still assigned to this category,
|
||||
* preventing products from losing their category reference.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $category_id The md_product_category.id to delete.
|
||||
* @throws Exception If the category is not found or has products assigned to it.
|
||||
*/
|
||||
public function deleteCategory(int $category_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, category, `log` FROM md_product_category
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $category_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Product category not found.");
|
||||
}
|
||||
|
||||
// Block if any product references this category
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_product
|
||||
WHERE company_id = :company_id
|
||||
AND category = :category_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':category_id' => $category_id,
|
||||
]);
|
||||
|
||||
if ($sth->fetchColumn() > 0) {
|
||||
throw new Exception(
|
||||
"Cannot delete — category \"{$row['category']}\" " .
|
||||
"still has products assigned to it."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_product_category
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $category_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Product write
|
||||
// MASTER FILE BASIS — Product
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert or update a product.
|
||||
* Pass $data['id'] > 0 for update, 0 for insert.
|
||||
* Return all products with their current aggregate warehouse balance.
|
||||
*
|
||||
* The balance is the sum of (total_in - total_out) across all warehouses
|
||||
* from the warehouse_balance table. Products with no balance rows show 0.
|
||||
*
|
||||
* Used to populate the product listing page.
|
||||
*
|
||||
* @return array All md_product rows for this company, each with a 'product_balance' field.
|
||||
*/
|
||||
public function getProductList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
|
||||
FROM md_product a
|
||||
LEFT JOIN warehouse_balance b
|
||||
ON a.company_id = b.company_id
|
||||
AND a.sku = b.product_sku
|
||||
WHERE a.company_id = :company_id
|
||||
GROUP BY a.id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single product row by its primary key.
|
||||
*
|
||||
* Used to pre-fill the edit form on the manage product page.
|
||||
*
|
||||
* @param int $id The md_product.id to fetch.
|
||||
* @return array|false Associative row, or false if not found.
|
||||
*/
|
||||
public function getProductById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_product
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products by SKU keyword — for live autocomplete on stock forms.
|
||||
*
|
||||
* Returns up to 50 matches. The keyword is safely bound as a LIKE parameter;
|
||||
* no wildcard escaping is needed here since '%' wrapping is the intended behaviour.
|
||||
*
|
||||
* @param string $keyword Partial SKU to match.
|
||||
* @return array Matching md_product rows.
|
||||
*/
|
||||
public function searchProduct(string $keyword): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_product
|
||||
WHERE company_id = :company_id
|
||||
AND sku LIKE :keyword
|
||||
LIMIT 50"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':keyword' => '%' . $keyword . '%',
|
||||
]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new product or update an existing one.
|
||||
*
|
||||
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
|
||||
* $product_image is the resolved filename/path from FileUploader — may be
|
||||
* the existing image when no new file was uploaded.
|
||||
* The $logging array is appended to the row's JSON log column.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, product_name, sku, price, min_stock,
|
||||
* reorder_point, category, description, status.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @param string $product_image Stored filename for the product image.
|
||||
*/
|
||||
public function saveProduct(array $data, array $logging, string $product_image): void
|
||||
{
|
||||
@@ -386,9 +396,77 @@ class ProductManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a product by negating its company_id.
|
||||
*
|
||||
* Blocks deletion if the product SKU has any active stock across any
|
||||
* td_stock_* warehouse table. This prevents the product master record
|
||||
* from disappearing while physical inventory still exists for it.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $product_id The md_product.id to delete.
|
||||
* @throws Exception If the product is not found or has active stock.
|
||||
*/
|
||||
public function deleteProduct(int $product_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, product_name, sku, `log` FROM md_product
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $product_id,
|
||||
]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Product not found.");
|
||||
}
|
||||
|
||||
// Block if active stock exists for this SKU in any warehouse
|
||||
$blocking_table = $this->findActiveStock($row['sku']);
|
||||
if ($blocking_table !== null) {
|
||||
throw new Exception(
|
||||
"Cannot delete — \"{$row['product_name']}\" " .
|
||||
"still has active stock in the system."
|
||||
);
|
||||
}
|
||||
|
||||
// Append delete event to log
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $this->buildLogEntry('delete');
|
||||
|
||||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_product
|
||||
SET company_id = company_id * -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $product_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full rack list with warehouse name, product info and occupancy status.
|
||||
* Return all rack slots across all warehouses with occupancy status,
|
||||
* warehouse name, and product name.
|
||||
*
|
||||
* Used by the rack occupancy dashboard to visualise which racks are
|
||||
* empty vs. occupied, and which product is in each slot.
|
||||
* Results are ordered by warehouse → zone → aisle → rack, with
|
||||
* numeric-first sorting via CAST so e.g. "2" sorts before "10".
|
||||
*
|
||||
* Security fix: was previously using $this->companyId (undefined property),
|
||||
* corrected to $this->company_id.
|
||||
*
|
||||
* @return array All md_rack rows joined to warehouse and product, with 'status' field.
|
||||
*/
|
||||
public function getRackOccupancy(): array
|
||||
{
|
||||
@@ -416,7 +494,7 @@ class ProductManager {
|
||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
||||
CAST(r.rack AS UNSIGNED), r.rack"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ReportManager
|
||||
*
|
||||
* Provides all read-only reporting and dashboard data aggregation.
|
||||
* Contains no write operations — purely for data retrieval and summarisation.
|
||||
*
|
||||
* Method order (all report basis):
|
||||
* Master data summaries → category, product, warehouse, contact counts
|
||||
* Stock summaries → balance, low stock, critical/warning counts
|
||||
* Dashboard reports → stats, movement charts, most moved, recent activity
|
||||
* Warehouse detail → capacity, space used, balance, movement, trend, activity
|
||||
* Expiry reports → expired / near-expiry stock
|
||||
* Lot / Rack reports → lot stock log, product lots, rack log, rack occupancy
|
||||
* Balance summary → getWarehouseBalanceSummary
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced
|
||||
* warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
|
||||
* before interpolation. Integer parameters (warehouse_id, company_id, limit)
|
||||
* are explicitly cast to (int) before use in any SQL string fragment.
|
||||
*
|
||||
* Security fixes applied vs. previous version:
|
||||
* - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped)
|
||||
* - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection;
|
||||
* warehouse_name in UNION now uses $safe (was unescaped)
|
||||
* - getRackLog: DB name from SELECT DATABASE() bound via PDO (was raw interpolation)
|
||||
* - getRackOccupancy: (already safe — no dynamic identifiers)
|
||||
*/
|
||||
class ReportManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
@@ -11,9 +39,18 @@ class ReportManager
|
||||
$this->companyId = $companyId;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<wh> table name for a given warehouse_id.
|
||||
* Returns null if warehouse not found.
|
||||
* Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
|
||||
*
|
||||
* Returns null if the warehouse is not found or inactive.
|
||||
* The warehouse_name is sanitised before use as a table suffix.
|
||||
*
|
||||
* @param int $warehouse_id The md_warehouse.id to resolve.
|
||||
* @return string|null Sanitised table name, or null if not active/found.
|
||||
*/
|
||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||
{
|
||||
@@ -28,7 +65,15 @@ class ReportManager
|
||||
return "td_stock_{$safe}";
|
||||
}
|
||||
|
||||
// These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
|
||||
/**
|
||||
* Execute a simple SELECT COUNT(*) or scalar query and return one value.
|
||||
*
|
||||
* Used by the stat-count methods that only need a single integer result.
|
||||
* The SQL must contain a :company_id placeholder.
|
||||
*
|
||||
* @param string $sql Prepared SQL with :company_id placeholder.
|
||||
* @return mixed The first column of the first result row.
|
||||
*/
|
||||
private function fetchScalar(string $sql)
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
@@ -36,6 +81,15 @@ class ReportManager
|
||||
return $sth->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return all rows as an associative array.
|
||||
*
|
||||
* Used by list-style reporting methods that need multiple rows.
|
||||
* The SQL must contain a :company_id placeholder.
|
||||
*
|
||||
* @param string $sql Prepared SQL with :company_id placeholder.
|
||||
* @return array All result rows as PDO::FETCH_ASSOC arrays.
|
||||
*/
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
@@ -43,8 +97,13 @@ class ReportManager
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Master data summaries
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Master Data Summary
|
||||
* Total number of product categories (all statuses) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalCategory(): int
|
||||
{
|
||||
@@ -54,6 +113,10 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of active (status = 1) product categories for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getActiveCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
@@ -63,6 +126,10 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of inactive (status = 0) product categories for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getInactiveCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
@@ -72,6 +139,10 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of active warehouses (status = 1) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalWarehouse(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
@@ -81,6 +152,10 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of distinct warehouse locations for this company's active warehouses.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalLocation(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(DISTINCT `location`)
|
||||
@@ -90,6 +165,10 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total rack capacity (all racks across all warehouses) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalCapacity(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
@@ -98,6 +177,10 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of occupied racks (product_sku IS NOT NULL) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getSpaceUsed(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
@@ -107,6 +190,10 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total number of products (all statuses) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalProduct(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
@@ -115,6 +202,11 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of distinct SKUs with a positive running balance across all warehouses.
|
||||
* "In stock" means (total_in - total_out) > 0 in warehouse_balance.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalProductInStock(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(DISTINCT product_sku)
|
||||
@@ -124,19 +216,68 @@ class ReportManager
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
public function getLowStockCount(): int
|
||||
/**
|
||||
* Number of contact types (all statuses) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalContactCategory(): int
|
||||
{
|
||||
$products = $this->getStockBalance();
|
||||
$count = 0;
|
||||
foreach ($products as $product) {
|
||||
$balance = (float) $product["total_in"] - (float) $product["total_out"];
|
||||
if ($balance < (float) $product["min_stock"]) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of active (status = 1) contact types for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getActiveContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id
|
||||
AND `status` = 1";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of inactive (status = 0) contact types for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getInactiveContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id
|
||||
AND `status` = 0";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total number of contacts for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalContact(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact
|
||||
WHERE company_id = :company_id";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Stock summaries
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return aggregated total_in, total_out, and min_stock for every SKU
|
||||
* across all warehouses from warehouse_balance.
|
||||
*
|
||||
* Used internally by getLowStockCount and as a general balance query.
|
||||
*
|
||||
* @return array Rows with product_sku, total_in, total_out, min_stock.
|
||||
*/
|
||||
public function getStockBalance(): array
|
||||
{
|
||||
$sql = "SELECT
|
||||
@@ -153,6 +294,34 @@ class ReportManager
|
||||
return $this->fetchAll($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many SKUs are currently below their min_stock threshold.
|
||||
*
|
||||
* Computed in PHP from getStockBalance() to avoid complex SQL HAVING with JOIN.
|
||||
*
|
||||
* @return int Number of SKUs with balance < min_stock.
|
||||
*/
|
||||
public function getLowStockCount(): int
|
||||
{
|
||||
$products = $this->getStockBalance();
|
||||
$count = 0;
|
||||
foreach ($products as $product) {
|
||||
$balance = (float) $product["total_in"] - (float) $product["total_out"];
|
||||
if ($balance < (float) $product["min_stock"]) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all SKUs at or below their reorder_point with full product and warehouse details.
|
||||
*
|
||||
* Includes a 'status' field: 'critical' if balance <= min_stock, 'warning' otherwise.
|
||||
* Only products with reorder_point > 0 are included (to skip un-configured products).
|
||||
*
|
||||
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
|
||||
*/
|
||||
public function getLowStockItems(): array
|
||||
{
|
||||
$sql = "SELECT
|
||||
@@ -200,55 +369,43 @@ class ReportManager
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count SKUs with status = 'critical' (balance <= min_stock).
|
||||
* Derived from getLowStockItems().
|
||||
*
|
||||
* @return int Number of critical stock items.
|
||||
*/
|
||||
public function getCriticalStockCount(): int
|
||||
{
|
||||
$items = $this->getLowStockItems();
|
||||
return count(array_filter($items, fn($i) => $i['status'] === 'critical'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count SKUs with status = 'warning' (reorder_point < balance <= min_stock boundary).
|
||||
* Derived from getLowStockItems().
|
||||
*
|
||||
* @return int Number of warning stock items.
|
||||
*/
|
||||
public function getWarningStockCount(): int
|
||||
{
|
||||
$items = $this->getLowStockItems();
|
||||
return count(array_filter($items, fn($i) => $i['status'] === 'warning'));
|
||||
}
|
||||
|
||||
// CONTACT SUMMARY
|
||||
public function getTotalContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
public function getActiveContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id
|
||||
AND `status` = 1";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
public function getInactiveContactCategory(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact_type
|
||||
WHERE company_id = :company_id
|
||||
AND `status` = 0";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
public function getTotalContact(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_contact
|
||||
WHERE company_id = :company_id";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
// DASHBOARD REPORTS (cross-warehouse, month-filtered via warehouse_balance)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Dashboard reports
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return aggregate stock movement stats for a specific month.
|
||||
*
|
||||
* Used by the main dashboard stats widget.
|
||||
* $month format: 'YYYY-MM' (e.g. '2025-04').
|
||||
*
|
||||
* @param string $month Month in YYYY-MM format.
|
||||
* @return array Keys: total_in, total_out, active_products.
|
||||
*/
|
||||
public function getDashboardStats(string $month): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -266,6 +423,14 @@ class ReportManager
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count SKUs currently below their min_stock threshold — for the dashboard alert tile.
|
||||
*
|
||||
* Similar to getLowStockCount() but computed directly via SQL aggregate for efficiency,
|
||||
* without the intermediate getStockBalance() call.
|
||||
*
|
||||
* @return int Number of SKUs with balance < min_stock.
|
||||
*/
|
||||
public function getDashboardLowStockCount(): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -288,6 +453,15 @@ class ReportManager
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return monthly stock_in and stock_out totals for a rolling N-month window.
|
||||
*
|
||||
* Produces chart-ready arrays with labels (e.g. "Apr 2025"), stock_in, and stock_out.
|
||||
* Months with no data return 0. Data is sourced from warehouse_balance (all warehouses).
|
||||
*
|
||||
* @param int $months Number of months to include (default 12).
|
||||
* @return array Keys: labels (array), stock_in (array), stock_out (array).
|
||||
*/
|
||||
public function getStockMovementChart(int $months = 12): array
|
||||
{
|
||||
$now = new DateTime();
|
||||
@@ -328,9 +502,19 @@ class ReportManager
|
||||
return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the top N most-moved products (by total_out) for a given month.
|
||||
*
|
||||
* Used by the dashboard "most moved" widget.
|
||||
* $limit is cast to (int) before interpolation to prevent SQL injection.
|
||||
*
|
||||
* @param string $month Month in YYYY-MM format.
|
||||
* @param int $limit Maximum number of products to return (default 10).
|
||||
* @return array Rows with product_sku, total_in, total_out, product_name, category_name.
|
||||
*/
|
||||
public function getMostMovedProducts(string $month, int $limit = 10): array
|
||||
{
|
||||
$limit = (int) $limit;
|
||||
$limit = (int) $limit; // cast before interpolation
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
wb.product_sku,
|
||||
@@ -358,6 +542,13 @@ class ReportManager
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all months for which warehouse_balance data exists, newest first.
|
||||
*
|
||||
* Used to populate the month selector on the dashboard and reports pages.
|
||||
*
|
||||
* @return array Flat array of YYYY-MM strings.
|
||||
*/
|
||||
public function getAvailableMonths(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -371,7 +562,17 @@ class ReportManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent stock activity across all warehouses — last N transactions.
|
||||
* Return the most recent N stock transactions across all active warehouses.
|
||||
*
|
||||
* Builds a UNION ALL across all td_stock_* tables to produce a unified
|
||||
* activity feed ordered by date DESC. Used by the dashboard "recent activity" widget.
|
||||
*
|
||||
* Security fix: warehouse_name in UNION SQL now uses $safe (sanitised with
|
||||
* preg_replace) instead of the raw warehouse_name string.
|
||||
*
|
||||
* @param int $limit Maximum number of transactions to return (default 10).
|
||||
* @return array Activity rows with product_name, product_sku, warehouse_name,
|
||||
* direction ('in'|'out'), qty, type, date.
|
||||
*/
|
||||
public function getRecentActivity(int $limit = 10): array
|
||||
{
|
||||
@@ -384,21 +585,27 @@ class ReportManager
|
||||
|
||||
if (empty($warehouses)) return [];
|
||||
|
||||
// Security: use $safe (sanitised name) for both the table identifier
|
||||
// and the literal warehouse_name string in the SELECT — never raw $wh['warehouse_name'].
|
||||
$unions = implode("\nUNION ALL\n", array_map(
|
||||
fn($wh) => "SELECT s.date, s.product_sku, s.type,
|
||||
function ($wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$cid = (int) $this->companyId;
|
||||
return "SELECT s.date, s.product_sku, s.type,
|
||||
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
||||
p.product_name,
|
||||
'{$wh['warehouse_name']}' AS warehouse_name
|
||||
FROM `td_stock_{$wh['warehouse_name']}` s
|
||||
'{$safe}' AS warehouse_name
|
||||
FROM `td_stock_{$safe}` s
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = s.company_id
|
||||
AND p.sku = s.product_sku
|
||||
WHERE s.company_id = {$this->companyId}",
|
||||
WHERE s.company_id = {$cid}";
|
||||
},
|
||||
$warehouses
|
||||
));
|
||||
|
||||
$limit = (int) $limit;
|
||||
$limit = (int) $limit; // cast before interpolation
|
||||
$sth = $this->pdo->query(
|
||||
"SELECT * FROM ({$unions}) AS all_stock
|
||||
ORDER BY date DESC
|
||||
@@ -421,7 +628,16 @@ class ReportManager
|
||||
return $items;
|
||||
}
|
||||
|
||||
// WAREHOUSE OVERVIEW
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Warehouse detail reports
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Total rack capacity for a specific warehouse (all racks regardless of occupancy).
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return int Total rack count.
|
||||
*/
|
||||
public function getWarehouseCapacity(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -432,6 +648,12 @@ class ReportManager
|
||||
return (int) $sth->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of occupied racks (product_sku IS NOT NULL) for a specific warehouse.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return int Occupied rack count.
|
||||
*/
|
||||
public function getWarehouseSpaceUsed(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -443,6 +665,12 @@ class ReportManager
|
||||
return (int) $sth->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Total in and out balance for a specific warehouse from warehouse_balance.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return array Keys: total_in, total_out.
|
||||
*/
|
||||
public function getWarehouseBalance(int $warehouse_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -456,6 +684,14 @@ class ReportManager
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count SKUs below min_stock threshold for a specific warehouse.
|
||||
*
|
||||
* Scoped to warehouse_balance rows for this warehouse only.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return int Number of SKUs below min_stock.
|
||||
*/
|
||||
public function getWarehouseLowStockCount(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -479,6 +715,15 @@ class ReportManager
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return monthly stock_in and stock_out totals for a warehouse over the last 12 months.
|
||||
*
|
||||
* Queries the per-warehouse td_stock_<wh> table directly (not warehouse_balance)
|
||||
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return array Keys: labels, stock_in, stock_out (each a 12-element array).
|
||||
*/
|
||||
public function getWarehouseStockMovement(int $warehouse_id): array
|
||||
{
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
@@ -522,6 +767,16 @@ class ReportManager
|
||||
return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a running cumulative balance trend for a warehouse over the last 12 months.
|
||||
*
|
||||
* Starts from the balance before the 12-month window and accumulates
|
||||
* monthly in/out to produce a month-by-month balance curve.
|
||||
* Used by the warehouse overview balance trend chart.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return array Keys: labels (12-element), balance (12-element cumulative floats).
|
||||
*/
|
||||
public function getWarehouseStockTrend(int $warehouse_id): array
|
||||
{
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
@@ -531,6 +786,7 @@ class ReportManager
|
||||
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
|
||||
$end = $now->format('Y-m-d 23:59:59');
|
||||
|
||||
// Opening balance: everything before the 12-month window
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
|
||||
FROM `{$table}`
|
||||
@@ -574,6 +830,15 @@ class ReportManager
|
||||
return ['labels' => $labels, 'balance' => $balance];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the 10 most recent stock movements for a warehouse.
|
||||
*
|
||||
* Used by the warehouse overview recent activity list.
|
||||
* Includes only rows with in > 0 or out > 0 (excludes zero-quantity rows).
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return array Activity rows with product_name, sku, direction, qty, type, description, date.
|
||||
*/
|
||||
public function getWarehouseActivity(int $warehouse_id): array
|
||||
{
|
||||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||||
@@ -610,24 +875,59 @@ class ReportManager
|
||||
return $activities;
|
||||
}
|
||||
|
||||
// EXPIRED / NEAR EXPIRY STOCK
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Expiry reports
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all stock rows with lot numbers that are expired or expiring within 30 days.
|
||||
*
|
||||
* Can be scoped to a single warehouse by passing warehouse_id > 0,
|
||||
* or run across all warehouses with warehouse_id = 0.
|
||||
*
|
||||
* Status thresholds:
|
||||
* days_remaining < 0 → 'expired'
|
||||
* 0–7 → 'critical'
|
||||
* 8–14 → 'warning'
|
||||
* 15–30 → 'caution'
|
||||
* > 30 → excluded
|
||||
*
|
||||
* Security fix: $warehouse_id is now cast to (int) and the WHERE condition
|
||||
* uses a bound parameter (:warehouse_id) instead of raw string interpolation.
|
||||
* warehouse_name in UNION now uses $safe variable (sanitised) not raw $wh['warehouse_name'].
|
||||
*
|
||||
* @param int $warehouse_id Warehouse filter (0 = all warehouses).
|
||||
* @return array Expiry-status stock items with product, lot, location, and days_remaining.
|
||||
*/
|
||||
public function getExpiredStock(int $warehouse_id = 0): array
|
||||
{
|
||||
$where_wh = $warehouse_id > 0 ? "AND id = {$warehouse_id}" : '';
|
||||
// Security: cast to int, use bound parameter in WHERE
|
||||
$warehouse_id = (int) $warehouse_id;
|
||||
|
||||
if ($warehouse_id > 0) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name
|
||||
FROM md_warehouse
|
||||
WHERE company_id = :company_id
|
||||
AND status = 1
|
||||
{$where_wh}"
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1 AND id = :warehouse_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
} else {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
}
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($warehouses)) return [];
|
||||
|
||||
$cid = (int) $this->companyId; // cast before interpolation
|
||||
|
||||
// Security: use $safe (sanitised) for table identifier and literal warehouse name string
|
||||
$unions = implode("\nUNION ALL\n", array_map(
|
||||
fn($wh) => "SELECT
|
||||
function ($wh) use ($cid) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
return "SELECT
|
||||
s.id,
|
||||
s.product_sku,
|
||||
s.lot_number,
|
||||
@@ -636,10 +936,11 @@ class ReportManager
|
||||
s.rack,
|
||||
ROUND(s.`in`, 2) AS quantity,
|
||||
{$wh['id']} AS warehouse_id
|
||||
FROM `td_stock_{$wh['warehouse_name']}` s
|
||||
WHERE s.company_id = {$this->companyId}
|
||||
FROM `td_stock_{$safe}` s
|
||||
WHERE s.company_id = {$cid}
|
||||
AND s.type = 'in'
|
||||
AND s.lot_number IS NOT NULL",
|
||||
AND s.lot_number IS NOT NULL";
|
||||
},
|
||||
$warehouses
|
||||
));
|
||||
|
||||
@@ -658,17 +959,17 @@ class ReportManager
|
||||
mw.warehouse_name
|
||||
FROM ($unions) AS stock
|
||||
INNER JOIN md_lot l
|
||||
ON l.company_id = {$this->companyId}
|
||||
ON l.company_id = {$cid}
|
||||
AND l.product_sku = stock.product_sku
|
||||
AND l.lot_number = stock.lot_number
|
||||
INNER JOIN md_product p
|
||||
ON p.company_id = {$this->companyId}
|
||||
ON p.company_id = {$cid}
|
||||
AND p.sku = stock.product_sku
|
||||
LEFT JOIN md_product_category pc
|
||||
ON pc.company_id = {$this->companyId}
|
||||
ON pc.company_id = {$cid}
|
||||
AND pc.id = p.category
|
||||
INNER JOIN md_warehouse mw
|
||||
ON mw.company_id = {$this->companyId}
|
||||
ON mw.company_id = {$cid}
|
||||
AND mw.id = stock.warehouse_id
|
||||
WHERE l.expiry_date IS NOT NULL
|
||||
ORDER BY l.expiry_date ASC";
|
||||
@@ -708,35 +1009,20 @@ class ReportManager
|
||||
return $items;
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Warehouse balance summary
|
||||
// REPORT BASIS — Lot / Rack reports
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate total_in, total_out and warehouse count across all warehouses.
|
||||
*/
|
||||
public function getWarehouseBalanceSummary(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
SUM(total_in) AS total_in,
|
||||
SUM(total_out) AS total_out,
|
||||
COUNT(DISTINCT warehouse_id) AS total_warehouse
|
||||
FROM warehouse_balance
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Dashboard report queries
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All stock movements for a given product_sku + lot_number across all warehouses,
|
||||
* sorted by date DESC.
|
||||
* Return all stock movements for a specific product_sku + lot_number across all warehouses.
|
||||
*
|
||||
* Used by the lot stock log dashboard report page.
|
||||
* Results are merged from all td_stock_* tables and sorted by date DESC.
|
||||
* Returns empty array if either parameter is blank.
|
||||
*
|
||||
* @param string $product_sku SKU to filter by.
|
||||
* @param string $lot_number Lot number to filter by.
|
||||
* @return array Stock rows with date, type, zone/aisle/rack, in/out amounts, warehouse_name.
|
||||
*/
|
||||
public function getLotStockLog(string $product_sku, string $lot_number): array
|
||||
{
|
||||
@@ -767,7 +1053,7 @@ class ReportManager
|
||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
||||
s.serial_number,
|
||||
s.description,
|
||||
'{$wh['warehouse_name']}' AS warehouse_name
|
||||
'{$safe}' AS warehouse_name
|
||||
FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
@@ -787,8 +1073,19 @@ class ReportManager
|
||||
}
|
||||
|
||||
/**
|
||||
* All lots with running balance, expiry status, and days remaining.
|
||||
* Returns rows plus summary counts (total, active, expired, near).
|
||||
* Return all lots with running balance, expiry status, and summary counts.
|
||||
*
|
||||
* Used by the product lot dashboard report page.
|
||||
* Each row gets a 'status' field ('expired' | 'critical' | 'near' | 'ok')
|
||||
* based on days_remaining.
|
||||
*
|
||||
* Returns both the row data and a summary:
|
||||
* total → total number of lot rows
|
||||
* active → lots with balance > 0
|
||||
* expired → lots past expiry date
|
||||
* near → lots expiring within 30 days
|
||||
*
|
||||
* @return array Keys: rows (array), total (int), active (int), expired (int), near (int).
|
||||
*/
|
||||
public function getProductLots(): array
|
||||
{
|
||||
@@ -845,13 +1142,25 @@ class ReportManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Rack activity log for a given rack_id, with user name.
|
||||
* Return the action log for a specific rack, with user name.
|
||||
*
|
||||
* Used by the rack log dashboard report page.
|
||||
* Joins the wms.user table for the acting user's name.
|
||||
*
|
||||
* Security fix: the database name is now fetched once via a bound query
|
||||
* and stored as $db_name (string), then used as a backtick-quoted identifier
|
||||
* rather than being embedded in the JOIN without escaping.
|
||||
*
|
||||
* @param int $rack_id The md_rack.id to fetch logs for (0 returns empty array).
|
||||
* @return array Log rows with dt, action, product_sku, td_stock_id, login, user_name.
|
||||
*/
|
||||
public function getRackLog(int $rack_id): array
|
||||
{
|
||||
if (!$rack_id) return [];
|
||||
|
||||
$db = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
|
||||
// Fetch DB name safely — used as a backtick-quoted identifier, not user input
|
||||
$db_name = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
|
||||
$db_name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db_name);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
@@ -863,7 +1172,7 @@ class ReportManager
|
||||
rl.login,
|
||||
u.name AS user_name
|
||||
FROM md_rack_log rl
|
||||
LEFT JOIN {$db}.user u
|
||||
LEFT JOIN `{$db_name}`.user u
|
||||
ON u.user_id = rl.user_id
|
||||
WHERE rl.company_id = :company_id
|
||||
AND rl.md_rack_id = :rack_id
|
||||
@@ -876,9 +1185,16 @@ class ReportManager
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Full rack list with warehouse name, product info and occupancy status.
|
||||
* Return all rack slots across all warehouses with occupancy status,
|
||||
* warehouse name, and product name.
|
||||
*
|
||||
* Used by the rack occupancy dashboard report page to visualise which
|
||||
* racks are empty vs occupied and which product is in each slot.
|
||||
* Results are ordered by warehouse → zone → aisle → rack with
|
||||
* numeric-first sorting via CAST.
|
||||
*
|
||||
* @return array All md_rack rows joined to warehouse and product with 'status' field.
|
||||
*/
|
||||
public function getRackOccupancy(): array
|
||||
{
|
||||
@@ -909,6 +1225,29 @@ class ReportManager
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Balance summary
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return aggregate total_in, total_out, and warehouse count across all warehouses.
|
||||
*
|
||||
* Used by the warehouse balance overview page to show company-wide totals.
|
||||
*
|
||||
* @return array Keys: total_in, total_out, total_warehouse.
|
||||
*/
|
||||
public function getWarehouseBalanceSummary(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
SUM(total_in) AS total_in,
|
||||
SUM(total_out) AS total_out,
|
||||
COUNT(DISTINCT warehouse_id) AS total_warehouse
|
||||
FROM warehouse_balance
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,24 @@
|
||||
/**
|
||||
* StockManager
|
||||
*
|
||||
* Encapsulates read operations for ICS stock transactions:
|
||||
* - Stock list by type (in / out / transfer)
|
||||
* - Single record retrieval for stock_in, stock_out, transfer
|
||||
* Handles read and write operations for ICS stock transactions
|
||||
* (stock in, stock out, stock transfer).
|
||||
*
|
||||
* Write operations (manage_stock_in, manage_stock_out, manage_stock_transfer)
|
||||
* are handled in their engine files via WarehouseManager.
|
||||
* Method order:
|
||||
* Master file basis → (none — stock transactions are not master data)
|
||||
* Transaction basis → getStockList, getStockInById, getStockOutById,
|
||||
* getTransferById, saveStockIn, saveStockOut, saveStockTransfer
|
||||
* Report basis → (none — reporting is handled by ReportManager)
|
||||
*
|
||||
* Write operations delegate rack and balance side-effects to WarehouseManager.
|
||||
* Delete operations are handled directly in WarehouseManager (deleteStockIn, etc.).
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced warehouse
|
||||
* names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation.
|
||||
*/
|
||||
class StockManager {
|
||||
|
||||
@@ -25,8 +37,17 @@ class StockManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve warehouse name by ID and return the td_stock_<wh> table name.
|
||||
* Throws if warehouse not found.
|
||||
* Resolve the dynamic td_stock_<warehouse> table name for a warehouse_id.
|
||||
*
|
||||
* Looks up warehouse_name from md_warehouse (no status filter — unlike
|
||||
* WarehouseManager::resolveWarehouseTable, this serves read flows that may
|
||||
* need to access inactive warehouses for historical record retrieval).
|
||||
* The name is sanitised with preg_replace before being used as a table
|
||||
* identifier, preventing SQL injection via malicious warehouse names.
|
||||
*
|
||||
* @param int $warehouse_id The md_warehouse.id to resolve.
|
||||
* @return string The sanitised table name, e.g. "td_stock_Main".
|
||||
* @throws Exception If no warehouse is found for the given ID.
|
||||
*/
|
||||
private function resolveTable(int $warehouse_id): string
|
||||
{
|
||||
@@ -46,19 +67,26 @@ class StockManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Stock list queries
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all stock records of a given type for a warehouse.
|
||||
* type: 'in' | 'out' | 'transfer'
|
||||
* Return all stock records of a given movement type for a warehouse.
|
||||
*
|
||||
* Used to populate the stock in / stock out / transfer listing pages.
|
||||
* The 'quantity' alias resolves to the correct column (in or out) depending
|
||||
* on the type. For transfers, only the outbound row is listed (out > 0).
|
||||
*
|
||||
* @param int $warehouse_id The md_warehouse.id to query.
|
||||
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
|
||||
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
|
||||
*/
|
||||
public function getStockList(int $warehouse_id, string $type): array
|
||||
{
|
||||
$table = $this->resolveTable($warehouse_id);
|
||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
|
||||
// transfer list shows outbound side only (out > 0)
|
||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -79,13 +107,16 @@ class StockManager {
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Single record retrieval
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a single stock_in record with product and contact name,
|
||||
* plus lot expiry date if applicable.
|
||||
* Fetch a single stock_in record with related product, contact, and lot data.
|
||||
*
|
||||
* Used to pre-fill the manage stock in form in edit mode and for the
|
||||
* stock in detail view. Joins md_lot to include lot expiry_date when available.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse the stock_in belongs to.
|
||||
* @param int $id The td_stock_<wh>.id of the stock_in row.
|
||||
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
|
||||
* 'expiry_date', or false if not found.
|
||||
*/
|
||||
public function getStockInById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
@@ -112,7 +143,15 @@ class StockManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single stock_out record with product and contact name.
|
||||
* Fetch a single stock_out record with related product and contact data.
|
||||
*
|
||||
* Used to pre-fill the manage stock out form in edit mode and for the
|
||||
* stock out detail view.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse the stock_out belongs to.
|
||||
* @param int $id The td_stock_<wh>.id of the stock_out row.
|
||||
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
|
||||
* or false if not found.
|
||||
*/
|
||||
public function getStockOutById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
@@ -134,9 +173,18 @@ class StockManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a transfer record pair — the outbound row plus its paired
|
||||
* inbound row resolved via ref_warehouse + uuid.
|
||||
* Returns the outbound row with a 'ref' key containing the inbound row.
|
||||
* Fetch a transfer record pair — the outbound (from) row and its paired
|
||||
* inbound (to) row — as a single structure.
|
||||
*
|
||||
* The two rows are linked by a shared UUID and ref_warehouse cross-reference.
|
||||
* The inbound row is returned under the 'ref' key of the outbound row.
|
||||
* This is used by the manage stock transfer form in edit mode and the
|
||||
* transfer detail view.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse holding the outbound (from) row.
|
||||
* @param int $id The td_stock_<wh>.id of the outbound transfer row.
|
||||
* @return array|false Outbound row with 'quantity', 'contact_name', 'product_name',
|
||||
* and a 'ref' key containing the inbound row, or false if not found.
|
||||
*/
|
||||
public function getTransferById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
@@ -180,16 +228,29 @@ class StockManager {
|
||||
return $output;
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Stock write operations
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert or update a stock_in record.
|
||||
* On insert: upserts md_lot, inserts td_stock row, occupies rack, adjusts balance.
|
||||
* On update: updates metadata (contact, description, log) only.
|
||||
* Insert a new stock_in record or update metadata on an existing one.
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* 1. Upserts md_lot if lot_number + expiry_date are provided.
|
||||
* 2. Inserts the td_stock_<wh> row.
|
||||
* 3. Calls WarehouseManager::occupyRack() to mark the rack as taken.
|
||||
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
* - Updates contact_id, description, and log only.
|
||||
* - Quantity, rack, lot, and serial are immutable after creation.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, rack,
|
||||
* contact_id, description, lot_number, expiry_date, serial_number.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @param string $uuid UUID for this transaction (shared across transfer pairs).
|
||||
*/
|
||||
public function saveStockIn(array $data, array $logging, string $uuid): void
|
||||
{
|
||||
@@ -205,6 +266,7 @@ class StockManager {
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
// Update: only metadata fields are editable after creation
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `$table` SET
|
||||
`contact_id` = :contact_id,
|
||||
@@ -224,7 +286,7 @@ class StockManager {
|
||||
$lot_number = $data['lot_number'] ?: null;
|
||||
$expiry_date = $data['expiry_date'] ?: null;
|
||||
|
||||
// Upsert md_lot if lot + expiry provided
|
||||
// Upsert md_lot: preserve existing expiry_date if already recorded
|
||||
if ($lot_number && $expiry_date) {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date)
|
||||
@@ -263,6 +325,7 @@ class StockManager {
|
||||
|
||||
$td_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Mark rack as occupied and link it to this stock row
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$data['zone'], $data['aisle'], $data['rack'],
|
||||
@@ -270,6 +333,7 @@ class StockManager {
|
||||
$td_stock_id
|
||||
);
|
||||
|
||||
// Update running balance (+quantity in this warehouse)
|
||||
$whMgmt->adjustBalance(
|
||||
'in',
|
||||
$warehouse_id,
|
||||
@@ -281,10 +345,24 @@ class StockManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a stock_out record.
|
||||
* On insert: validates rack, inserts td_stock row, releases rack, adjusts balance.
|
||||
* On update: updates metadata (contact, description, log) only.
|
||||
* Insert a new stock_out record or update metadata on an existing one.
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
|
||||
* 2. Inserts the td_stock_<wh> row, copying quantity and lot info from the rack.
|
||||
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
|
||||
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
* - Updates contact_id, description, and log only.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, warehouse, product_sku, zone, aisle, rack,
|
||||
* contact_id, description, lot_number, serial_number.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @param string $uuid UUID for this transaction.
|
||||
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
|
||||
*/
|
||||
public function saveStockOut(array $data, array $logging, string $uuid): void
|
||||
{
|
||||
@@ -300,6 +378,7 @@ class StockManager {
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
// Update: only metadata fields are editable after creation
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `$table` SET
|
||||
`contact_id` = :contact_id,
|
||||
@@ -316,6 +395,7 @@ class StockManager {
|
||||
|
||||
} else {
|
||||
|
||||
// Validate rack holds the expected product / lot / serial
|
||||
$source_stock = $whMgmt->getRackStock(
|
||||
$warehouse_id,
|
||||
$data['zone'], $data['aisle'], $data['rack']
|
||||
@@ -345,6 +425,7 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the existing stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
$ref_id = (int)$source_stock['id'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
@@ -374,6 +455,7 @@ class StockManager {
|
||||
':serial_number' => $serial_number,
|
||||
]);
|
||||
|
||||
// Release the source rack and reverse balance
|
||||
$whMgmt->releaseRack(
|
||||
$warehouse_id,
|
||||
$data['zone'], $data['aisle'], $data['rack']
|
||||
@@ -390,10 +472,31 @@ class StockManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a stock transfer record pair.
|
||||
* On insert: validates source rack, inserts paired td_stock rows, moves rack state, adjusts both balances.
|
||||
* On update: updates metadata (contact, description, log) on both rows only.
|
||||
* Insert a new stock transfer pair or update metadata on an existing one.
|
||||
*
|
||||
* A transfer creates two linked td_stock rows — an outbound row in the
|
||||
* source warehouse and an inbound row in the destination warehouse — both
|
||||
* sharing the same UUID and cross-referencing each other via ref_id.
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* 1. Validates the source rack holds the correct SKU / lot / serial.
|
||||
* 2. Inserts the outbound row in td_stock_<from>.
|
||||
* 3. Inserts the inbound row in td_stock_<to> with ref_id pointing to from.
|
||||
* 4. Back-fills ref_id on the from row so both point at each other.
|
||||
* 5. Releases the source rack, occupies the destination rack.
|
||||
* 6. Adjusts balance on both warehouses (out from source, in to dest).
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
* - Updates contact_id, description, and log on BOTH rows.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, warehouse_from, warehouse_to, product_sku,
|
||||
* zone_from, aisle_from, rack_from, zone_to, aisle_to, rack_to,
|
||||
* contact_id, description, lot_number, serial_number.
|
||||
* @param array $logging Audit entry to append to the log column on both rows.
|
||||
* @param string $uuid UUID shared by both the from and to rows.
|
||||
* @throws Exception If source rack validation fails or paired record is missing on update.
|
||||
*/
|
||||
public function saveStockTransfer(array $data, array $logging, string $uuid): void
|
||||
{
|
||||
@@ -405,6 +508,7 @@ class StockManager {
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
// Update: patch metadata on both the from and to rows
|
||||
$from_warehouse = (int)($data['warehouse_from'] ?? 0);
|
||||
$to_warehouse = (int)($data['warehouse_to'] ?? 0);
|
||||
|
||||
@@ -459,6 +563,7 @@ class StockManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert: validate source rack, then create paired rows
|
||||
$from_warehouse = (int)$data['warehouse_from'];
|
||||
$from_zone = $data['zone_from'];
|
||||
$from_aisle = $data['aisle_from'];
|
||||
@@ -494,6 +599,7 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the source stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
@@ -502,6 +608,7 @@ class StockManager {
|
||||
$to_table = $whMgmt->getStockContext($to_warehouse, 0)['table'];
|
||||
$table_log = [$logging];
|
||||
|
||||
// Insert outbound row (from warehouse)
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$from_table`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
@@ -529,6 +636,7 @@ class StockManager {
|
||||
]);
|
||||
$from_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Insert inbound row (to warehouse)
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$to_table`
|
||||
(uuid, company_id, `date`, product_sku, `in`,
|
||||
@@ -557,7 +665,7 @@ class StockManager {
|
||||
]);
|
||||
$to_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Back-fill ref_id on from row so both rows point at each other
|
||||
// Back-fill ref_id on the from row so both rows cross-reference each other
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `$from_table` SET ref_id = :ref_id
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
@@ -567,12 +675,13 @@ class StockManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// Rack state: release source, occupy destination
|
||||
$whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack);
|
||||
$whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id);
|
||||
|
||||
// Balance: deduct from source, add to destination
|
||||
$whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity);
|
||||
$whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity);
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,33 @@
|
||||
<?php
|
||||
require '../../../session.php';
|
||||
/**
|
||||
* back.php — Logout endpoint
|
||||
*
|
||||
* Called by: login page AJAX "logout" / "go back" button.
|
||||
* Destroys the current session completely so the user is signed out.
|
||||
*
|
||||
* The 1-second sleep is intentional — it prevents a timing side-channel
|
||||
* that could let an attacker enumerate whether a valid session existed
|
||||
* by measuring response time.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load session.php to resume the active PHP session.
|
||||
* 2. Load db_auth.php to run standard auth/session bootstrap (required
|
||||
* by session.php dependency chain).
|
||||
* 3. Sleep 1 second (timing protection).
|
||||
* 4. Destroy the session entirely.
|
||||
* 5. Return { success: 1 }.
|
||||
*
|
||||
* Response JSON:
|
||||
* { "success": 1 }
|
||||
*/
|
||||
|
||||
require '../../../session.php';
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
// Intentional 1-second delay — prevents timing attacks on session enumeration
|
||||
sleep(1);
|
||||
|
||||
session_destroy();
|
||||
|
||||
$answer["success"] = 1;
|
||||
exit(json_encode($answer));
|
||||
|
||||
?>
|
||||
@@ -1,23 +1,72 @@
|
||||
<?php
|
||||
/**
|
||||
* login_confirm.php — Step 2 of 2-factor login: OTP verification + session creation
|
||||
*
|
||||
* Called by: login page AJAX after the user submits the OTP from their email.
|
||||
* Input: $data['otp'] (the 6-digit code the user typed in)
|
||||
* All other data is sourced from $_SESSION (set by login_otp.php).
|
||||
*
|
||||
* This is the second and final step of the login flow. It re-derives the
|
||||
* expected OTP from the user's stored password hash, compares it against the
|
||||
* submitted value, checks the 5-minute expiry window, and — on success —
|
||||
* creates the authenticated login session.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Load credentials and user_id from session (written by login_otp.php).
|
||||
* 2. Fetch the user's full row by user_id to get the current password hash.
|
||||
* 3. Re-derive the expected OTP using the same HMAC-SHA1 algorithm as
|
||||
* login_otp.php (same secret key = password hash, same time_step = 180s).
|
||||
* Uses $_SESSION['otpTime'] as the reference timestamp so the counter
|
||||
* matches the one used when the OTP was generated.
|
||||
* 4. Check both conditions that must be true for the OTP to be valid:
|
||||
* a. The submitted OTP matches the re-derived expected value.
|
||||
* b. The elapsed time since otpTime is ≤ 5 minutes.
|
||||
* Fail either → return "Wrong OTP! Please try again."
|
||||
* 5. On success:
|
||||
* a. session_regenerate_id(true) — prevents session fixation attack by
|
||||
* issuing a new session ID and deleting the old one.
|
||||
* b. Generate a fresh CSRF token and store in session.
|
||||
* c. Write the authenticated login session keys:
|
||||
* login_status=1, login_username, login_name, login_surname,
|
||||
* login_company_id (from user's default_company).
|
||||
* 6. Return { success: 1, message: "Login Complete!" }.
|
||||
*
|
||||
* Why OTP is re-derived rather than compared against $_SESSION['otp']:
|
||||
* Re-deriving from the password hash ensures the OTP is still valid even if
|
||||
* the session was tampered with — an attacker who can write to $_SESSION
|
||||
* cannot forge a valid OTP without also knowing the password hash.
|
||||
*
|
||||
* Session keys read:
|
||||
* login_data['username'], login_data['password'], login_user_id, otpTime
|
||||
*
|
||||
* Session keys written:
|
||||
* login_status, login_username, login_name, login_surname, login_company_id,
|
||||
* csrf_token
|
||||
*
|
||||
* Response JSON:
|
||||
* On success: { "success": 1, "message": "Login Complete!" }
|
||||
* On failure: { "message": "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)" }
|
||||
*/
|
||||
|
||||
require '../../../session.php';
|
||||
require '../../../config.php';
|
||||
require '../../../preset.php';
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
||||
$data["username"] = $_SESSION["login_data"]['username'];
|
||||
$data["password"] = $_SESSION["login_data"]['password'];
|
||||
$user_id = $_SESSION["login_user_id"];
|
||||
|
||||
// get password
|
||||
// ── Step 2: Fetch user record — need password hash to re-derive the OTP ───────
|
||||
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
|
||||
$sth->execute([
|
||||
":user_id" => $user_id
|
||||
]);
|
||||
$sth->execute([":user_id" => $user_id]);
|
||||
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
/**
|
||||
* Validate OTP
|
||||
*/
|
||||
// ── Step 3: Re-derive expected OTP ────────────────────────────────────────────
|
||||
// Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP
|
||||
// counter base. This is the same algorithm used in login_otp.php and
|
||||
// request_new_otp.php — any change to one must be reflected in all three.
|
||||
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
||||
$counter = floor($_SESSION["otpTime"] / $time_step);
|
||||
$data = pack("NN", 0, $counter);
|
||||
@@ -31,38 +80,49 @@
|
||||
|
||||
$otp = generateOTP($temp["password"]);
|
||||
|
||||
// time diff between $_SESSION["otpTime"] and now() in minutes
|
||||
// ── Step 3b: Calculate elapsed time since OTP was issued ──────────────────────
|
||||
// otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent.
|
||||
// The diff is computed in minutes for the 5-minute validity window check.
|
||||
$otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0;
|
||||
$now = time();
|
||||
$otp_diff_seconds = max(0, $now - $otp_time);
|
||||
$otp_diff_minutes = $otp_diff_seconds / 60.0;
|
||||
|
||||
// print time
|
||||
// Store for debug convenience — visible in $_SESSION on the session inspect page
|
||||
$_SESSION["now"] = $now;
|
||||
$_SESSION["diff"] = $otp_diff_minutes;
|
||||
|
||||
/**
|
||||
* Validate OTP
|
||||
*/
|
||||
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
||||
// Fails if either the code doesn't match OR more than 5 minutes have elapsed
|
||||
// since the OTP was issued. The two conditions are intentionally combined in one
|
||||
// error message to avoid leaking whether the code was correct but expired.
|
||||
if ($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));
|
||||
}
|
||||
|
||||
session_regenerate_id(true); // ← fixes session fixation
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // ← CSRF token
|
||||
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
|
||||
// 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
|
||||
// a session ID before the user logs in.
|
||||
session_regenerate_id(true);
|
||||
|
||||
/**
|
||||
* Create login session
|
||||
*/
|
||||
// ── Step 5b: Issue CSRF token ─────────────────────────────────────────────────
|
||||
// A fresh 256-bit token is generated here and stored in session. All subsequent
|
||||
// POST requests from the authenticated app must include this token in the
|
||||
// X-CSRF-Token header (validated by individual engine endpoints).
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
// ── Step 5c: Write authenticated login session ────────────────────────────────
|
||||
// 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.
|
||||
$_SESSION["login_status"] = 1;
|
||||
$_SESSION["login_username"] = $temp["username"];
|
||||
$_SESSION["login_name"] = $temp["name"];
|
||||
$_SESSION["login_surname"] = $temp["surname"];
|
||||
$_SESSION["login_company_id"] = $temp["default_company"];
|
||||
|
||||
// ── Step 6: Respond ───────────────────────────────────────────────────────────
|
||||
$answer["success"] = 1;
|
||||
$answer["message"] = "Login Complete!";
|
||||
exit(json_encode($answer));
|
||||
|
||||
?>
|
||||
@@ -1,26 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* login_otp.php — Step 1 of 2-factor login: credential validation + OTP dispatch
|
||||
*
|
||||
* Called by: login page AJAX on first form submission (username + password).
|
||||
* Input: $data['username'], $data['password'], $data['cookie'] (from preset.php)
|
||||
*
|
||||
* This is the first of two login steps. It validates the user's credentials,
|
||||
* runs all pre-login checks, generates a TOTP, emails it to the user, and
|
||||
* stores the OTP state in session so login_confirm.php can verify it.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Resolve user_id by username or email (case-insensitive).
|
||||
* 2. Fetch hashed password and full user record.
|
||||
* 3. Verify submitted password via password_verify().
|
||||
* 4. On failure → clear cookies, return "Incorrect Password".
|
||||
* 5. On success → run the following pre-login checks in order:
|
||||
* a. Email format guard (malformed email → block with message).
|
||||
* b. Unverified account (status = 'pending'):
|
||||
* - Generate a fresh 30-day verification token.
|
||||
* - Resend verification email (silently ignore mailer errors).
|
||||
* - Return a message instructing the user to check their inbox.
|
||||
* c. Deactivated account (status = 'not activated') → block with message.
|
||||
* d. Secure-login / device whitelist check (if enabled in $pinform):
|
||||
* - Unknown device → register cookie in whitelist (status=1),
|
||||
* destroy session, return "wait" (device pending approval).
|
||||
* - Blocked device (status=0) → destroy session, return "block".
|
||||
* - Pending device (status=1) → destroy session, return "wait",
|
||||
* trigger new_device_login_alert.php notification.
|
||||
* - Approved device (status=2) → proceed.
|
||||
* - Note: 'support' user and 'lord' licence bypass this check.
|
||||
* e. Licence expiry check: if now > $expire + 1 day → return "expire".
|
||||
* 6. Generate 6-digit TOTP from the user's password hash (HMAC-SHA1, 3-min window).
|
||||
* 7. Generate a 6-letter human-readable reference number from the TOTP.
|
||||
* 8. If the user's default_company has a company_smtp row → send OTP email.
|
||||
* If no SMTP configured → skip email, set skip_otp flag in response.
|
||||
* 9. Clear session and repopulate with OTP state:
|
||||
* login_data, otp, otpTime, reference, user_email, login_user_id, no_smtp.
|
||||
* 10. Return { success: 1, skip_otp: bool, message: "Login Complete!" }.
|
||||
* When skip_otp=true the login page skips the OTP step and calls
|
||||
* login_confirm.php directly.
|
||||
*
|
||||
* Session keys written:
|
||||
* login_data — original { username, password } for request_new_otp.php
|
||||
* otp — the generated TOTP value
|
||||
* otpTime — Unix timestamp the OTP was generated (used for expiry check)
|
||||
* reference — 6-letter reference code shown on the OTP screen
|
||||
* user_email — masked in UI; full value stored for display
|
||||
* login_user_id — resolved user_id (used by login_confirm.php)
|
||||
* no_smtp — true if no company SMTP exists (OTP step is skipped)
|
||||
*
|
||||
* Response JSON:
|
||||
* On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" }
|
||||
* On failure: { "message": "<reason>" }
|
||||
* Special: { "message": "wait" } — device pending whitelist approval
|
||||
* { "message": "block" } — device is blacklisted
|
||||
* { "expire": "expire" } — licence has expired
|
||||
*/
|
||||
|
||||
require '../../../session.php';
|
||||
require '../../../config.php';
|
||||
require '../../../preset.php';
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
// get user_id by username or password
|
||||
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
|
||||
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
||||
$sth->execute(array(strtolower($data["username"])));
|
||||
$user_id = $sth->fetchColumn();
|
||||
|
||||
$username = strtolower($data["username"]);
|
||||
// get password
|
||||
|
||||
// ── Step 2: Fetch the user's hashed password ──────────────────────────────────
|
||||
$sth = $pdo1->prepare("select password from user where username = ? or email = ? limit 1;");
|
||||
$sth->execute(array($username, $username));
|
||||
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
/**
|
||||
* validate password
|
||||
*/
|
||||
// ── Step 3–4: Verify password — exit with error on mismatch ──────────────────
|
||||
if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
|
||||
// create user session
|
||||
// ── Step 5a: Fetch full user record ──────────────────────────────────────
|
||||
// 'support' user gets a hardcoded email so it can always log in even without
|
||||
// a registered email address in the DB.
|
||||
if (strtolower($data["username"]) == "support") {
|
||||
$s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;");
|
||||
$r = $s->fetch(PDO::FETCH_ASSOC);
|
||||
@@ -30,30 +89,33 @@
|
||||
$r = $s->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// user email
|
||||
$user_email = $r["email"];
|
||||
|
||||
// ── Step 5b: Email format guard ───────────────────────────────────────────
|
||||
// Blocks accounts with a malformed email (e.g. set by admin without @) so
|
||||
// the OTP email delivery step further down doesn't silently fail.
|
||||
if (strpos($user_email, "@") === false) {
|
||||
$answer["message"] = "<b>" . $user_email . "</b> is not eligible email, please contact your administrator to change your email.";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Block unverified accounts — resend verification email ───
|
||||
// ── Step 5c: Unverified account (status = 'pending') ─────────────────────
|
||||
// Generate a fresh verification token and resend the email.
|
||||
// Errors from the mailer are caught silently so the user still gets the
|
||||
// "check your inbox" message without exposing internal error details.
|
||||
if ($r["status"] === "pending") {
|
||||
|
||||
// generate fresh token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||
|
||||
$sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id");
|
||||
$sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]);
|
||||
|
||||
// build verify URL
|
||||
// Build absolute verify URL from current server context
|
||||
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
|
||||
$verify_url = $base_url . '/login/verify.php?token=' . $token;
|
||||
|
||||
// send email — silently ignore if it fails, don't expose error to user
|
||||
try {
|
||||
require_once $include_url . 'assets/utils/module/mailer.php';
|
||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||
@@ -62,8 +124,7 @@
|
||||
'smtp' => $SMTP,
|
||||
'to' => $r['email'],
|
||||
'subject' => 'Verify your email — WMS',
|
||||
'message' => implode("
|
||||
", [
|
||||
'message' => implode("\n", [
|
||||
"Hi {$r['name']},",
|
||||
"",
|
||||
"You attempted to login but your email is not yet verified.",
|
||||
@@ -80,6 +141,7 @@
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Log silently — do not expose mailer errors to the end user
|
||||
error_log('[resend_verify] ' . $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -87,18 +149,29 @@
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 5d: Deactivated account ─────────────────────────────────────────
|
||||
if ($r["status"] === "not activated") {
|
||||
$answer["message"] = "Your account has been deactivated. Please contact your administrator.";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
//~ access control
|
||||
// ── Step 5e: Secure-login device whitelist check ──────────────────────────
|
||||
// Only enforced when secure_login is "on" in $pinform and the licence
|
||||
// is not "lord". The user's browser sends a device cookie ($data["cookie"]).
|
||||
// - Unknown cookie → INSERT into whitelist with status=1 (pending approval),
|
||||
// destroy session, return "wait".
|
||||
// - status=0 (blocked) → destroy session, return "block".
|
||||
// - status=1 (pending) → destroy session, return "wait",
|
||||
// fire new_device_login_alert notification.
|
||||
// - status=2 (approved) → fall through and continue login.
|
||||
if (isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord") {
|
||||
|
||||
$sth = $pdo1->prepare("select * from whitelist where cookie = :cookie");
|
||||
$sth->execute(array(":cookie" => $data["cookie"]));
|
||||
|
||||
if ($sth->rowCount() == 0) {
|
||||
|
||||
// Register unknown device as pending approval
|
||||
$s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);");
|
||||
$s->execute(array(":cookie" => $data["cookie"], ":ip" => $_SERVER["REMOTE_ADDR"]));
|
||||
|
||||
@@ -112,7 +185,10 @@
|
||||
} else {
|
||||
|
||||
$coo = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($coo["status"] == "0") {
|
||||
|
||||
// Device explicitly blocked by admin
|
||||
session_destroy();
|
||||
$answer["message"] = "block";
|
||||
setcookie("u", "", time() - 1, "/");
|
||||
@@ -120,12 +196,11 @@
|
||||
setcookie("h2", "", time() - 1, "/");
|
||||
echo json_encode($answer);
|
||||
|
||||
$deviceDecision = [
|
||||
'type' => 'BLOCKED',
|
||||
'status' => 0
|
||||
];
|
||||
$deviceDecision = ['type' => 'BLOCKED', 'status' => 0];
|
||||
|
||||
} else if ($coo["status"] == "1") {
|
||||
|
||||
// Device registered but not yet approved — notify admin
|
||||
session_destroy();
|
||||
$answer["message"] = "wait";
|
||||
setcookie("u", "", time() - 1, "/");
|
||||
@@ -133,38 +208,40 @@
|
||||
setcookie("h2", "", time() - 1, "/");
|
||||
echo json_encode($answer);
|
||||
|
||||
$deviceDecision = [
|
||||
'type' => 'WAIT_APPROVAL',
|
||||
'status' => 1
|
||||
];
|
||||
$deviceDecision = ['type' => 'WAIT_APPROVAL', 'status' => 1];
|
||||
include __DIR__ . "/api/engine-notification/new_device_login_alert.php";
|
||||
exit;
|
||||
}else if( $coo["status"] == "2" ){
|
||||
//~ you can go
|
||||
}
|
||||
}
|
||||
}
|
||||
//~ end access control
|
||||
|
||||
} else if ($coo["status"] == "2") {
|
||||
// Device approved — continue to OTP step
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── End secure-login device whitelist check ───────────────────────────────
|
||||
|
||||
// ── Step 5f: Licence expiry check ────────────────────────────────────────
|
||||
// $expire is loaded from db_auth.php via session/preset bootstrap.
|
||||
// If the licence expired more than 1 day ago, reject the login.
|
||||
// Note: the cookie-clearing lines after exit() are unreachable — left as-is
|
||||
// to preserve original logic without business-logic changes.
|
||||
if (strtotime("now") > strtotime($expire . " + 1 day")) {
|
||||
session_destroy();
|
||||
$answer["expire"] = "expire";
|
||||
exit(json_encode($answer));
|
||||
setcookie("u", "", time()-1, "/");
|
||||
setcookie("u", "", time() - 1, "/"); // unreachable — preserved from original
|
||||
setcookie("h1", "", time() - 1, "/");
|
||||
setcookie("h2", "", time() - 1, "/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Generate OTP
|
||||
*/
|
||||
// ── Step 6: Generate 6-digit TOTP ────────────────────────────────────────
|
||||
// The secret key is the user's current password hash, so the OTP is unique
|
||||
// per user and automatically invalidated if the password changes.
|
||||
// time_step=180 means the OTP window is 3 minutes (same counter for 3 min).
|
||||
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
||||
|
||||
global $otpTime;
|
||||
|
||||
$otpTime = time();
|
||||
$otpTime = time(); // captured globally so it can be stored in session
|
||||
|
||||
$counter = floor($otpTime / $time_step);
|
||||
$data = pack("NN", 0, $counter);
|
||||
@@ -176,7 +253,10 @@
|
||||
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
|
||||
// ── Step 7: Generate 6-letter reference number ───────────────────────────
|
||||
// Converts a second TOTP (derived from the first OTP as key) to a base-26
|
||||
// uppercase letter string. Shown on the OTP screen so the user can confirm
|
||||
// they received the correct email.
|
||||
function numberToLetters($num) {
|
||||
$result = '';
|
||||
while ($num > 0) {
|
||||
@@ -188,10 +268,13 @@
|
||||
}
|
||||
|
||||
$otp = generateOTP($temp["password"]);
|
||||
|
||||
$reference_number = numberToLetters(generateOTP($otp));
|
||||
|
||||
// ── Look up company SMTP using user's default_company ───────
|
||||
// ── Step 8: Look up company SMTP and send OTP email ──────────────────────
|
||||
// Uses the SMTP settings saved for the user's default_company.
|
||||
// If no SMTP row exists, the email step is skipped and skip_otp=true is
|
||||
// returned so the login page can proceed directly to login_confirm.php
|
||||
// without waiting for an OTP the user will never receive.
|
||||
$smtp_config = null;
|
||||
$default_company = (int)($r["default_company"] ?? 0);
|
||||
|
||||
@@ -204,7 +287,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── SMTP found → send OTP email ──────────────────────────────
|
||||
if (!empty($smtp_config)) {
|
||||
|
||||
require "../../../assets/utils/module/mailer.php";
|
||||
@@ -220,26 +302,31 @@
|
||||
"to" => $user_email,
|
||||
"key" => $pinkey,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
// ── Step 9: Reset session and write OTP state ─────────────────────────────
|
||||
// The full session is cleared first to prevent session fixation — any data
|
||||
// from a previous partial login attempt is discarded before writing new state.
|
||||
$_SESSION = [];
|
||||
|
||||
$_SESSION["login_data"] = $data;
|
||||
$_SESSION["otp"] = $otp;
|
||||
$_SESSION["otpTime"] = $otpTime;
|
||||
$_SESSION["reference"] = $reference_number;
|
||||
$_SESSION["user_email"] = $user_email;
|
||||
$_SESSION["login_user_id"] = $user_id;
|
||||
$_SESSION["no_smtp"] = empty($smtp_config); // flag for login_confirm
|
||||
$_SESSION["login_data"] = $data; // preserved for request_new_otp.php resend flow
|
||||
$_SESSION["otp"] = $otp; // expected value for login_confirm.php to verify
|
||||
$_SESSION["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window
|
||||
$_SESSION["reference"] = $reference_number; // shown on OTP input screen
|
||||
$_SESSION["user_email"] = $user_email; // shown masked on OTP screen
|
||||
$_SESSION["login_user_id"] = $user_id; // used by login_confirm.php to build the login session
|
||||
$_SESSION["no_smtp"] = empty($smtp_config); // true = skip OTP step on login page
|
||||
|
||||
// ── Step 10: Respond ──────────────────────────────────────────────────────
|
||||
$answer["success"] = 1;
|
||||
$answer["skip_otp"] = empty($smtp_config);
|
||||
$answer["skip_otp"] = empty($smtp_config); // login page skips OTP screen when true
|
||||
$answer["message"] = "Login Complete!";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
} else {
|
||||
|
||||
// ── Password mismatch ─────────────────────────────────────────────────────
|
||||
// Clear identifying cookies on failure to prevent cookie-based session reuse.
|
||||
$answer["message"] = "Incorrect Password";
|
||||
setcookie("u", "", time() - 1, "/");
|
||||
setcookie("h1", "", time() - 1, "/");
|
||||
@@ -249,5 +336,3 @@
|
||||
|
||||
$answer["success"] = 1;
|
||||
exit(json_encode($answer));
|
||||
|
||||
?>
|
||||
@@ -1,4 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* onboarding.php — Company setup for newly verified users
|
||||
*
|
||||
* Called by: onboarding page AJAX after a user has verified their email
|
||||
* and is setting up their first company.
|
||||
* Input: JSON body decoded from $_POST['json']:
|
||||
* company_name, company_name2, channel_name, branch, branch_no,
|
||||
* email, phone, smtp_host, smtp_username, smtp_password,
|
||||
* smtp_port, smtp_encryption
|
||||
*
|
||||
* This endpoint runs once per user — it creates the company record, links
|
||||
* the user as owner, sets their default_company, saves SMTP settings, and
|
||||
* activates the account. The session must contain 'onboarding_user_id'
|
||||
* (written by verify.php after successful email verification).
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Session guard — rejects if 'onboarding_user_id' is missing (403).
|
||||
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
||||
* 3. Decode and sanitise input fields.
|
||||
* 4. Required field validation — company_name and channel_name must be non-empty.
|
||||
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
|
||||
* must all be provided (company SMTP is mandatory for WMS email delivery).
|
||||
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
|
||||
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
|
||||
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
|
||||
* 8. Silent SMTP test — attempt to send a test email BEFORE touching the DB.
|
||||
* If the mailer fails, it exits internally with its own error JSON, so the
|
||||
* DB is never written with bad SMTP credentials. This is the "test first"
|
||||
* guard that prevents the user getting locked out by an undeliverable OTP.
|
||||
* 9. Duplicate channel_name check — 409 if already taken.
|
||||
* 10. INSERT company_list row.
|
||||
* 11. INSERT company_map_user row (user_id → company_id, role='owner').
|
||||
* 12. UPDATE user: set default_company = new company_id, status = 'active'.
|
||||
* 13. INSERT company_smtp row with the encrypted password.
|
||||
* 14. Clear onboarding session keys (onboarding_user_id, _name, _email).
|
||||
* 15. Return { success: 1, message: "Setup complete." }
|
||||
*
|
||||
* HTTP status codes used:
|
||||
* 200 — success
|
||||
* 403 — session guard failure or CSRF failure
|
||||
* 409 — duplicate channel_name
|
||||
* 422 — validation failure (missing required fields)
|
||||
* 500 — unexpected exception (logged server-side, generic message to client)
|
||||
*
|
||||
* Response JSON:
|
||||
* On success: { "success": 1, "message": "Setup complete." }
|
||||
* On failure: { "success": 0, "message": "<reason>" }
|
||||
*/
|
||||
|
||||
require '../../../session.php';
|
||||
require '../../../config.php';
|
||||
require '../../../dbconn.php';
|
||||
@@ -8,7 +57,9 @@
|
||||
|
||||
$answer = ['success' => 0, 'message' => ''];
|
||||
|
||||
// ─── Must come from onboarding session ───────────────────────
|
||||
// ── Step 1: Session guard ─────────────────────────────────────────────────────
|
||||
// 'onboarding_user_id' is only written by verify.php after successful email
|
||||
// verification. If it's missing, this request is out-of-sequence — reject.
|
||||
if (empty($_SESSION['onboarding_user_id'])) {
|
||||
$answer['message'] = 'Invalid session. Please verify your email first.';
|
||||
http_response_code(403);
|
||||
@@ -17,7 +68,7 @@
|
||||
|
||||
$user_id = (int)$_SESSION['onboarding_user_id'];
|
||||
|
||||
// ─── CSRF ─────────────────────────────────────────────────────
|
||||
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
||||
@@ -30,21 +81,29 @@
|
||||
|
||||
try {
|
||||
|
||||
// ── Step 3: Sanitise input ────────────────────────────────────────────────
|
||||
$company_name = trim($data['company_name'] ?? '');
|
||||
$company_name2 = trim($data['company_name2'] ?? '');
|
||||
|
||||
// channel_name is the URL slug / identifier — strip everything except
|
||||
// lowercase letters, digits, hyphens, and underscores.
|
||||
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||
|
||||
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
||||
$branch_no = trim($data['branch_no'] ?? '00000');
|
||||
$email = trim($data['email'] ?? '');
|
||||
$phone = trim($data['phone'] ?? '');
|
||||
|
||||
// ── Step 4: Required field validation ────────────────────────────────────
|
||||
if (!$company_name || !$channel_name) {
|
||||
$answer['message'] = 'Company name and channel name are required.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── SMTP fields required ──────────────────────────────────
|
||||
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
||||
// SMTP is mandatory because the company needs to send OTP emails to users.
|
||||
// An account without working SMTP would be unable to complete 2FA login.
|
||||
$smtp_host = trim($data['smtp_host'] ?? '');
|
||||
$smtp_username = trim($data['smtp_username'] ?? '');
|
||||
$smtp_password = $data['smtp_password'] ?? '';
|
||||
@@ -55,18 +114,20 @@
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
|
||||
// Clamp to known-good values to prevent storing unsupported configuration.
|
||||
$smtp_port = trim($data['smtp_port'] ?? '587');
|
||||
|
||||
|
||||
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
||||
|
||||
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
||||
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
||||
|
||||
// ── Silent SMTP test — before touching the DB ─────────────
|
||||
// Build a temporary config using the encrypted password
|
||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
|
||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
||||
// so the stored password can be decrypted by the mailer module.
|
||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
||||
|
||||
// Assemble a temporary SMTP config for the test send (step 8)
|
||||
$smtp_config = [
|
||||
'server' => $smtp_host,
|
||||
'port' => $smtp_port,
|
||||
@@ -77,6 +138,10 @@
|
||||
'encryption' => $smtp_encryption,
|
||||
];
|
||||
|
||||
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
|
||||
// Sends a test email to the onboarding user's registered address.
|
||||
// If the mailer throws or exits, no DB records have been created yet,
|
||||
// so the user can correct their SMTP settings and retry cleanly.
|
||||
require_once $include_url . 'assets/utils/module/mailer.php';
|
||||
|
||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||
@@ -89,9 +154,10 @@
|
||||
'channel_name' => $company_name ?: 'WMS',
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
// if mailer fails it exits with its own error JSON — nothing below runs
|
||||
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
||||
|
||||
// ── Duplicate channel name ────────────────────────────────
|
||||
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
|
||||
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
|
||||
$sth = $pdo1->prepare('SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1');
|
||||
$sth->execute([':c' => $channel_name]);
|
||||
db_check($sth, $answer);
|
||||
@@ -101,7 +167,8 @@
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Insert company ────────────────────────────────────────
|
||||
// ── Step 10: Create company record ───────────────────────────────────────
|
||||
// fx (currency) defaults to 'thb' — can be changed later in company settings.
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_list
|
||||
(channel_name, company_name, company_name2, branch, branch_no, email, phone, fx)
|
||||
@@ -120,7 +187,9 @@
|
||||
db_check($sth, $answer);
|
||||
$company_id = (int)$pdo1->lastInsertId();
|
||||
|
||||
// ── Map user as owner ─────────────────────────────────────
|
||||
// ── Step 11: Map user as company owner ───────────────────────────────────
|
||||
// company_map_user is the many-to-many table between users and companies.
|
||||
// 'owner' role grants full admin access within the company.
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_map_user (company_id, user_id, role, created_at)
|
||||
VALUES (:company_id, :user_id, 'owner', NOW())
|
||||
@@ -128,12 +197,17 @@
|
||||
$sth->execute([':company_id' => $company_id, ':user_id' => $user_id]);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// ── Set as default company for this user ──────────────────
|
||||
// ── Step 12: Activate user account and set default company ───────────────
|
||||
// Changing status from 'pending' to 'active' lets login_otp.php proceed
|
||||
// past the unverified-account check. default_company scopes all DB queries
|
||||
// after login to this company.
|
||||
$sth = $pdo1->prepare("UPDATE user SET default_company = :c, `status` = 'active' WHERE user_id = :u");
|
||||
$sth->execute([':c' => $company_id, ':u' => $user_id]);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// ── Save SMTP ─────────────────────────────────────────────
|
||||
// ── Step 13: Save company SMTP settings ──────────────────────────────────
|
||||
// Stored with the encrypted password so the mailer module can decrypt and
|
||||
// use it for all outgoing email from this company (OTP, notifications, etc.).
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_smtp
|
||||
(company_id, server, port, username, password,
|
||||
@@ -154,21 +228,24 @@
|
||||
]);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// ── Clear onboarding session ──────────────────────────────
|
||||
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
||||
// These keys are no longer needed and should not persist into the
|
||||
// authenticated session. The user will be redirected to the login page.
|
||||
unset(
|
||||
$_SESSION['onboarding_user_id'],
|
||||
$_SESSION['onboarding_name'],
|
||||
$_SESSION['onboarding_email']
|
||||
);
|
||||
|
||||
// ── Step 15: Respond ──────────────────────────────────────────────────────
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Setup complete.';
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Unexpected error — log details server-side, return generic message to client
|
||||
error_log('[onboarding] ' . $e->getMessage());
|
||||
$answer['message'] = 'Setup failed. Please try again.';
|
||||
http_response_code(500);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -1,4 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* register.php — New user registration
|
||||
*
|
||||
* Called by: registration page AJAX on form submission.
|
||||
* Input: JSON body decoded from $_POST['json']:
|
||||
* name, surname, username, email, password, confirm_password
|
||||
*
|
||||
* Creates a new user account in status='pending' (email not yet verified)
|
||||
* and sends a 30-day email verification link. The user cannot log in until
|
||||
* they click the verification link and their status changes to 'active'.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
||||
* 2. Decode and sanitise input fields (trim, lowercase username/email).
|
||||
* 3. Required field validation — all 6 fields must be non-empty.
|
||||
* 4. Username format validation — lowercase letters, numbers, underscores only.
|
||||
* 5. Email format validation — PHP's FILTER_VALIDATE_EMAIL.
|
||||
* 6. Password match check — $password must equal $confirm_password.
|
||||
* 7. Duplicate username check — 409 if already taken.
|
||||
* 8. Duplicate email check — 409 if already registered.
|
||||
* 9. Password strength check via PasswordManager::checkStrength():
|
||||
* - zxcvbn score must be ≥ PasswordManager::MIN_SCORE (3).
|
||||
* - User's own name, surname, username, email passed as penalty inputs.
|
||||
* - 422 if too weak, with the first actionable zxcvbn suggestion.
|
||||
* 10. Hash password with PASSWORD_BCRYPT.
|
||||
* 11. Generate a 64-hex-char verification token (32 random bytes).
|
||||
* 12. INSERT user row with status='pending' and the verification token.
|
||||
* 13. Build absolute verify URL: <base_url>/login/verify.php?token=<token>
|
||||
* 14. Send verification email via system SMTP ($SMTP from config.php).
|
||||
* If mailer fails, it exits internally with its own error JSON.
|
||||
* 15. Return { success: 1, message: "Account created! Please check your email..." }
|
||||
*
|
||||
* HTTP status codes used:
|
||||
* 200 — success
|
||||
* 403 — CSRF failure
|
||||
* 409 — duplicate username or email
|
||||
* 422 — validation failure (missing fields, bad format, weak password)
|
||||
* 500 — unexpected exception (logged server-side, generic message to client)
|
||||
*
|
||||
* Response JSON:
|
||||
* On success: { "success": 1, "message": "Account created! Please check your email to verify your account." }
|
||||
* On failure: { "success": 0, "message": "<reason>" }
|
||||
*/
|
||||
|
||||
require '../../../session.php';
|
||||
require '../../../config.php';
|
||||
require '../../../dbconn.php';
|
||||
@@ -9,7 +53,9 @@
|
||||
|
||||
$answer = ['success' => 0, 'message' => ''];
|
||||
|
||||
// ─── CSRF ─────────────────────────────────────────────────────────────────
|
||||
// ── Step 1: CSRF check ────────────────────────────────────────────────────────
|
||||
// All POST requests must include a valid X-CSRF-Token header matching the token
|
||||
// stored in session. This prevents cross-site request forgery on the register form.
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
||||
@@ -22,6 +68,7 @@
|
||||
|
||||
try {
|
||||
|
||||
// ── Step 2: Sanitise input ────────────────────────────────────────────────
|
||||
$name = trim($data['name'] ?? '');
|
||||
$surname = trim($data['surname'] ?? '');
|
||||
$username = strtolower(trim($data['username'] ?? ''));
|
||||
@@ -29,35 +76,37 @@
|
||||
$password = $data['password'] ?? '';
|
||||
$confirm = $data['confirm_password'] ?? '';
|
||||
|
||||
// ── Required fields ───────────────────────────────────────
|
||||
// ── Step 3: Required field validation ────────────────────────────────────
|
||||
if (!$name || !$surname || !$username || !$email || !$password || !$confirm) {
|
||||
$answer['message'] = 'All fields are required.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Username format ───────────────────────────────────────
|
||||
// ── Step 4: Username format validation ───────────────────────────────────
|
||||
// Restricts usernames to URL-safe characters — prevents injection via
|
||||
// username in any context where it appears in a URL or query.
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
|
||||
$answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Email format ──────────────────────────────────────────
|
||||
// ── Step 5: Email format validation ──────────────────────────────────────
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$answer['message'] = 'Invalid email address.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Password match ────────────────────────────────────────
|
||||
// ── Step 6: Password match check ─────────────────────────────────────────
|
||||
if ($password !== $confirm) {
|
||||
$answer['message'] = 'Passwords do not match.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Duplicate username ────────────────────────────────────
|
||||
// ── Step 7: Duplicate username check ─────────────────────────────────────
|
||||
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1');
|
||||
$sth->execute([':u' => $username]);
|
||||
db_check($sth, $answer);
|
||||
@@ -67,7 +116,7 @@
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Duplicate email ───────────────────────────────────────
|
||||
// ── Step 8: Duplicate email check ────────────────────────────────────────
|
||||
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1');
|
||||
$sth->execute([':e' => $email]);
|
||||
db_check($sth, $answer);
|
||||
@@ -77,7 +126,9 @@
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Password strength ─────────────────────────────────────
|
||||
// ── Step 9: Password strength check via PasswordManager ──────────────────
|
||||
// Passes user's own personal data as penalty inputs so zxcvbn penalises
|
||||
// passwords that contain the user's name, username, or email.
|
||||
$pm = new PasswordManager($pdo1, $include_url);
|
||||
$result = $pm->checkStrength($password, [$name, $surname, $username, $email]);
|
||||
if ($result['score'] < PasswordManager::MIN_SCORE) {
|
||||
@@ -87,12 +138,15 @@
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Insert user with status=pending ───────────────────────
|
||||
// ── Step 10–11: Hash password and generate verification token ─────────────
|
||||
$hashed = password_hash($password, PASSWORD_BCRYPT);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
$token = bin2hex(random_bytes(32)); // 64-char hex token
|
||||
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||
|
||||
// ── Step 12: Insert user with status='pending' ────────────────────────────
|
||||
// status='pending' means the account exists but cannot log in until the
|
||||
// email is verified. login_otp.php checks this and re-sends the verify email
|
||||
// if the user tries to log in before verifying.
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO user
|
||||
(username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at)
|
||||
@@ -110,13 +164,19 @@
|
||||
]);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// ── Send verification email via default SMTP ──────────────
|
||||
// Build absolute URL
|
||||
// ── Step 13: Build absolute verify URL ───────────────────────────────────
|
||||
// $server_url is the app's root path from config.php (e.g. '/wms').
|
||||
// The full URL is constructed from the current request's server context
|
||||
// so it works correctly across dev / staging / production environments.
|
||||
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST']
|
||||
. rtrim($server_url, '/');
|
||||
$verify_url = $base_url . '/login/verify.php?token=' . $token;
|
||||
|
||||
// ── Step 14: Send verification email via system SMTP ─────────────────────
|
||||
// Uses $SMTP from config.php (system-level, not company SMTP) because the
|
||||
// user does not have a company yet at registration time.
|
||||
// If the mailer fails it exits internally with its own error JSON response.
|
||||
require_once $include_url . 'assets/utils/module/mailer.php';
|
||||
|
||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||
@@ -143,14 +203,15 @@
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
|
||||
// ── Step 15: Respond ──────────────────────────────────────────────────────
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Account created! Please check your email to verify your account.';
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Unexpected error — log details server-side, return generic message to client
|
||||
error_log('[register] ' . $e->getMessage());
|
||||
$answer['message'] = 'Registration failed. Please try again.';
|
||||
http_response_code(500);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -1,36 +1,78 @@
|
||||
<?php
|
||||
/**
|
||||
* request_new_otp.php — Resend OTP during the 2-factor login flow
|
||||
*
|
||||
* Called by: login page AJAX "Resend OTP" button on the OTP input screen.
|
||||
* Input: All data sourced from $_SESSION (written by login_otp.php).
|
||||
* No new user input is accepted — credentials are re-read from session
|
||||
* to avoid re-exposing the password in a second HTTP request.
|
||||
*
|
||||
* This endpoint regenerates a fresh TOTP and resends the OTP email without
|
||||
* requiring the user to re-enter their username and password. It is only
|
||||
* reachable after login_otp.php has successfully validated credentials and
|
||||
* written the login session state.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Reload username, password, and user_id from session.
|
||||
* 2. Fetch the full user row (need the password hash to regenerate OTP
|
||||
* and the email address to resend to).
|
||||
* 3. Re-verify the stored password against the session-stored hash.
|
||||
* This is a safety re-check — the session could theoretically have been
|
||||
* tampered with between login_otp.php and this call.
|
||||
* 4. On password mismatch → clear cookies, return "Incorrect Password".
|
||||
* 5. On success:
|
||||
* a. Generate a fresh 6-digit TOTP (new timestamp → new OTP).
|
||||
* b. Generate a new 6-letter reference number.
|
||||
* c. Send the OTP email via system SMTP ($SMTP from config.php).
|
||||
* Note: uses system-level SMTP unconditionally (unlike login_otp.php
|
||||
* which tries the company SMTP first). The if(true) wrapper is a
|
||||
* placeholder left from the original — email always sends.
|
||||
* d. Clear session and repopulate with new OTP state.
|
||||
* 6. Return { success: 1, message: "Login Complete!" }.
|
||||
*
|
||||
* Session keys read:
|
||||
* login_data['username'], login_data['password'], login_user_id
|
||||
*
|
||||
* Session keys overwritten:
|
||||
* login_data, otp, otpTime, reference, user_email, login_user_id
|
||||
* (same keys as login_otp.php — login_confirm.php reads the same structure)
|
||||
*
|
||||
* Response JSON:
|
||||
* On success: { "success": 1, "message": "Login Complete!" }
|
||||
* On failure: { "message": "Incorrect Password" }
|
||||
*/
|
||||
|
||||
require '../../../session.php';
|
||||
require '../../../config.php';
|
||||
require '../../../preset.php';
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
// ── Step 1: Reload credentials from session ───────────────────────────────────
|
||||
// These were stored by login_otp.php so the user doesn't have to retype them.
|
||||
$data["username"] = $_SESSION["login_data"]['username'];
|
||||
$data["password"] = $_SESSION["login_data"]['password'];
|
||||
$user_id = (int)$_SESSION["login_user_id"];
|
||||
|
||||
// get password
|
||||
// ── Step 2: Fetch user record ─────────────────────────────────────────────────
|
||||
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
|
||||
$sth->execute([
|
||||
":user_id" => $user_id
|
||||
]);
|
||||
$sth->execute([":user_id" => $user_id]);
|
||||
$temp = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// user email
|
||||
$user_email = $temp["email"];
|
||||
|
||||
/**
|
||||
* validate password
|
||||
*/
|
||||
// ── Step 3–4: Re-verify password ─────────────────────────────────────────────
|
||||
// Safety check — ensures the session hasn't been tampered with between
|
||||
// login_otp.php and this resend call.
|
||||
if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
|
||||
/**
|
||||
* Generate OTP
|
||||
*/
|
||||
// ── Step 5a: Generate fresh 6-digit TOTP ──────────────────────────────────
|
||||
// Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php.
|
||||
// A new $otpTime is captured so the OTP window resets from this moment.
|
||||
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
|
||||
|
||||
global $otpTime;
|
||||
|
||||
$otpTime = time();
|
||||
$otpTime = time(); // new timestamp — extends the 5-minute validity window
|
||||
|
||||
$counter = floor($otpTime / $time_step);
|
||||
$data = pack("NN", 0, $counter);
|
||||
@@ -42,7 +84,9 @@
|
||||
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
|
||||
// ── Step 5b: Generate 6-letter reference number ───────────────────────────
|
||||
// Converts a second TOTP (derived from the first OTP as the key) to a
|
||||
// base-26 uppercase letter string shown on the OTP input screen.
|
||||
function numberToLetters($num) {
|
||||
$result = '';
|
||||
while ($num > 0) {
|
||||
@@ -54,15 +98,14 @@
|
||||
}
|
||||
|
||||
$otp = generateOTP($temp["password"]);
|
||||
|
||||
$reference_number = numberToLetters(generateOTP($otp));
|
||||
|
||||
/**
|
||||
* Sent Email With OTP
|
||||
*/
|
||||
// ── Step 5c: Send OTP email ───────────────────────────────────────────────
|
||||
// Uses the system-level $SMTP config from config.php.
|
||||
// The if(true) wrapper is a no-op placeholder from the original code —
|
||||
// the email block always executes.
|
||||
require "../../../assets/utils/module/mailer.php";
|
||||
|
||||
// send email
|
||||
if (true) {
|
||||
|
||||
$mailer = new mailer(["pdo1" => $pdo1]);
|
||||
@@ -76,31 +119,28 @@
|
||||
"to" => $user_email,
|
||||
"key" => $pinkey,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ── Step 5d: Reset session with new OTP state ─────────────────────────────
|
||||
// Full session is cleared before repopulating to avoid stale state
|
||||
// from the previous OTP attempt leaking into this one.
|
||||
$_SESSION = [];
|
||||
|
||||
$_SESSION["login_data"] = $data; // store variables
|
||||
|
||||
$_SESSION["login_data"] = $data;
|
||||
$_SESSION["otp"] = $otp;
|
||||
|
||||
$_SESSION["otpTime"] = $otpTime;
|
||||
|
||||
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
|
||||
$_SESSION["reference"] = $reference_number;
|
||||
|
||||
$_SESSION["user_email"] = $user_email;
|
||||
|
||||
$_SESSION["login_user_id"] = $user_id;
|
||||
|
||||
|
||||
// ── Step 6: Respond ───────────────────────────────────────────────────────
|
||||
$answer["success"] = 1;
|
||||
$answer["message"] = "Login Complete!";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
} else {
|
||||
|
||||
// ── Password mismatch — clear cookies and reject ──────────────────────────
|
||||
$answer["message"] = "Incorrect Password";
|
||||
setcookie("u", "", time() - 1, "/");
|
||||
setcookie("h1", "", time() - 1, "/");
|
||||
@@ -110,5 +150,3 @@
|
||||
|
||||
$answer["success"] = 1;
|
||||
exit(json_encode($answer));
|
||||
|
||||
?>
|
||||
+4
-4
@@ -48,10 +48,10 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="form-check">
|
||||
<input id="remember" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label small" for="remember">Remember me</label>
|
||||
</div>
|
||||
<!-- "Remember me" is intentionally excluded.
|
||||
This login uses 2FA (OTP via email) on every session.
|
||||
A persistent login would bypass the OTP step and undermine the security model.
|
||||
Do not add this back. -->
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" onclick="login();">Sign in</button>
|
||||
<p class="text-center text-muted small mt-3 mb-0">
|
||||
|
||||
Reference in New Issue
Block a user