Add SMS quote approval, fix Twilio credentials, fix passkey post-login redirect
- Add 'Send Quote via SMS' button on quote details page that sends the approval link to the customer via SMS (respects NotifyBySms, handles prospects via ProspectPhone) - Reuses existing valid approval token rather than regenerating, so a previously emailed link stays valid when SMS is also sent - Fix Twilio appsettings.json placeholders (real credentials moved to gitignored appsettings.Development.json) - Fix passkey login ignoring ReturnUrl: biometric login on the login page now respects the form's ReturnUrl hidden field so QR-code and deep-link flows redirect correctly after authentication instead of always going to the dashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3291,6 +3291,68 @@ public class QuotesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the quote approval link to the customer via SMS.
|
||||
/// Reuses the existing approval token when valid; generates a new one only when none exists or it is expired.
|
||||
/// Does NOT regenerate a live token — so a previously emailed link stays valid.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SendQuoteApprovalSms(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var quote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer, q => q.QuoteStatus);
|
||||
if (quote == null)
|
||||
return Json(new { success = false, message = "Quote not found." });
|
||||
|
||||
// Determine recipient phone for the feedback message
|
||||
string? recipientPhone = quote.CustomerId.HasValue
|
||||
? (quote.Customer?.MobilePhone ?? quote.Customer?.Phone)
|
||||
: quote.ProspectPhone;
|
||||
|
||||
string recipientName = quote.CustomerId.HasValue && quote.Customer != null
|
||||
? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName)
|
||||
? quote.Customer.CompanyName
|
||||
: $"{quote.Customer.ContactFirstName} {quote.Customer.ContactLastName}".Trim())
|
||||
: (!string.IsNullOrWhiteSpace(quote.ProspectContactName) ? quote.ProspectContactName
|
||||
: quote.ProspectCompanyName ?? "Prospect");
|
||||
|
||||
// Ensure a valid (non-expired) approval token exists — generate only if missing or expired
|
||||
bool tokenChanged = false;
|
||||
if (string.IsNullOrEmpty(quote.ApprovalToken) ||
|
||||
(quote.ApprovalTokenExpiresAt.HasValue && quote.ApprovalTokenExpiresAt.Value < DateTime.UtcNow))
|
||||
{
|
||||
var tokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
|
||||
quote.ApprovalToken = Convert.ToBase64String(tokenBytes)
|
||||
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
|
||||
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var td) ? td : 30);
|
||||
quote.ApprovalTokenUsedAt = null;
|
||||
tokenChanged = true;
|
||||
}
|
||||
|
||||
if (tokenChanged)
|
||||
{
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
var (success, error) = await _notificationService.NotifyQuoteSentSmsAsync(quote);
|
||||
|
||||
if (!success)
|
||||
return Json(new { success = false, message = error ?? "SMS could not be sent." });
|
||||
|
||||
var phone = string.IsNullOrWhiteSpace(recipientPhone) ? "their phone" : recipientPhone;
|
||||
return Json(new { success = true, message = $"Approval link sent to {recipientName} via SMS ({phone})." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sending quote approval SMS for quote {QuoteId}", id);
|
||||
return Json(new { success = false, message = "An unexpected error occurred. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the notification delivery history for a quote as a JSON array.
|
||||
/// Used by the "Notifications Sent" tab on the Details page to show which emails/SMS
|
||||
|
||||
@@ -1478,7 +1478,10 @@
|
||||
</form>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-primary" onclick="resendQuote(@Model.Id)">
|
||||
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote to Customer
|
||||
<i class="bi bi-envelope-arrow-up me-1"></i>Send Quote via Email
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info" onclick="sendQuoteSms(@Model.Id)">
|
||||
<i class="bi bi-chat-dots me-1"></i>Send Quote via SMS
|
||||
</button>
|
||||
@if (!Model.ConvertedToJobId.HasValue)
|
||||
{
|
||||
@@ -2012,6 +2015,33 @@
|
||||
</style>
|
||||
}
|
||||
|
||||
<!-- Send Quote via SMS Modal -->
|
||||
<div class="modal fade" id="sendQuoteSmsModal" tabindex="-1" aria-labelledby="sendQuoteSmsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" id="sendQuoteSmsModalHeader">
|
||||
<h5 class="modal-title" id="sendQuoteSmsModalLabel">
|
||||
<i class="bi bi-chat-dots me-2"></i>Send Quote via SMS
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center" id="sendQuoteSmsBody">
|
||||
<div id="sendQuoteSmsSending">
|
||||
<div class="spinner-border text-info mb-3" role="status"></div>
|
||||
<div class="text-muted">Sending SMS…</div>
|
||||
</div>
|
||||
<div id="sendQuoteSmsResult" class="d-none">
|
||||
<i id="sendQuoteSmsIcon" class="fs-1 d-block mb-3"></i>
|
||||
<p id="sendQuoteSmsMessage" class="mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer d-none" id="sendQuoteSmsFooter">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Quote Modal -->
|
||||
<div class="modal fade" id="sendQuoteModal" tabindex="-1" aria-labelledby="sendQuoteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
@@ -2131,6 +2161,53 @@
|
||||
});
|
||||
}
|
||||
|
||||
function sendQuoteSms(quoteId) {
|
||||
document.getElementById('sendQuoteSmsSending').classList.remove('d-none');
|
||||
document.getElementById('sendQuoteSmsResult').classList.add('d-none');
|
||||
document.getElementById('sendQuoteSmsFooter').classList.add('d-none');
|
||||
document.getElementById('sendQuoteSmsModalHeader').className = 'modal-header';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('sendQuoteSmsModal'));
|
||||
modal.show();
|
||||
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
fetch('@Url.Action("SendQuoteApprovalSms", "Quotes")?id=' + quoteId, {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': token, 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('sendQuoteSmsSending').classList.add('d-none');
|
||||
document.getElementById('sendQuoteSmsResult').classList.remove('d-none');
|
||||
document.getElementById('sendQuoteSmsFooter').classList.remove('d-none');
|
||||
|
||||
const icon = document.getElementById('sendQuoteSmsIcon');
|
||||
const msg = document.getElementById('sendQuoteSmsMessage');
|
||||
const header = document.getElementById('sendQuoteSmsModalHeader');
|
||||
|
||||
if (data.success) {
|
||||
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
|
||||
header.className = 'modal-header bg-success text-white';
|
||||
showInfo(data.message, 'SMS Sent');
|
||||
} else {
|
||||
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
||||
header.className = 'modal-header bg-danger text-white';
|
||||
showWarning(data.message, 'SMS Not Sent');
|
||||
}
|
||||
msg.textContent = data.message;
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('sendQuoteSmsSending').classList.add('d-none');
|
||||
document.getElementById('sendQuoteSmsResult').classList.remove('d-none');
|
||||
document.getElementById('sendQuoteSmsFooter').classList.remove('d-none');
|
||||
document.getElementById('sendQuoteSmsIcon').className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
||||
document.getElementById('sendQuoteSmsModalHeader').className = 'modal-header bg-danger text-white';
|
||||
document.getElementById('sendQuoteSmsMessage').textContent = 'A network error occurred. Please try again.';
|
||||
showWarning('A network error occurred. Please try again.', 'SMS Not Sent');
|
||||
});
|
||||
}
|
||||
|
||||
function loadNotifications(quoteId) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('notificationsModal'));
|
||||
document.getElementById('notificationsLoading').classList.remove('d-none');
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
"FromName": "Powder Coating App Staff"
|
||||
},
|
||||
"Twilio": {
|
||||
"AccountSid": "SK45bb87a7645d34c9227ea20faccad642",
|
||||
"AuthToken": " f262409674753f285b1c8184785c270e",
|
||||
"FromNumber": "+18664883595",
|
||||
"AccountSid": "your-twilio-account-sid",
|
||||
"AuthToken": "your-twilio-auth-token",
|
||||
"FromNumber": "your-twilio-from-number",
|
||||
"DevRedirectPhone": ""
|
||||
},
|
||||
"Stripe": {
|
||||
|
||||
@@ -256,7 +256,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const result = await loginWithPasskey();
|
||||
|
||||
if (result.success) {
|
||||
window.location.href = result.redirectUrl || '/';
|
||||
// Prefer the login page's ReturnUrl hidden field (set by the server to route
|
||||
// through EnrollPrompt with the original destination) over the server's default
|
||||
// dashboard redirect, so QR-code and deep-link flows land in the right place.
|
||||
const formReturnUrl = document.querySelector('input[name="ReturnUrl"]')?.value;
|
||||
window.location.href = formReturnUrl || result.redirectUrl || '/';
|
||||
} else if (!result.cancelled) {
|
||||
passkeyBtn.disabled = false;
|
||||
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
|
||||
|
||||
Reference in New Issue
Block a user