Files
itflow/client/login_reset.php
johnnyq 9457442804 Emails stage 3: every email now goes through emailQueue and the shared layout - the client portal invite sends a link to set a password (or to sign in with Microsoft) instead of the password itself, offered on New Contact as well as Edit through portalMailInvite, with Forgot password, password changed and two-step sign-in emails through portalMailPasswordReset and portalMailAccountChange; shared link and code emails with the note quoted and the whole link printed under the button, replacing shareMailHtml and shareQueueMail; calendar invitations, updates and cancellations with when, repeats, where, the description and a join button; Email on the contacts and clients lists in the layout from one of the configured mailboxes with an optional greeting and send time, leaving out archived contacts and contacts without an address, each address once, nothing queued without mail set up, through emailBulkSend and emailBulkPost with a shared bulk_email_fields window; Admin > Mail > Previews shows all 52 emails built by their real senders on sample records inside a rolled-back transaction with emailQueue capturing and custom actions off, with computer and phone widths and Send me a copy; emailHtml gains content and button show_url, emailQueue gains queued_at and always so billing still queues as before, plus emailMailboxes and emailTestSend; ticketMailCreated, ticketMailReply, ticketMailWatchersAdded, ticketMailTaskApproval, ticketMailClosedReply, ticketSlaAlert and billingMailCardSaved moved into functions; fixes Email on the contacts list queuing mail to contacts with no address, the clients list including archived contacts, and both taking any From address posted; changelog updated
2026-10-17 23:26:25 -04:00

203 lines
10 KiB
PHP

