Merge pull request #1254 from itflow-org/develop

Develop to Master for 25.12.1 Maint Release
This commit is contained in:
Johnny 2025-12-14 14:03:56 -05:00 committed by GitHub
commit 163aa3062e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1103 additions and 723 deletions

View File

@ -2,6 +2,21 @@
This file documents all notable changes made to ITFlow.
## [25.12.1] Maint Release
### Major Changes
- Unified the Client/Agent Login and process (Note only Client Users can Reset passwords from the login page, does not apply to agent users).
### Bug Fixes
- Fix Payment Provider not adding an account.
- Fix New ticket button in contact details in the related tickets section.
### New Features & Updates
- You can now Set Payment Provider income/expense account, expense vendor and expense category upond creation or editing.
- Moved Saved Payment Provider Methods away from admin side nav to the count link within Payment Providers page.
- Moved AI Models from the admin side nav to the model count link within AI Providers.
- Add Favicon Reset.
## [25.12] Stable Release
### Breaking Changes ###
@ -319,7 +334,7 @@ We will provide example code with directory structure for each custom directory
---
### Fixed
- Several security vulnerabilities patched.
- Several security vulnerabilities patched (with thanks to www.helx.io).
- Ticket status is no longer updated when scheduling.
- Client Portal: Tech contacts can no longer edit their own details.
- Fixed overlapping logo issue in Invoice/Quote PDF exports.

View File

@ -13,7 +13,7 @@ We operate a rolling release model. Any bug fixes will be released into latest v
| Version | Supported |
|---------| ------------------ |
| 25.05 | :white_check_mark: |
| 25.12 | :white_check_mark: |
## Reporting a Vulnerability via GitHub Security Advisories

View File

@ -12,6 +12,16 @@ $num_rows = mysqli_num_rows($sql);
?>
<ol class="breadcrumb d-print-none">
<li class="breadcrumb-item">
<a href="/admin">Admin</a>
</li>
<li class="breadcrumb-item">
<a href="ai_provider.php">AI Providers</a>
</li>
<li class="breadcrumb-item active">AI Models</li>
</ol>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-robot mr-2"></i>AI Models</h3>

View File

@ -39,7 +39,7 @@ $num_rows = mysqli_num_rows($sql);
Key <?php if ($sort == 'ai_provider_api_key') { echo $order_icon; } ?>
</a>
</th>
<th>
<th class="text-center">
<a class="text-dark">Models</a>
</th>
<th class="text-center">Action</th>
@ -67,7 +67,8 @@ $num_rows = mysqli_num_rows($sql);
</td>
<td><?php echo $url; ?></td>
<td><?php echo $key; ?></td>
<td><?php echo $ai_model_count; ?></td>
<td class="text-center">
<a class="badge badge-dark badge-pill p-2" href="ai_model.php"><?= $ai_model_count ?></a>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">

View File

