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:
2026-05-02 09:38:55 -04:00
parent d9bf80cc9a
commit a2d48c8b58
6 changed files with 238 additions and 5 deletions
@@ -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&hellip;</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');
+3 -3
View File
@@ -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": {
+5 -1
View File
@@ -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}`;