<?php
/*
* Client Portal
* Setting a password from a link - Forgot password, and the invite a primary
* contact or an agent sends - and asking for a Forgot password link.
*
* A link is ?email=&token=&client=. Only the token's SHA-256 is kept, and it
* runs out: an hour for Forgot password, a week for an invite
* (functions/portal.php). Using it spends it. Asking for a link always says
* the same thing, whether or not the address has an account, and sends at
* most one per account every PORTAL_RESET_REQUEST_GAP_MINUTES.
*/
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:");
header("X-Frame-Options: DENY");
require_once '../config.php';
require_once '../functions.php';
require_once '../includes/load_global_settings.php';
if (intval($config_client_portal_enable) !== 1) {
header("Location: /login.php");
exit();
}
require_once __DIR__ . "/../includes/session_init.php";
require_once "../includes/inc_set_timezone.php";
$ip = escapeSql(getIP());
$user_agent = escapeSql($_SERVER['HTTP_USER_AGENT'] ?? '');
$can_email = portalMailReady();
$company = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_name, company_logo FROM companies WHERE company_id = 1"));
$company_name = (string) ($company['company_name'] ?? '');
$message = '';
$message_type = 'danger';
$link_user = null;
$link = ['email' => '', 'token' => '', 'client' => 0];
/** A row in the log for a password link - $user_id 0 when the address has no account. */
$reset_log = function ($action, $description, $user_id, $client_id) use ($mysqli, $ip, $user_agent) {
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Client Login', log_action = '" . escapeSql($action) . "', log_description = '" . escapeSql($description) . "',
log_ip = '$ip', log_user_agent = '$user_agent', log_client_id = " . intval($client_id) . ", log_user_id = " . intval($user_id));
};
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password_reset_email_request']) && $can_email) {
$email = trim((string) ($_POST['email'] ?? ''));
// From one address, ten asks in ten minutes is plenty - after that, nothing is sent
$asked = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE log_type = 'Client Login' AND log_action = 'Reset Requested'
AND log_ip = '$ip' AND log_created_at > NOW() - INTERVAL 10 MINUTE"))[0]);
$row = null;
if ($asked < 10 && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$email_sql = escapeSql($email);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_id, user_email, contact_name, contact_client_id FROM users
JOIN contacts ON contact_user_id = user_id
JOIN clients ON client_id = contact_client_id
WHERE user_email = '$email_sql' AND user_type = 2 AND user_auth_method = 'local' AND user_status = 1 AND user_archived_at IS NULL
AND contact_archived_at IS NULL AND client_archived_at IS NULL
LIMIT 1"));
}
$reset_log('Reset Requested', 'Asked for a portal password link for ' . mb_substr($email, 0, 200), intval($row['user_id'] ?? 0), intval($row['contact_client_id'] ?? 0));
if ($row) {
$user_id = intval($row['user_id']);
$recent = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE log_type = 'Client Login' AND log_action = 'Reset Sent'
AND log_user_id = $user_id AND log_created_at > NOW() - INTERVAL " . PORTAL_RESET_REQUEST_GAP_MINUTES . " MINUTE"))[0]);
if (!$recent) {
portalMailPasswordReset($user_id);
$reset_log('Reset Sent', 'Emailed a portal password link to ' . $row['user_email'], $user_id, intval($row['contact_client_id']));
}
}
$message = "If there is a client portal account for that address, a link to reset its password is on its way. It works for " . PORTAL_RESET_LINK_MINUTES . " minutes.";
$message_type = 'success';
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password_reset_set_password'])) {
$link = ['email' => (string) ($_POST['email'] ?? ''), 'token' => (string) ($_POST['token'] ?? ''), 'client' => intval($_POST['client'] ?? 0)];
$link_user = portalPasswordLinkUser($link['email'], $link['token'], $link['client']);
$password = (string) ($_POST['new_password'] ?? '');
if (!$link_user) {
$message = 'That link has run out or has been used already. Ask for a new one below.';
} elseif (($problem = userPasswordProblem($password)) !== '') {
$message = $problem;
} elseif ($password !== (string) ($_POST['confirm_password'] ?? '')) {
$message = 'The two passwords are not the same - type it again in both.';
} else {
$user_id = intval($link_user['user_id']);
$hash = escapeSql(password_hash($password, PASSWORD_DEFAULT));
mysqli_query($mysqli, "UPDATE users SET user_password = '$hash' WHERE user_id = $user_id AND user_type = 2");
portalPasswordLinkClear($user_id);
$reset_log('Password Reset', 'Set a new portal password from an emailed link', $user_id, $link['client']);
portalMailAccountChange($link_user['user_email'], $link_user['contact_name'], 'password_link');
$_SESSION['login_message'] = 'Your password is set - sign in with it below.';
$_SESSION['login_message_type'] = 'success';
header("Location: /login.php");
exit();
}
} elseif (isset($_GET['token'])) {
$link = ['email' => (string) ($_GET['email'] ?? ''), 'token' => (string) $_GET['token'], 'client' => intval($_GET['client'] ?? 0)];
$link_user = portalPasswordLinkUser($link['email'], $link['token'], $link['client']);
if (!$link_user) {
$message = 'That link has run out or has been used already.' . ($can_email ? ' Ask for a new one below.' : ' Ask us for a new one.');
}
}
?>
<!DOCTYPE html>
<html lang="en" data-bs-theme="light" data-lte-color-mode="off">
<head>
<meta charset="utf-8">
<title><?= escapeHtml($company_name) ?> | <?= $link_user ? 'Set your password' : 'Reset your password' ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<link rel="stylesheet" href="/libs/fontawesome-free/css/all.min.css">
<?php if (file_exists(__DIR__ . '/../uploads/favicon.ico')) { ?>
<link rel="icon" type="image/x-icon" href="/uploads/favicon.ico">
<?php } ?>
<link rel="stylesheet" href="/libs/adminlte/css/adminlte.min.css">
<link rel="stylesheet" href="/css/itflow_custom.css">
</head>
<body class="hold-transition login-page">
<div class="login-box">
<div class="login-logo">
<?php if (!empty($company['company_logo'])) { ?>
<img alt="<?= escapeHtml($company_name) ?> logo" height="110" width="380" class="img-fluid" src="/uploads/settings/<?= escapeHtml($company['company_logo']) ?>">
<?php } else { ?>
<span class="fw-semibold"><?= escapeHtml($company_name) ?></span>
<?php } ?>
</div>
<div class="card">
<div class="card-body login-card-body">
<?php if ($link_user) { ?>
<h1 class="h5 mb-1">Set your password</h1>
<p class="text-body-secondary small mb-3">For <?= escapeHtml($link_user['user_email']) ?> - at least <?= USER_PASSWORD_MIN ?> characters.</p>
<?php if ($message !== '') { ?>
<div class="alert alert-<?= $message_type ?> small"><?= escapeHtml($message) ?></div>
<?php } ?>
<form method="post" autocomplete="off">
<input type="hidden" name="email" value="<?= escapeHtml($link['email']) ?>">
<input type="hidden" name="token" value="<?= escapeHtml($link['token']) ?>">
<input type="hidden" name="client" value="<?= intval($link['client']) ?>">
<input type="text" class="d-none" name="username" value="<?= escapeHtml($link_user['user_email']) ?>" autocomplete="username" aria-hidden="true" tabindex="-1">
<div class="input-group mb-3">
<span class="input-group-text"><i class="fas fa-fw fa-lock"></i></span>
<input type="password" class="form-control" placeholder="New password" name="new_password" minlength="<?= USER_PASSWORD_MIN ?>" autocomplete="new-password" aria-label="New password" required autofocus>
</div>
<div class="input-group mb-3">
<span class="input-group-text"><i class="fas fa-fw fa-check-double"></i></span>
<input type="password" class="form-control" placeholder="The same again" name="confirm_password" minlength="<?= USER_PASSWORD_MIN ?>" autocomplete="new-password" aria-label="New password again" required>
</div>
<button type="submit" class="btn btn-primary w-100 mb-3" name="password_reset_set_password">Set password</button>
</form>
<?php } else { ?>
<h1 class="h5 mb-1">Reset your password</h1>
<p class="text-body-secondary small mb-3">For the client portal. Enter the email address you sign in with and we will email you a link.</p>
<?php if ($message !== '') { ?>
<div class="alert alert-<?= $message_type ?> small"><?= escapeHtml($message) ?></div>
<?php } ?>
<?php if ($can_email) { ?>
<form method="post" autocomplete="off">
<div class="input-group mb-3">
<span class="input-group-text"><i class="fas fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" placeholder="Email address" name="email" maxlength="200" autocomplete="email" aria-label="Email address" required<?= $message_type === 'success' ? '' : ' autofocus' ?>>
</div>
<button type="submit" class="btn btn-primary w-100 mb-3" name="password_reset_email_request">Email me a link</button>
</form>
<?php } else { ?>
<div class="alert alert-secondary small">Email is not set up here, so a link can't be sent - ask us to reset it for you.</div>
<?php } ?>
<?php } ?>
<a href="/login.php"><i class="fas fa-arrow-left me-1"></i>Back to sign in</a>
</div>
</div>
</div>
<script src="/js/login_prevent_resubmit.js"></script>
</body>
</html>