@ -69,30 +69,20 @@
</a>
</li>
<li class="nav-item">
<a href="/admin/payment_provider.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'payment_provider.php' ? 'active' : ''); ?>">
<a href="/admin/payment_provider.php"
class="nav-link <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['payment_provider.php', 'saved_payment_method.php']) ? 'active' : ''); ?>">
<i class="nav-icon far fa-credit-card"></i>
<p>Payment Providers</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/saved_payment_method.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'saved_payment_method.php' ? 'active' : ''); ?>">
<i class="nav-icon far fa-credit-card"></i>
<p>Saved Payments</p>
</a>
</li>
<?php } ?>
<li class="nav-item">
<a href="/admin/ai_provider.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'ai_provider.php' ? 'active' : ''); ?>">
<a href="/admin/ai_provider.php"
class="nav-link <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['ai_provider.php', 'ai_model.php']) ? 'active' : ''); ?>">
<i class="nav-icon fas fa-robot"></i>
<p>AI Providers</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/ai_model.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'ai_model.php' ? 'active' : ''); ?>">
<i class="nav-icon fas fa-robot"></i>
<p>AI Models</p>
</a>
</li>
<?php if ($config_module_enable_ticketing) { ?>
<li class="nav-item">

View File

@ -16,11 +16,26 @@ ob_start();
<div class="modal-body">
<div class="alert alert-info">
An income account named after the provider will always be created and used for income of paid invoices.<br>
If "Enable Expense" option is enabled, a matching vendor will also be automatically created for expense tracking. Additionally, an expense category named "Payment Processing" will be created.
<div class="alert alert-info text-center">
<h6>Before Adding a Payment Provider!</h6>
We recommend you add an <strong>Account</strong> and <strong>Vendor</strong> based off the Provider name before continuing eg <strong>Stripe</strong>
</div>
<ul class="nav nav-pills nav-justified mb-3">
<li class="nav-item">
<a class="nav-link active" data-toggle="pill" href="#pills-details">Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-expense">Expense</a>
</li>
</ul>
<hr>
<div class="tab-content">
<div class="tab-pane fade show active" id="pills-details">
<div class="form-group">
<label>Provider <strong class="text-danger">*</strong></label>
<div class="input-group">
@ -53,6 +68,30 @@ ob_start();
</div>
</div>
<div class="form-group">
<label>Income / Expense Account <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-piggy-bank"></i></span>
</div>
<select class="form-control select2" name="account" required>
<option value="">- Select an Account -</option>
<?php
$sql = mysqli_query($mysqli, "SELECT account_id, account_name FROM accounts WHERE account_archived_at IS NULL ORDER BY account_name ASC");
while ($row = mysqli_fetch_array($sql)) {
$account_id = intval($row['account_id']);
$account_name = nullable_htmlentities($row['account_name']);
?>
<option <?php if ($account_name === 'Stripe') { echo "selected"; } ?> value="<?= $account_id ?>"><?= $account_name ?></option>
<?php
}
?>
</select>
</div>
</div>
<div class="form-group">
<label>Threshold</label>
<div class="input-group">
@ -64,7 +103,9 @@ ob_start();
<small class="form-text text-muted">Will not show as an option at Checkout if invoice amount is above this number, 0 disables the threshold check.</small>
</div>
<hr>
</div>
<div class="tab-pane fade" id="pills-expense">
<div class="form-group">
<div class="custom-control custom-switch">
@ -73,6 +114,60 @@ ob_start();
</div>
</div>
<div class="form-group">
<label>Payment Provider Vendor <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-building"></i></span>
</div>
<select class="form-control select2" name="expense_vendor" required>
<option value="0">Expense Disabled</option>
<?php
$sql = mysqli_query($mysqli, "SELECT vendor_id, vendor_name FROM vendors WHERE vendor_client_id = 0 AND vendor_archived_at IS NULL ORDER BY vendor_name ASC");
while ($row = mysqli_fetch_array($sql)) {
$vendor_id = intval($row['vendor_id']);
$vendor_name = nullable_htmlentities($row['vendor_name']);
?>
<option <?php if ($vendor_name === 'Stripe') { echo "selected"; } ?> value="<?= $vendor_id ?>"><?= $vendor_name ?></option>
<?php
}
?>
</select>
</div>
</div>
<div class="form-group">
<label>Expense Category <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-list"></i></span>
</div>
<select class="form-control select2" name="expense_category" required>
<option value="">- Select a Category -</option>
<?php
$sql = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories WHERE category_type = 'Expense' AND category_archived_at IS NULL ORDER BY category_name ASC");
while ($row = mysqli_fetch_array($sql)) {
$category_id = intval($row['category_id']);
$category_name = nullable_htmlentities($row['category_name']);
?>
<option <?php if ($category_name === 'Processing Fee') { echo "selected"; } ?> value="<?= $category_id ?>"><?= $category_name ?></option>
<?php
}
?>
</select>
<div class="input-group-append">
<button class="btn btn-secondary ajax-modal" type="button"
data-modal-url="../admin/modals/category/category_add.php?category=Expense">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
</div>
<div class="form-group">
<label>Percentage Fee to expense</label>
<div class="input-group">
@ -96,6 +191,8 @@ ob_start();
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="add_payment_provider" class="btn btn-primary text-bold"><i class="fa fa-check mr-2"></i>Add</button>
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fa fa-times mr-2"></i>Cancel</button>

View File

@ -10,10 +10,10 @@ $row = mysqli_fetch_array($sql);
$provider_name = nullable_htmlentities($row['payment_provider_name']);
$public_key = nullable_htmlentities($row['payment_provider_public_key']);
$private_key = nullable_htmlentities($row['payment_provider_private_key']);
$account_id = nullable_htmlentities($row['payment_provider_account']);
$account_id = intval($row['payment_provider_account']);
$threshold = floatval($row['payment_provider_threshold']);
$vendor_id = nullable_htmlentities($row['payment_provider_expense_vendor']);
$category_id = nullable_htmlentities($row['payment_provider_expense_category']);
$vendor_id = intval($row['payment_provider_expense_vendor']);
$category_id = intval($row['payment_provider_expense_category']);
$percent_fee = floatval($row['payment_provider_expense_percentage_fee']) * 100;
$flat_fee = floatval($row['payment_provider_expense_flat_fee']);
@ -21,24 +21,39 @@ $flat_fee = floatval($row['payment_provider_expense_flat_fee']);
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-credit-card mr-2"></i>Editing: <strong><?php echo $provider_name; ?></strong></h5>
<h5 class="modal-title"><i class="fa fa-fw fa-credit-card mr-2"></i>Editing: <strong><?= $provider_name ?></strong></h5>
<button type="button" class="close text-white" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="provider_id" value="<?php echo $provider_id; ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="provider_id" value="<?= $provider_id ?>">
<div class="modal-body">
<ul class="nav nav-pills nav-justified mb-3">
<li class="nav-item">
<a class="nav-link active" data-toggle="pill" href="#pills-details">Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="pill" href="#pills-expense">Expense</a>
</li>
</ul>
<hr>
<div class="tab-content">
<div class="tab-pane fade show active" id="pills-details">
<div class="form-group">
<label>Publishable key <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
<input type="text" class="form-control" name="public_key" placeholder="Publishable API Key (pk_...)" value="<?php echo $public_key; ?>">
<input type="text" class="form-control" name="public_key" placeholder="Publishable API Key (pk_...)" value="<?= $public_key ?>">
</div>
</div>
@ -48,7 +63,31 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
</div>
<input type="text" class="form-control" name="private_key" placeholder="Secret API Key (sk_...)" value="<?php echo $private_key; ?>">
<input type="text" class="form-control" name="private_key" placeholder="Secret API Key (sk_...)" value="<?= $private_key ?>">
</div>
</div>
<div class="form-group">
<label>Income / Expense Account <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-piggy-bank"></i></span>
</div>
<select class="form-control select2" name="account" required>
<option value="">- Select an Account -</option>
<?php
$sql = mysqli_query($mysqli, "SELECT account_id, account_name FROM accounts WHERE account_archived_at IS NULL ORDER BY account_name ASC");
while ($row = mysqli_fetch_array($sql)) {
$account_id_select = intval($row['account_id']);
$account_name = nullable_htmlentities($row['account_name']);
?>
<option <?php if ($account_id === $account_id_select) { echo "selected"; } ?> value="<?= $account_id_select ?>"><?= $account_name ?></option>
<?php
}
?>
</select>
</div>
</div>
@ -63,14 +102,64 @@ ob_start();
<small class="form-text text-muted">Will not show as an option at Checkout if above this number</small>
</div>
<hr>
</div>
<div class="tab-pane fade" id="pills-expense">
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="enable_expense" <?php if ($vendor_id) { echo "checked"; } ?> value="1" id="enableEditExpenseSwitch">
<label class="custom-control-label" for="enableEditExpenseSwitch">Enable Expense</label>
<label>Payment Provider Vendor <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-building"></i></span>
</div>
<select class="form-control select2" name="expense_vendor" required>
<option value="0">Expense Disabled</option>
<?php
$sql = mysqli_query($mysqli, "SELECT vendor_id, vendor_name FROM vendors WHERE vendor_client_id = 0 AND vendor_archived_at IS NULL ORDER BY vendor_name ASC");
while ($row = mysqli_fetch_array($sql)) {
$vendor_id_select = intval($row['vendor_id']);
$vendor_name = nullable_htmlentities($row['vendor_name']);
?>
<option <?php if ($vendor_id === $vendor_id_select) { echo "selected"; } ?>
value="<?= $vendor_id_select ?>"><?= $vendor_name ?>
</option>
<?php
}
?>
</select>
</div>
</div>
<div class="form-group">
<label>Expense Category <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-list"></i></span>
</div>
<select class="form-control select2" name="expense_category" required>
<option value="">- Select a Category -</option>
<?php
$sql_category = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories WHERE category_type = 'Expense' AND category_archived_at IS NULL ORDER BY category_name ASC");
while ($row = mysqli_fetch_array($sql_category)) {
$category_id_select = intval($row['category_id']);
$category_name = nullable_htmlentities($row['category_name']);
?>
<option <?php if ($category_id === $category_id_select) { echo "selected"; } ?> value="<?= $category_id_select ?>"><?= $category_name ?></option>
<?php
}
?>
</select>
<div class="input-group-append">
<button class="btn btn-secondary ajax-modal" type="button"
data-modal-url="../admin/modals/category/category_add.php?category=Expense">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<small>(Category: Payment Processing -- Vendor: <?php echo $provider_name; ?></small>
</div>
<div class="form-group">
@ -94,7 +183,8 @@ ob_start();
</div>
<small class="form-text text-muted">See <a href="https://stripe.com/pricing" target="_blank">here <i class="fas fa-fw fa-external-link-alt"></i></a> for the latest Stripe Fees.</small>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="edit_payment_provider" class="btn btn-primary text-bold"><i class="fa fa-check mr-2"></i>Save</button>

View File

@ -57,7 +57,7 @@ $num_rows = mysqli_num_rows($sql);
<th>
<a class="text-dark">Expensed Fee</a>
</th>
<th>
<th class="text-center">
<a class="text-dark">Saved Payment Methods</a>
</th>
<th class="text-center">Action</th>
@ -72,7 +72,7 @@ $num_rows = mysqli_num_rows($sql);
$provider_description = nullable_htmlentities($row['payment_provider_description']);
$account_name = nullable_htmlentities($row['account_name']);
$threshold = floatval($row['payment_provider_threshold']);
$vendor_name = nullable_htmlentities($row['vendor_name']);
$vendor_name = nullable_htmlentities($row['vendor_name'] ?? "Expense Disabled");
$category = nullable_htmlentities($row['category_name']);
$percent_fee = floatval($row['payment_provider_expense_percentage_fee']) * 100;
$flat_fee = floatval($row['payment_provider_expense_flat_fee']);
@ -94,7 +94,9 @@ $num_rows = mysqli_num_rows($sql);
<td><?php echo $vendor_name; ?></td>
<td><?php echo $category; ?></td>
<td><?php echo $percent_fee; ?>% + <?php echo numfmt_format_currency($currency_format, $flat_fee, $session_company_currency); ?></td>
<td><?php echo $saved_payment_count; ?></td>
<td class="text-center">
<a class="badge badge-dark badge-pill p-2" href="saved_payment_method.php"><?= $saved_payment_count ?></a>
</td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">

View File

@ -14,53 +14,20 @@ if (isset($_POST['add_payment_provider'])) {
$public_key = sanitizeInput($_POST['public_key']);
$private_key = sanitizeInput($_POST['private_key']);
$threshold = floatval($_POST['threshold']);
$enable_expense = intval($_POST['enable_expense'] ?? 0);
$account = intval($_POST['account']);
$expense_vendor = intval($_POST['expense_vendor']) ?? 0;
$expense_category = intval($_POST['expense_category']) ?? 0;
$percentage_fee = floatval($_POST['percentage_fee']) / 100 ?? 0;
$flat_fee = floatval($_POST['flat_fee']) ?? 0;
// Check to ensure provider isn't added twice
$sql = "SELECT 1 FROM payment_providers WHERE payment_provider_name = '$provider' LIMIT 1";
$result = mysqli_query($mysqli, $sql);
if (mysqli_num_rows($result) > 0) {
$sql = mysqli_query($mysqli, "SELECT 1 FROM payment_providers WHERE payment_provider_name = '$provider' LIMIT 1");
if (mysqli_num_rows($sql) > 0) {
flash_alert("Payment Provider <strong>$provider</strong> already exists", 'error');
redirect();
}
// Check for Stripe Account, if not create it
$sql_account = mysqli_query($mysqli,"SELECT account_id FROM accounts WHERE account_name = '$provider' AND account_archived_at IS NULL LIMIT 1");
if (mysqli_num_rows($sql_account) == 0) {
$account_id = mysqli_insert_id($mysqli);
} else {
$row = mysqli_fetch_array($sql_account);
$account_id = intval($row['account_id']);
}
// Expense defaults
$category_id = 0;
$vendor_id = 0;
if ($enable_expense) {
// Category
$sql_category = mysqli_query($mysqli,"SELECT category_id FROM categories WHERE category_name = 'Payment Processing' AND category_type = 'Expense' AND category_archived_at IS NULL LIMIT 1");
if (mysqli_num_rows($sql_category) == 0) {
mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Processing Fee', category_type = 'Payment Processing', category_color = 'gray'");
$category_id = mysqli_insert_id($mysqli);
} else {
$row = mysqli_fetch_array($sql_category);
$category_id = intval($row['category_id']);
}
// Vendor
$sql_vendor = mysqli_query($mysqli,"SELECT vendor_id FROM vendors WHERE vendor_name = '$provider' AND vendor_client_id = 0 AND vendor_archived_at IS NULL LIMIT 1");
if (mysqli_num_rows($sql_vendor) == 0) {
mysqli_query($mysqli,"INSERT INTO vendors SET vendor_name = '$provider', vendor_description = 'Payment Processor Provider', vendor_client_id = 0");
$vendor_id = mysqli_insert_id($mysqli);
} else {
$row = mysqli_fetch_array($sql_vendor);
$vendor_id = intval($row['vendor_id']);
}
}
mysqli_query($mysqli,"INSERT INTO payment_providers SET payment_provider_name = '$provider', payment_provider_public_key = '$public_key', payment_provider_private_key = '$private_key', payment_provider_threshold = $threshold, payment_provider_account = $account_id, payment_provider_expense_vendor = $vendor_id, payment_provider_expense_category = $category_id, payment_provider_expense_percentage_fee = $percentage_fee, payment_provider_expense_flat_fee = $flat_fee");
mysqli_query($mysqli,"INSERT INTO payment_providers SET payment_provider_name = '$provider', payment_provider_public_key = '$public_key', payment_provider_private_key = '$private_key', payment_provider_threshold = $threshold, payment_provider_account = $account, payment_provider_expense_vendor = $expense_vendor, payment_provider_expense_category = $expense_category, payment_provider_expense_percentage_fee = $percentage_fee, payment_provider_expense_flat_fee = $flat_fee");
$provider_id = mysqli_insert_id($mysqli);
@ -81,11 +48,13 @@ if (isset($_POST['edit_payment_provider'])) {
$public_key = sanitizeInput($_POST['public_key']);
$private_key = sanitizeInput($_POST['private_key']);
$threshold = floatval($_POST['threshold']);
$enable_expense = intval($_POST['enable_expense'] ?? 0);
$account = intval($_POST['account']);
$expense_vendor = intval($_POST['expense_vendor']) ?? 0;
$expense_category = intval($_POST['expense_category']) ?? 0;
$percentage_fee = floatval($_POST['percentage_fee']) / 100;
$flat_fee = floatval($_POST['flat_fee']);
mysqli_query($mysqli,"UPDATE payment_providers SET payment_provider_public_key = '$public_key', payment_provider_private_key = '$private_key', payment_provider_threshold = $threshold, payment_provider_expense_percentage_fee = $percentage_fee, payment_provider_expense_flat_fee = $flat_fee WHERE payment_provider_id = $provider_id");
mysqli_query($mysqli,"UPDATE payment_providers SET payment_provider_public_key = '$public_key', payment_provider_private_key = '$private_key', payment_provider_threshold = $threshold, payment_provider_account = $account, payment_provider_expense_vendor = $expense_vendor, payment_provider_expense_category = $expense_category, payment_provider_expense_percentage_fee = $percentage_fee, payment_provider_expense_flat_fee = $flat_fee WHERE payment_provider_id = $provider_id");
logAction("Payment Provider", "Edit", "$session_name edited Payment Provider $provider");

View File

@ -49,3 +49,17 @@ if (isset($_POST['edit_favicon_settings'])) {
redirect();
}
if (isset($_GET['reset_favicon'])) {
if (file_exists("../uploads/favicon.ico")) {
unlink("../uploads/favicon.ico");
}
logAction("Settings", "Edit", "$session_name reset Favicon");
flash_alert("Favicon reset", 'error');
redirect();
}

View File

@ -33,6 +33,16 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
?>
<ol class="breadcrumb d-print-none">
<li class="breadcrumb-item">
<a href="/admin">Admin</a>
</li>
<li class="breadcrumb-item">
<a href="payment_provider.php">Payment Providers</a>
</li>
<li class="breadcrumb-item active">Saved Payment Methods (Stripe)</li>
</ol>
<div class="card card-dark">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-fw fa-credit-card mr-2"></i>Saved Payment Methods</h3>
@ -107,8 +117,16 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
?>
<tr>
<td><?php echo $client_name; ?> (<?php echo $client_id; ?>)</td>
<td><?php echo $provider_name; ?> (<?php echo $provider_id; ?>)</td>
<td>
<?= $client_name ?>
<br>
<small class="text-secondary">ID: <?= $client_id ?></small>
</td>
<td>
<?= $provider_name ?>
<br>
<small class="text-secondary">ID: <?= $provider_id ?></small>
</td>
<td><?php echo $saved_payment_description; ?></td>
<td><?php echo $provider_client; ?></td>
<td><?php echo $provider_payment_method; ?></td>

View File

@ -57,11 +57,12 @@ require_once "includes/inc_all_admin.php";
<hr>
<button type="submit" name="edit_favicon_settings" class="btn btn-primary text-bold"><i class="fa fa-check mr-2"></i>Upload Icon</button>
<?php if(file_exists("../uploads/favicon.ico")) { ?>
<a href="post.php?reset_favicon" class="btn btn-outline-danger"><i class="fas fa-redo-alt mr-2"></i>Reset Favicon</a>
<?php } ?>
</form>
</div>
</div>
<?php
require_once "../includes/footer.php";

View File

@ -797,7 +797,9 @@ if (isset($_GET['contact_id'])) {
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fa fa-fw fa-life-ring mr-2"></i>Related Tickets</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#addTicketModal">
<button type="button" class="btn btn-primary ajax-modal"
data-modal-url="modals/ticket/ticket_add.php?<?= $client_url ?>&contact_id=<?= $contact_id ?>"
data-modal-size="lg">
<i class="fas fa-plus mr-2"></i>New Ticket
</button>
</div>

View File

@ -152,8 +152,6 @@ ob_start();
</button>
</div>
</div>
</div>
<?php if ($client_id) { ?>

View File

@ -16,13 +16,13 @@ if (!isset($_SESSION)) {
}
if (!isset($_SESSION['client_logged_in']) || !$_SESSION['client_logged_in']) {
header("Location: /client/login.php");
header("Location: /login.php");
die;
}
// Check user type
if ($_SESSION['user_type'] !== 2) {
header("Location: /client/login.php");
header("Location: /login.php");
exit();
}

View File

@ -1,234 +0,0 @@
<?php
/*
* Client Portal
* Landing / Home page for the client portal
*/
header("Content-Security-Policy: default-src 'self'");
require_once '../config.php';
require_once '../functions.php';
require_once '../includes/load_global_settings.php';
if (!isset($_SESSION)) {
// HTTP Only cookies
ini_set("session.cookie_httponly", true);
if ($config_https_only) {
// Tell client to only send cookie(s) over HTTPS
ini_set("session.cookie_secure", true);
}
session_start();
}
// Set Timezone after session_start
require_once "../includes/inc_set_timezone.php";
// Check to see if client portal is enabled
if($config_client_portal_enable == 0) {
echo "Client Portal is Disabled";
exit();
}
$session_ip = sanitizeInput(getIP());
$session_user_agent = sanitizeInput($_SERVER['HTTP_USER_AGENT']);
$sql_settings = mysqli_query($mysqli, "SELECT config_azure_client_id, config_login_message FROM settings WHERE company_id = 1");
$settings = mysqli_fetch_array($sql_settings);
$azure_client_id = $settings['config_azure_client_id'];
$config_login_message = nullable_htmlentities($settings['config_login_message']);
$company_sql = mysqli_query($mysqli, "SELECT company_name, company_logo FROM companies WHERE company_id = 1");
$company_results = mysqli_fetch_array($company_sql);
$company_name = $company_results['company_name'];
$company_logo = $company_results['company_logo'];
if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['login'])) {
$email = sanitizeInput($_POST['email']);
$password = $_POST['password'];
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
header("HTTP/1.1 401 Unauthorized");
$_SESSION['login_message'] = 'Invalid e-mail';
} else {
$sql = mysqli_query($mysqli, "SELECT * FROM users
LEFT JOIN contacts ON user_id = contact_user_id
LEFT JOIN clients ON contact_client_id = client_id
WHERE user_email = '$email'
AND client_archived_at IS NULL
AND user_archived_at IS NULL
AND user_type = 2
AND user_status = 1
LIMIT 1"
);
$row = mysqli_fetch_array($sql);
$client_id = intval($row['contact_client_id']);
$user_id = intval($row['user_id']);
$session_user_id = $user_id; // to pass the user_id to logAction function
$contact_id = intval($row['contact_id']);
$user_email = sanitizeInput($row['user_email']);
$user_auth_method = sanitizeInput($row['user_auth_method']);
if ($user_auth_method == 'local') {
if (password_verify($password, $row['user_password'])) {
$_SESSION['client_logged_in'] = true;
$_SESSION['client_id'] = $client_id;
$_SESSION['user_id'] = $user_id;
$_SESSION['user_type'] = 2;
$_SESSION['contact_id'] = $contact_id;
$_SESSION['login_method'] = "local";
header("Location: index.php");
// Logging
logAction("Client Login", "Success", "Client contact $user_email successfully logged in locally", $client_id, $user_id);
} else {
// Logging
logAction("Client Login", "Failed", "Failed client portal login attempt using $email (incorrect password for contact ID $contact_id)", $client_id, $user_id);
header("HTTP/1.1 401 Unauthorized");
$_SESSION['login_message'] = 'Incorrect username or password.';
}
} else {
// Logging
logAction("Client Login", "Failed", "Failed client portal login attempt using $email (invalid email/not allowed local auth)");
header("HTTP/1.1 401 Unauthorized");
$_SESSION['login_message'] = 'Incorrect username or password.';
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title><?php echo $company_name; ?> | Client Portal Login</title>
<!-- Tell the browser to be responsive to screen width -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<!-- Favicon - If Fav Icon exists else use the default one -->
<?php if(file_exists('../uploads/favicon.ico')) { ?>
<link rel="icon" type="image/x-icon" href="../uploads/favicon.ico">
<?php } ?>
<!-- Font Awesome -->
<link rel="stylesheet" href="../plugins/fontawesome-free/css/all.min.css">
<!-- Theme style -->
<link rel="stylesheet" href="../plugins/adminlte/css/adminlte.min.css">
</head>
<body class="hold-transition login-page">
<div class="login-box">
<div class="login-logo">
<?php if (!empty($company_logo)) { ?>
<img alt="<?=$company_name?> logo" height="110" width="380" class="img-fluid" src="<?php echo "../uploads/settings/$company_logo"; ?>">
<?php } else { ?>
<b><?=$company_name?></b> <br>Client Portal Login</h2>
<?php } ?>
</div>
<div class="card">
<div class="card-body login-card-body">
<?php if(!empty($config_login_message)){ ?>
<p class="login-box-msg px-0"><?php echo nl2br($config_login_message); ?></p>
<?php } ?>
<?php
if (!empty($_SESSION['login_message'])) { ?>
<p class="login-box-msg text-danger">
<?php
echo $_SESSION['login_message'];
unset($_SESSION['login_message']);
?>
</p>
<?php
}
?>
<form method="post">
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Registered Client Email" name="email" required autofocus>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-envelope"></span>
</div>
</div>
</div>
<div class="input-group mb-3">
<input type="password" class="form-control" placeholder="Client Password" name="password" required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-lock"></span>
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-block mb-3" name="login">Sign in</button>
<hr>
<?php
if (!empty($config_smtp_host)) { ?>
<h5 class="text-center"><a href="login_reset.php">Forgot password?</a></h5>
<?php } ?>
</form>
<?php
if (!empty($azure_client_id)) { ?>
<hr>
<div class="col text-center">
<a href="login_microsoft.php">
<button type="button" class="btn btn-secondary">Login with Microsoft Entra</button>
</a>
</div>
<?php } ?>
</div>
<!-- /.login-card-body -->
</div>
<!-- /.div.card -->
</div>
<!-- /.login-box -->
<?php
if (!$config_whitelabel_enabled) {
echo '<small class="text-muted">Powered by ITFlow</small>';
}
?>
<!-- jQuery -->
<script src="../plugins/jquery/jquery.min.js"></script>
<!-- Bootstrap 4 -->
<script src="../plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- AdminLTE App -->
<script src="../plugins/adminlte/js/adminlte.min.js"></script>
<!-- Prevents resubmit on refresh or back -->
<script src="../js/login_prevent_resubmit.js"></script>
</body>
</html>

View File

@ -12,7 +12,7 @@ require_once '../includes/load_global_settings.php';
if (empty($config_smtp_host)) {
header("Location: login.php");
header("Location: /login.php");
exit();
}
@ -157,7 +157,7 @@ if ($_SERVER['REQUEST_METHOD'] == "POST") {
// Redirect to login page
$_SESSION['login_message'] = "Password reset successfully!";
header("Location: login.php");
header("Location: /login.php");
exit();
} else {
@ -275,7 +275,7 @@ if ($_SERVER['REQUEST_METHOD'] == "POST") {
?>
</p>
<a href="login.php">Back to login</a>
<a href="/login.php">Back to login</a>
</div>

View File

@ -320,7 +320,7 @@ if (isset($_GET['logout'])) {
session_unset();
session_destroy();
redirect('login.php');
redirect('/login.php');
}

View File

@ -1784,3 +1784,220 @@ function cleanupUnusedImages(string $html, string $folderFsPath, string $folderW
}
}
}
/**
* Simple mysqli helper functions
* - Prepared statements under the hood
* - "Old style" INSERT/UPDATE SET feeling
*/
/**
* Core executor: prepares, binds, executes.
*
* @throws Exception on error
*/
function dbExecute(mysqli $mysqli, string $sql, array $params = []): mysqli_stmt
{
$stmt = $mysqli->prepare($sql);
if (!$stmt) {
throw new Exception('MySQLi prepare error: ' . $mysqli->error . ' | SQL: ' . $sql);
}
if (!empty($params)) {
$types = '';
$values = [];
foreach ($params as $param) {
if (is_int($param)) {
$types .= 'i';
} elseif (is_float($param)) {
$types .= 'd';
} elseif (is_bool($param)) {
$types .= 'i';
$param = $param ? 1 : 0;
} elseif (is_null($param)) {
$types .= 's';
$param = null;
} else {
$types .= 's';
}
$values[] = $param;
}
if (!$stmt->bind_param($types, ...$values)) {
throw new Exception('MySQLi bind_param error: ' . $stmt->error . ' | SQL: ' . $sql);
}
}
if (!$stmt->execute()) {
throw new Exception('MySQLi execute error: ' . $stmt->error . ' | SQL: ' . $sql);
}
return $stmt;
}
/**
* Fetch all rows as associative arrays.
*/
function dbFetchAll(mysqli $mysqli, string $sql, array $params = []): array
{
$stmt = dbExecute($mysqli, $sql, $params);
$result = $stmt->get_result();
if ($result === false) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
/**
* Fetch a single row (assoc) or null if none.
*/
function dbFetchOne(mysqli $mysqli, string $sql, array $params = []): ?array
{
$stmt = dbExecute($mysqli, $sql, $params);
$result = $stmt->get_result();
if ($result === false) {
return null;
}
$row = $result->fetch_assoc();
return $row !== null ? $row : null;
}
/**
* Fetch a single scalar value (first column of first row) or null.
*/
function dbFetchValue(mysqli $mysqli, string $sql, array $params = [])
{
$row = dbFetchOne($mysqli, $sql, $params);
if ($row === null) {
return null;
}
return reset($row);
}
/**
* INSERT using "SET" style.
* Example:
* $id = dbInsert($mysqli, 'clients', [
* 'client_name' => $name,
* 'client_type' => $type,
* ]);
*
* @return int insert_id
*
* @throws InvalidArgumentException
* @throws Exception
*/
function dbInsert(mysqli $mysqli, string $table, array $data): int
{
if (empty($data)) {
throw new InvalidArgumentException('dbInsert called with empty $data');
}
$setParts = [];
foreach ($data as $column => $_) {
$setParts[] = "$column = ?";
}
$sql = "INSERT INTO $table SET " . implode(', ', $setParts);
$params = array_values($data);
dbExecute($mysqli, $sql, $params);
return $mysqli->insert_id;
}
function dbUpdate(
mysqli $mysqli,
string $table,
array $data,
$where,
array $whereParams = []
): int {
if (empty($data)) {
throw new InvalidArgumentException('dbUpdate called with empty $data');
}
if (empty($where)) {
throw new InvalidArgumentException('dbUpdate requires a WHERE clause');
}
$setParts = [];
foreach ($data as $column => $_) {
$setParts[] = "$column = ?";
}
if (is_array($where)) {
$whereParts = [];
$whereParams = [];
foreach ($where as $column => $value) {
$whereParts[] = "$column = ?";
$whereParams[] = $value;
}
$whereSql = implode(' AND ', $whereParts);
} else {
$whereSql = $where;
}
$sql = "UPDATE $table SET " . implode(', ', $setParts) . " WHERE $whereSql";
$params = array_merge(array_values($data), $whereParams);
$stmt = dbExecute($mysqli, $sql, $params);
return $stmt->affected_rows;
}
/**
* DELETE helper.
*
* WHERE can be:
* - array: ['client_id' => $id] (auto "client_id = ?")
* - string: 'client_id = ?' (use with $whereParams)
*
* @return int affected_rows
*
* @throws InvalidArgumentException
* @throws Exception
*/
function dbDelete(
mysqli $mysqli,
string $table,
$where,
array $whereParams = []
): int {
if (empty($where)) {
throw new InvalidArgumentException('dbDelete requires a WHERE clause');
}
if (is_array($where)) {
$whereParts = [];
$whereParams = [];
foreach ($where as $column => $value) {
$whereParts[] = "$column = ?";
$whereParams[] = $value;
}
$whereSql = implode(' AND ', $whereParts);
} else {
$whereSql = $where;
}
$sql = "DELETE FROM $table WHERE $whereSql";
$stmt = dbExecute($mysqli, $sql, $whereParams);
return $stmt->affected_rows;
}
/**
* Transaction helpers (optional sugar).
*/
function dbBegin(mysqli $mysqli): void
{
$mysqli->begin_transaction();
}
function dbCommit(mysqli $mysqli): void
{
$mysqli->commit();
}
function dbRollback(mysqli $mysqli): void
{
$mysqli->rollback();
}

View File

@ -5,4 +5,4 @@
* Update this file each time we merge develop into master. Format is YY.MM (add a .v if there is more than one release a month.
*/
DEFINE("APP_VERSION", "25.12");
DEFINE("APP_VERSION", "25.12.1");

424
login.php
View File

@ -1,5 +1,7 @@
<?php
// Unified login (Agent + Client) using one email & password
// Enforce a Content Security Policy for security against cross-site scripting
header("Content-Security-Policy: default-src 'self'");
@ -11,6 +13,21 @@ if (!file_exists('config.php')) {
}
require_once "config.php";
require_once "functions.php";
require_once "plugins/totp/totp.php";
// Sessions & cookies
if (session_status() === PHP_SESSION_NONE) {
// HTTP-Only cookies
ini_set("session.cookie_httponly", true);
// Tell client to only send cookie(s) over HTTPS
if ($config_https_only || !isset($config_https_only)) {
ini_set("session.cookie_secure", true);
}
session_start();
}
// Check if setup mode is enabled or the variable is missing
if (!isset($config_enable_setup) || $config_enable_setup == 1) {
@ -19,28 +36,30 @@ if (!isset($config_enable_setup) || $config_enable_setup == 1) {
exit();
}
// Set Timezone
require_once "includes/inc_set_timezone.php";
// Check if the application is configured for HTTPS-only access
if ($config_https_only && (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') && (!isset($_SERVER['HTTP_X_FORWARDED_PROTO']) || $_SERVER['HTTP_X_FORWARDED_PROTO'] !== 'https')) {
echo "Login is restricted as ITFlow defaults to HTTPS-only for enhanced security. To login using HTTP, modify the config.php file by setting config_https_only to false. However, this is strongly discouraged, especially when accessing from potentially unsafe networks like the internet.";
exit;
}
require_once "functions.php";
require_once "plugins/totp/totp.php";
// Set Timezone after session_start
require_once "includes/inc_set_timezone.php";
// IP & User Agent for logging
$session_ip = sanitizeInput(getIP());
$session_user_agent = sanitizeInput($_SERVER['HTTP_USER_AGENT']);
$session_user_agent = sanitizeInput($_SERVER['HTTP_USER_AGENT'] ?? '');
// Block brute force password attacks - check recent failed login attempts for this IP
// Block access if more than 15 failed login attempts have happened in the last 10 minutes
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS failed_login_count FROM logs WHERE log_ip = '$session_ip' AND log_type = 'Login' AND log_action = 'Failed' AND log_created_at > (NOW() - INTERVAL 10 MINUTE)"));
$row = mysqli_fetch_assoc(mysqli_query(
$mysqli,
"SELECT COUNT(log_id) AS failed_login_count
FROM logs
WHERE log_ip = '$session_ip'
AND log_type = 'Login'
AND log_action = 'Failed'
AND log_created_at > (NOW() - INTERVAL 10 MINUTE)"
));
$failed_login_count = intval($row['failed_login_count']);
if ($failed_login_count >= 15) {
@ -53,7 +72,12 @@ if ($failed_login_count >= 15) {
}
// Query Settings for company
$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings LEFT JOIN companies ON settings.company_id = companies.company_id WHERE settings.company_id = 1");
$sql_settings = mysqli_query($mysqli, "
SELECT settings.*, companies.company_name, companies.company_logo
FROM settings
LEFT JOIN companies ON settings.company_id = companies.company_id
WHERE settings.company_id = 1
");
$row = mysqli_fetch_array($sql_settings);
// Company info
@ -73,64 +97,159 @@ $config_mail_from_name = sanitizeInput($row['config_mail_from_name']);
// Client Portal Enabled
$config_client_portal_enable = intval($row['config_client_portal_enable']);
$config_login_remember_me_expire = intval($row['config_login_remember_me_expire']);
// Login key (if setup)
$config_login_key_required = $row['config_login_key_required'];
$config_login_key_secret = $row['config_login_key_secret'];
$config_login_remember_me_expire = intval($row['config_login_remember_me_expire']);
// Azure / Entra for client
$azure_client_id = $row['config_azure_client_id'] ?? null;
// Login key verification
// If no/incorrect 'key' is supplied, send to client portal instead
if ($config_login_key_required) {
if (!isset($_GET['key']) || $_GET['key'] !== $config_login_key_secret) {
redirect("client");
$response = null;
$token_field = null;
$show_role_choice = false;
$email = '';
$password = '';
// Handle POST login request (normal login or role choice)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_POST['role_choice']))) {
$email = sanitizeInput($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$role_choice = $_POST['role_choice'] ?? null; // 'agent' or 'client'
// Basic validation
if (empty($email) || empty($password) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
header("HTTP/1.1 401 Unauthorized");
$response = "
<div class='alert alert-danger'>
Incorrect username or password.
<button class='close' data-dismiss='alert'>&times;</button>
</div>";
} else {
/*
* Unified lookup:
* - user_type = 1 Agent
* - user_type = 2 Client (must not be archived, client not archived)
* We fetch all possible matches for this email, then verify password per row.
* If both an agent and a client match with the same password:
* - First, show choice buttons (Agent / Client).
* - When user clicks a choice, we honor role_choice.
*/
$sql = mysqli_query($mysqli, "
SELECT users.*,
user_settings.*,
contacts.*,
clients.*
FROM users
LEFT JOIN user_settings ON users.user_id = user_settings.user_id
LEFT JOIN contacts ON users.user_id = contacts.contact_user_id
LEFT JOIN clients ON contacts.contact_client_id = clients.client_id
WHERE user_email = '$email'
AND user_archived_at IS NULL
AND user_status = 1
AND (
user_type = 1
OR (user_type = 2 AND client_archived_at IS NULL)
)
");
$agentRow = null;
$clientRow = null;
while ($r = mysqli_fetch_assoc($sql)) {
if (!password_verify($password, $r['user_password'])) {
continue;
}
if (intval($r['user_type']) === 1 && $agentRow === null) {
$agentRow = $r;
}
if (intval($r['user_type']) === 2 && $clientRow === null) {
$clientRow = $r;
}
}
}
// HTTP-Only cookies
ini_set("session.cookie_httponly", true);
$selectedRow = null;
$selectedType = null; // 1 = agent, 2 = client
// Tell client to only send cookie(s) over HTTPS
if ($config_https_only || !isset($config_https_only)) {
ini_set("session.cookie_secure", true);
}
if ($agentRow === null && $clientRow === null) {
// Handle POST login request
if (isset($_POST['login'])) {
// No matching user/password combo
header("HTTP/1.1 401 Unauthorized");
logAction("Login", "Failed", "Failed login attempt using $email");
// Sessions should start after the user has POSTed data
session_start();
$response = "
<div class='alert alert-danger'>
Incorrect username or password.
<button class='close' data-dismiss='alert'>&times;</button>
</div>";
// Passed login brute force check
$email = sanitizeInput($_POST['email']);
$password = $_POST['password'];
} elseif ($agentRow !== null && $clientRow !== null) {
$current_code = 0; // Default value
// Both agent and client accounts share same email + password
if ($role_choice === 'agent') {
$selectedRow = $agentRow;
$selectedType = 1;
} elseif ($role_choice === 'client') {
$selectedRow = $clientRow;
$selectedType = 2;
} else {
// First time we realise this is a dual-role account: ask user to pick
$show_role_choice = true;
$response = "
<div class='alert alert-info'>
This login can be used as either an Agent account or a Client Portal account.
Please choose how you want to continue.
<button class='close' data-dismiss='alert'>&times;</button>
</div>";
}
} else {
// Only one valid row (agent OR client)
if ($agentRow !== null) {
$selectedRow = $agentRow;
$selectedType = 1;
} else {
$selectedRow = $clientRow;
$selectedType = 2;
}
}
// If we have a specific user selected, proceed with actual login
if ($selectedRow !== null && $selectedType !== null) {
$user_id = intval($selectedRow['user_id']);
$user_email = sanitizeInput($selectedRow['user_email']);
$session_user_id = $user_id; // to pass the user_id to logAction function
// =========================
// AGENT LOGIN FLOW
// =========================
if ($selectedType === 1) {
// Login key verification
// If no/incorrect 'key' is supplied, send to client portal instead
if ($config_login_key_required) {
if (!isset($_GET['key']) || $_GET['key'] !== $config_login_key_secret) {
redirect();
}
}
$user_name = sanitizeInput($selectedRow['user_name']);
$token = sanitizeInput($selectedRow['user_token']);
$force_mfa = intval($selectedRow['user_config_force_mfa']);
$user_role_id = intval($selectedRow['user_role_id']);
$user_encryption_ciphertext = $selectedRow['user_specific_encryption_ciphertext'];
$user_extension_key = $selectedRow['user_extension_key'];
$current_code = 0;
if (isset($_POST['current_code'])) {
$current_code = intval($_POST['current_code']);
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM users LEFT JOIN user_settings on users.user_id = user_settings.user_id WHERE user_email = '$email' AND user_archived_at IS NULL AND user_status = 1 AND user_type = 1"));
// Check password
if ($row && password_verify($password, $row['user_password'])) {
// User password correct (partial login)
// Set temporary user variables
$user_name = sanitizeInput($row['user_name']);
$user_id = intval($row['user_id']);
$session_user_id = $user_id; // to pass the user_id to logAction function
$user_email = sanitizeInput($row['user_email']);
$token = sanitizeInput($row['user_token']);
$force_mfa = intval($row['user_config_force_mfa']);
$user_role_id = intval($row['user_role_id']);
$user_encryption_ciphertext = $row['user_specific_encryption_ciphertext'];
$user_extension_key = $row['user_extension_key'];
$mfa_is_complete = false; // Default to requiring MFA
$extended_log = ''; // Default value
$mfa_is_complete = false;
$extended_log = '';
if (empty($token)) {
// MFA is not configured
@ -139,10 +258,14 @@ if (isset($_POST['login'])) {
// Validate MFA via a remember-me cookie
if (isset($_COOKIE['rememberme'])) {
// Get remember tokens less than $config_login_remember_me_days_expire days old
$remember_tokens = mysqli_query($mysqli, "SELECT remember_token_token FROM remember_tokens WHERE remember_token_user_id = $user_id AND remember_token_created_at > (NOW() - INTERVAL $config_login_remember_me_expire DAY)");
while ($row = mysqli_fetch_assoc($remember_tokens)) {
if (hash_equals($row['remember_token_token'], $_COOKIE['rememberme'])) {
$remember_tokens = mysqli_query($mysqli, "
SELECT remember_token_token
FROM remember_tokens
WHERE remember_token_user_id = $user_id
AND remember_token_created_at > (NOW() - INTERVAL $config_login_remember_me_expire DAY)
");
while ($remember_row = mysqli_fetch_assoc($remember_tokens)) {
if (hash_equals($remember_row['remember_token_token'], $_COOKIE['rememberme'])) {
$mfa_is_complete = true;
$extended_log = 'with 2FA remember-me cookie';
break;
@ -157,25 +280,48 @@ if (isset($_POST['login'])) {
}
if ($mfa_is_complete) {
// MFA Completed successfully
// FULL LOGIN SUCCESS
// FULL AGENT LOGIN SUCCESS
// Create a remember me token, if requested
if (isset($_POST['remember_me'])) {
// TODO: Record the UA and IP a token is generated from so that can be shown later on
$newRememberToken = bin2hex(random_bytes(64));
setcookie('rememberme', $newRememberToken, time() + 86400*$config_login_remember_me_expire, "/", null, true, true);
mysqli_query($mysqli, "INSERT INTO remember_tokens SET remember_token_user_id = $user_id, remember_token_token = '$newRememberToken'");
setcookie(
'rememberme',
$newRememberToken,
time() + 86400 * $config_login_remember_me_expire,
"/",
null,
true,
true
);
mysqli_query($mysqli, "
INSERT INTO remember_tokens
SET remember_token_user_id = $user_id,
remember_token_token = '$newRememberToken'
");
$extended_log .= ", generated a new remember-me token";
}
// Check this login isn't suspicious
$sql_ip_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS ip_previous_logins FROM logs WHERE log_type = 'Login' AND log_action = 'Success' AND log_ip = '$session_ip' AND log_user_id = $user_id"));
$sql_ip_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, "
SELECT COUNT(log_id) AS ip_previous_logins
FROM logs
WHERE log_type = 'Login'
AND log_action = 'Success'
AND log_ip = '$session_ip'
AND log_user_id = $user_id
"));
$ip_previous_logins = sanitizeInput($sql_ip_prev_logins['ip_previous_logins']);
$sql_ua_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS ua_previous_logins FROM logs WHERE log_type = 'Login' AND log_action = 'Success' AND log_user_agent = '$session_user_agent' AND log_user_id = $user_id"));
$sql_ua_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, "
SELECT COUNT(log_id) AS ua_previous_logins
FROM logs
WHERE log_type = 'Login'
AND log_action = 'Success'
AND log_user_agent = '$session_user_agent'
AND log_user_id = $user_id
"));
$ua_prev_logins = sanitizeInput($sql_ua_prev_logins['ua_previous_logins']);
// Notify if both the user agent and IP are different
@ -209,32 +355,20 @@ if (isset($_POST['login'])) {
}
// Setup encryption session key
if (isset($user_encryption_ciphertext)) {
if (!empty($user_encryption_ciphertext)) {
$site_encryption_master_key = decryptUserSpecificKey($user_encryption_ciphertext, $password);
generateUserSessionKey($site_encryption_master_key);
// Setup extension - currently unused
//if (is_null($user_extension_key)) {
// Extension cookie
// Note: Browsers don't accept cookies with SameSite None if they are not HTTPS.
//setcookie("user_extension_key", "$user_extension_key", ['path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'None']);
// Set PHP session in DB, so we can access the session encryption data (above)
//$user_php_session = session_id();
//mysqli_query($mysqli, "UPDATE users SET user_php_session = '$user_php_session' WHERE user_id = $user_id");
//}
}
// Redirect to last visited or config home
if (isset($_GET['last_visited']) && (str_starts_with(base64_decode($_GET['last_visited']), '/agent') || str_starts_with(base64_decode($_GET['last_visited']), '/admin'))) {
redirect($_SERVER["REQUEST_SCHEME"] . "://" . $config_base_url . base64_decode($_GET['last_visited']) );
redirect($_SERVER["REQUEST_SCHEME"] . "://" . $config_base_url . base64_decode($_GET['last_visited']));
} else {
redirect("agent/$config_start_page");
}
} else {
// MFA is configured and needs to be confirmed, or was unsuccessful
@ -242,7 +376,9 @@ if (isset($_POST['login'])) {
// HTML code for the token input field
$token_field = "
<div class='input-group mb-3'>
<input type='text' inputmode='numeric' pattern='[0-9]*' maxlength='6' class='form-control' placeholder='Enter your 2FA code' name='current_code' required autofocus>
<input type='text' inputmode='numeric' pattern='[0-9]*' maxlength='6'
class='form-control' placeholder='Enter your 2FA code'
name='current_code' required autofocus>
<div class='input-group-append'>
<div class='input-group-text'>
<span class='fas fa-key'></span>
@ -250,11 +386,9 @@ if (isset($_POST['login'])) {
</div>
</div>";
// Log/notify if MFA was unsuccessful
if ($current_code !== 0) {
// Logging
logAction("Login", "MFA Failed", "$user_name failed MFA", 0, $user_id);
logAction("Login", "MFA Failed", "$user_email failed MFA", 0, $user_id);
// Email the tech to advise their credentials may be compromised
if (!empty($config_smtp_host)) {
@ -270,10 +404,9 @@ if (isset($_POST['login'])) {
'body' => $body
]
];
$mail = addToMailQueue($data);
addToMailQueue($data);
}
// HTML feedback for incorrect 2FA code
$response = "
<div class='alert alert-warning'>
Please Enter 2FA Code!
@ -282,24 +415,59 @@ if (isset($_POST['login'])) {
}
}
// =========================
// CLIENT LOGIN FLOW
// =========================
} elseif ($selectedType === 2) {
if ($config_client_portal_enable != 1) {
// Client portal disabled
header("HTTP/1.1 401 Unauthorized");
logAction("Client Login", "Failed", "Client portal disabled; login attempt using $email");
$response = "
<div class='alert alert-danger'>
Incorrect username or password.
<button class='close' data-dismiss='alert'>&times;</button>
</div>";
} else {
// Password incorrect or user doesn't exist - show generic error
$client_id = intval($selectedRow['contact_client_id']);
$contact_id = intval($selectedRow['contact_id']);
$user_auth_method = sanitizeInput($selectedRow['user_auth_method']);
if ($client_id && $contact_id && $user_auth_method === 'local') {
$_SESSION['client_logged_in'] = true;
$_SESSION['client_id'] = $client_id;
$_SESSION['user_id'] = $user_id;
$_SESSION['user_type'] = 2;
$_SESSION['contact_id'] = $contact_id;
$_SESSION['login_method'] = "local";
logAction("Client Login", "Success", "Client contact $user_email successfully logged in locally", $client_id, $user_id);
header("Location: client/index.php");
exit();
} else {
// Not allowed or invalid
logAction("Client Login", "Failed", "Failed client portal login attempt using $email (invalid auth method or missing contact/client)", $client_id ?? 0, $user_id);
header("HTTP/1.1 401 Unauthorized");
logAction("Login", "Failed", "Failed login attempt using $email");
$response = "
<div class='alert alert-danger'>
Incorrect username or password.
<button class='close' data-dismiss='alert'>&times;</button>
</div>";
}
}
}
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
@ -313,10 +481,7 @@ if (isset($_POST['login'])) {
<!-- Font Awesome -->
<link rel="stylesheet" href="plugins/fontawesome-free/css/all.min.css">
<!--
Favicon
If Fav Icon exists else use the default one
-->
<!-- Favicon -->
<?php if(file_exists('uploads/favicon.ico')) { ?>
<link rel="icon" type="image/x-icon" href="/uploads/favicon.ico">
<?php } ?>
@ -336,7 +501,6 @@ if (isset($_POST['login'])) {
<?php } ?>
</div>
<!-- /.login-logo -->
<div class="card">
<div class="card-body login-card-body">
@ -350,16 +514,24 @@ if (isset($_POST['login'])) {
<form method="post">
<div class="input-group mb-3" <?php if (isset($token_field)) { echo "hidden"; } ?>>
<input type="text" class="form-control" placeholder="Agent Email" name="email" value="<?php if (isset($token_field)) { echo $email; }?>" required <?php if (!isset($token_field)) { echo "autofocus"; } ?> >
<div class="input-group mb-3" <?php if (isset($token_field) && $token_field) { echo "hidden"; } ?>>
<input type="text" class="form-control"
placeholder="<?php if ($config_login_key_required) { if (!isset($_GET['key']) || $_GET['key'] !== $config_login_key_secret) { echo "Client "; } } echo "Email"; ?>"
name="email"
value="<?php echo htmlspecialchars($email ?? '', ENT_QUOTES); ?>"
required <?php if (!isset($token_field) || !$token_field) { echo "autofocus"; } ?>
>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-envelope"></span>
</div>
</div>
</div>
<div class="input-group mb-3" <?php if (isset($token_field)) { echo "hidden"; } ?>>
<input type="password" class="form-control" placeholder="Agent Password" name="password" value="<?php if (isset($token_field)) { echo $password; } ?>" required>
<div class="input-group mb-3" <?php if (isset($token_field) && $token_field) { echo "hidden"; } ?>>
<input type="password" class="form-control" placeholder="Password" name="password"
value="<?php echo isset($token_field) && $token_field ? htmlspecialchars($password ?? '', ENT_QUOTES) : ''; ?>"
required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-lock"></span>
@ -368,38 +540,58 @@ if (isset($_POST['login'])) {
</div>
<?php
if (isset($token_field)) {
// If agent needs MFA, show 2FA field + remember me
if (isset($token_field) && $token_field) {
echo $token_field;
?>
<div class="form-group mb-3">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="remember_me" name="remember_me">
<label class="custom-control-label" for="remember_me">Remember Me</label>
</div>
</div>
<?php
}
?>
<button type="submit" class="btn btn-primary btn-block mb-3" name="login">Sign In</button>
<?php if($config_client_portal_enable == 1){ ?>
<hr>
<h5 class="text-center">Looking for the <a href="client">Client Portal?<a/></h5>
<?php } ?>
<?php if ($show_role_choice): ?>
<!-- When both agent & client accounts exist with same email/password -->
<div class="mb-2 text-center">
<button type="submit" class="btn btn-primary btn-block mb-2" name="role_choice" value="agent">
Log in as Agent
</button>
<button type="submit" class="btn btn-success btn-block" name="role_choice" value="client">
Log in as Client
</button>
</div>
<?php else: ?>
<button type="submit" class="btn btn-primary btn-block mb-3" name="login">Sign In</button>
<?php endif; ?>
</form>
<?php if($config_client_portal_enable == 1){ ?>
<hr>
<?php if (!empty($config_smtp_host)) { ?>
<a href="client/login_reset.php">Forgot password?</a>
<?php } ?>
<?php if (!empty($azure_client_id)) { ?>
<div class="col text-center mt-2">
<a href="client/login_microsoft.php">
<button type="button" class="btn btn-secondary">Login with Microsoft Entra</button>
</a>
</div>
<?php } ?>
<?php } ?>
</div>
<!-- /.login-card-body -->
</div>
</div>
<!-- /.login-box -->
<?php
if (!$config_whitelabel_enabled) {
echo '<small class="text-muted">Powered by ITFlow</small>';
}
?>
<!-- jQuery -->
<script src="plugins/jquery/jquery.min.js"></script>
@ -410,8 +602,6 @@ if (isset($_POST['login'])) {
<!-- AdminLTE App -->
<script src="plugins/adminlte/js/adminlte.min.js"></script>
<!-- <script src="plugins/Show-Hide-Passwords-Bootstrap-4/bootstrap-show-password.min.js"></script> -->
<!-- Prevents resubmit on refresh or back -->
<script src="js/login_prevent_resubmit.js"></script>