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