POS: Option for user sign in via the QR code (#6231)

* Login Code: Turn into Blazor component and extend with data for the app

* POS: Add login code for POS frontend

* Improve components, fix test
This commit is contained in:
d11n 2024-09-26 12:10:14 +02:00 committed by GitHub
parent b5590a38fe
commit 272cc3d3c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 253 additions and 75 deletions

View file

@ -3426,11 +3426,14 @@ namespace BTCPayServer.Tests
var user = s.RegisterNewUser(); var user = s.RegisterNewUser();
s.GoToHome(); s.GoToHome();
s.GoToProfile(ManageNavPages.LoginCodes); s.GoToProfile(ManageNavPages.LoginCodes);
var code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
s.ClickPagePrimary();
Assert.NotEqual(code, s.Driver.FindElement(By.Id("logincode")).GetAttribute("value"));
code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value"); string code = null;
TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
string prevCode = code;
await s.Driver.Navigate().RefreshAsync();
TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
Assert.NotEqual(prevCode, code);
TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
s.Logout(); s.Logout();
s.GoToLogin(); s.GoToLogin();
s.Driver.SetAttribute("LoginCode", "value", "bad code"); s.Driver.SetAttribute("LoginCode", "value", "bad code");

View file

@ -7,12 +7,13 @@
<use href="@GetPathTo(Symbol)"></use> <use href="@GetPathTo(Symbol)"></use>
</svg> </svg>
@code { @code {
public string GetPathTo(string symbol) [Parameter, EditorRequired]
public string Symbol { get; set; }
private string GetPathTo(string symbol)
{ {
var versioned = FileVersionProvider.AddFileVersionToPath(default, "img/icon-sprite.svg"); var versioned = FileVersionProvider.AddFileVersionToPath(default, "img/icon-sprite.svg");
var rootPath = (BTCPayServerOptions.RootPath ?? "/").WithTrailingSlash(); var rootPath = (BTCPayServerOptions.RootPath ?? "/").WithTrailingSlash();
return $"{rootPath}{versioned}#{Symbol}"; return $"{rootPath}{versioned}#{symbol}";
} }
[Parameter]
public string Symbol { get; set; }
} }

View file

@ -0,0 +1,42 @@
@using Microsoft.AspNetCore.Http
@inject IHttpContextAccessor HttpContextAccessor;
@if (Users?.Any() is true)
{
<div @attributes="Attrs" class="@CssClass">
<label for="SignedInUser" class="form-label">Signed in user</label>
<select id="SignedInUser" class="form-select" value="@_userId" @onchange="@(e => _userId = e.Value?.ToString())">
<option value="">None, just open the URL</option>
@foreach (var u in Users)
{
<option value="@u.Key">@u.Value</option>
}
</select>
</div>
}
@if (string.IsNullOrEmpty(_userId))
{
<QrCode Data="@PosUrl" />
}
else
{
<UserLoginCode UserId="@_userId" RedirectUrl="@PosPath" />
}
@code {
[Parameter, EditorRequired]
public string PosPath { get; set; }
[Parameter]
public Dictionary<string,string> Users { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> Attrs { get; set; }
private string _userId;
private string PosUrl => Request.GetAbsoluteRoot() + PosPath;
private HttpRequest Request => HttpContextAccessor.HttpContext?.Request;
private string CssClass => $"form-group {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
}

View file

@ -0,0 +1,29 @@
@using QRCoder
@if (!string.IsNullOrEmpty(Data))
{
<img @attributes="Attrs" style="image-rendering:pixelated;image-rendering:-moz-crisp-edges;min-width:@(Size)px;min-height:@(Size)px" src="data:image/png;base64,@(GetBase64(Data))" class="@CssClass" alt="@Data" />
}
@code {
[Parameter, EditorRequired]
public string Data { get; set; }
[Parameter]
public int Size { get; set; } = 256;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> Attrs { get; set; }
private static readonly QRCodeGenerator QrGenerator = new();
private string GetBase64(string data)
{
var qrCodeData = QrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
var qrCode = new PngByteQRCode(qrCodeData);
var bytes = qrCode.GetGraphic(5, [0, 0, 0, 255], [0xf5, 0xf5, 0xf7, 255]);
return Convert.ToBase64String(bytes);
}
private string CssClass => $"qr-code {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
}

View file

@ -0,0 +1,100 @@
@using System.Timers
@using BTCPayServer.Data
@using BTCPayServer.Fido2
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.Mvc
@using Microsoft.AspNetCore.Routing
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject UserManager<ApplicationUser> UserManager;
@inject UserLoginCodeService UserLoginCodeService;
@inject LinkGenerator LinkGenerator;
@inject IHttpContextAccessor HttpContextAccessor;
@implements IDisposable
@if (!string.IsNullOrEmpty(_data))
{
<div @attributes="Attrs" class="@CssClass" style="width:@(Size)px">
<div class="qr-container mb-2">
<QrCode Data="@_data" Size="Size"/>
</div>
<p class="text-center text-muted mb-1" id="progress">Valid for @_seconds seconds</p>
<div class="progress only-for-js" data-bs-toggle="tooltip" data-bs-placement="top">
<div class="progress-bar progress-bar-striped progress-bar-animated @(Percent < 15 ? "bg-warning" : null)" role="progressbar" style="width:@Percent%" id="progressbar"></div>
</div>
</div>
}
@code {
[Parameter]
public string UserId { get; set; }
[Parameter]
public string RedirectUrl { get; set; }
[Parameter]
public int Size { get; set; } = 256;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> Attrs { get; set; }
private static readonly double Seconds = UserLoginCodeService.ExpirationTime.TotalSeconds;
private double _seconds = Seconds;
private string _data;
private ApplicationUser _user;
private Timer _timer;
protected override async Task OnParametersSetAsync()
{
UserId ??= await GetUserId();
if (!string.IsNullOrEmpty(UserId)) _user = await UserManager.FindByIdAsync(UserId);
if (_user == null) return;
GenerateCodeAndStartTimer();
}
public void Dispose()
{
_timer?.Dispose();
}
private void GenerateCodeAndStartTimer()
{
var loginCode = UserLoginCodeService.GetOrGenerate(_user.Id);
_data = GetData(loginCode);
_seconds = Seconds;
_timer?.Dispose();
_timer = new Timer(1000);
_timer.Elapsed += CountDownTimer;
_timer.Enabled = true;
}
private void CountDownTimer(object source, ElapsedEventArgs e)
{
if (_seconds > 0)
_seconds -= 1;
else
GenerateCodeAndStartTimer();
InvokeAsync(StateHasChanged);
}
private async Task<string> GetUserId()
{
var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
return state.User.Identity?.IsAuthenticated is true
? UserManager.GetUserId(state.User)
: null;
}
private string GetData(string loginCode)
{
var req = HttpContextAccessor.HttpContext?.Request;
if (req == null) return loginCode;
return !string.IsNullOrEmpty(RedirectUrl)
? LinkGenerator.LoginCodeLink(loginCode, RedirectUrl, req.Scheme, req.Host, req.PathBase)
: $"{loginCode};{LinkGenerator.IndexLink(req.Scheme, req.Host, req.PathBase)};{_user.Email}";
}
private double Percent => Math.Round(_seconds / Seconds * 100);
private string CssClass => $"user-login-code d-inline-flex flex-column {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
}

View file

@ -123,15 +123,30 @@ namespace BTCPayServer.Controllers
return View(nameof(Login), new LoginViewModel { Email = email }); return View(nameof(Login), new LoginViewModel { Email = email });
} }
// GET is for signin via the POS backend
[HttpGet("/login/code")]
[AllowAnonymous]
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> LoginUsingCode(string loginCode, string returnUrl = null)
{
return await LoginCodeResult(loginCode, returnUrl);
}
[HttpPost("/login/code")] [HttpPost("/login/code")]
[AllowAnonymous] [AllowAnonymous]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)] [RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null) public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
{
return await LoginCodeResult(loginCode, returnUrl);
}
private async Task<IActionResult> LoginCodeResult(string loginCode, string returnUrl)
{ {
if (!string.IsNullOrEmpty(loginCode)) if (!string.IsNullOrEmpty(loginCode))
{ {
var userId = _userLoginCodeService.Verify(loginCode); var code = loginCode.Split(';').First();
var userId = _userLoginCodeService.Verify(code);
if (userId is null) if (userId is null)
{ {
TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid"; TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid";

View file

@ -1,21 +1,12 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers namespace BTCPayServer.Controllers;
{
public partial class UIManageController public partial class UIManageController
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> LoginCodes() public ActionResult LoginCodes()
{ {
var user = await _userManager.GetUserAsync(User); return View();
if (user == null)
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
return View(nameof(LoginCodes), _userLoginCodeService.GetOrGenerate(user.Id));
}
} }
} }

View file

@ -40,7 +40,6 @@ namespace BTCPayServer.Controllers
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly Fido2Service _fido2Service; private readonly Fido2Service _fido2Service;
private readonly LinkGenerator _linkGenerator; private readonly LinkGenerator _linkGenerator;
private readonly UserLoginCodeService _userLoginCodeService;
private readonly IHtmlHelper Html; private readonly IHtmlHelper Html;
private readonly UserService _userService; private readonly UserService _userService;
private readonly UriResolver _uriResolver; private readonly UriResolver _uriResolver;
@ -62,7 +61,6 @@ namespace BTCPayServer.Controllers
UserService userService, UserService userService,
UriResolver uriResolver, UriResolver uriResolver,
IFileService fileService, IFileService fileService,
UserLoginCodeService userLoginCodeService,
IHtmlHelper htmlHelper IHtmlHelper htmlHelper
) )
{ {
@ -76,7 +74,6 @@ namespace BTCPayServer.Controllers
_authorizationService = authorizationService; _authorizationService = authorizationService;
_fido2Service = fido2Service; _fido2Service = fido2Service;
_linkGenerator = linkGenerator; _linkGenerator = linkGenerator;
_userLoginCodeService = userLoginCodeService;
Html = htmlHelper; Html = htmlHelper;
_userService = userService; _userService = userService;
_uriResolver = uriResolver; _uriResolver = uriResolver;

View file

@ -3,7 +3,6 @@ using System;
using BTCPayServer; using BTCPayServer;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
@ -52,6 +51,12 @@ namespace Microsoft.AspNetCore.Mvc
{ {
return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase); return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase);
} }
public static string LoginCodeLink(this LinkGenerator urlHelper, string loginCode, string returnUrl, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(nameof(UIAccountController.LoginUsingCode), "UIAccount", new { loginCode, returnUrl }, scheme, host, pathbase);
}
public static string ResetPasswordLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase) public static string ResetPasswordLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
{ {
return urlHelper.GetUriByAction( return urlHelper.GetUriByAction(
@ -109,5 +114,14 @@ namespace Microsoft.AspNetCore.Mvc
values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId, payoutState }, values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId, payoutState },
scheme, host, pathbase); scheme, host, pathbase);
} }
public static string IndexLink(this LinkGenerator urlHelper, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
action: nameof(UIHomeController.Index),
controller: "UIHome",
values: null,
scheme, host, pathbase);
}
} }
} }

View file

@ -8,6 +8,7 @@ namespace BTCPayServer.Fido2
public class UserLoginCodeService public class UserLoginCodeService
{ {
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
public static readonly TimeSpan ExpirationTime = TimeSpan.FromSeconds(60);
public UserLoginCodeService(IMemoryCache memoryCache) public UserLoginCodeService(IMemoryCache memoryCache)
{ {
@ -29,10 +30,10 @@ namespace BTCPayServer.Fido2
} }
return _memoryCache.GetOrCreate(GetCacheKey(userId), entry => return _memoryCache.GetOrCreate(GetCacheKey(userId), entry =>
{ {
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); entry.AbsoluteExpirationRelativeToNow = ExpirationTime;
var code = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20)); var code = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20));
using var newEntry = _memoryCache.CreateEntry(code); using var newEntry = _memoryCache.CreateEntry(code);
newEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); newEntry.AbsoluteExpirationRelativeToNow = ExpirationTime;
newEntry.Value = userId; newEntry.Value = userId;
return code; return code;

View file

@ -627,6 +627,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
} }
vm.ExampleCallback = "{\n \"id\":\"SkdsDghkdP3D3qkj7bLq3\",\n \"url\":\"https://btcpay.example.com/invoice?id=SkdsDghkdP3D3qkj7bLq3\",\n \"status\":\"paid\",\n \"price\":10,\n \"currency\":\"EUR\",\n \"invoiceTime\":1520373130312,\n \"expirationTime\":1520374030312,\n \"currentTime\":1520373179327,\n \"exceptionStatus\":false,\n \"buyerFields\":{\n \"buyerEmail\":\"customer@example.com\",\n \"buyerNotify\":false\n },\n \"paymentSubtotals\": {\n \"BTC\":114700\n },\n \"paymentTotals\": {\n \"BTC\":118400\n },\n \"transactionCurrency\": \"BTC\",\n \"amountPaid\": \"1025900\",\n \"exchangeRates\": {\n \"BTC\": {\n \"EUR\": 8721.690715789999,\n \"USD\": 10817.99\n }\n }\n}"; vm.ExampleCallback = "{\n \"id\":\"SkdsDghkdP3D3qkj7bLq3\",\n \"url\":\"https://btcpay.example.com/invoice?id=SkdsDghkdP3D3qkj7bLq3\",\n \"status\":\"paid\",\n \"price\":10,\n \"currency\":\"EUR\",\n \"invoiceTime\":1520373130312,\n \"expirationTime\":1520374030312,\n \"currentTime\":1520373179327,\n \"exceptionStatus\":false,\n \"buyerFields\":{\n \"buyerEmail\":\"customer@example.com\",\n \"buyerNotify\":false\n },\n \"paymentSubtotals\": {\n \"BTC\":114700\n },\n \"paymentTotals\": {\n \"BTC\":118400\n },\n \"transactionCurrency\": \"BTC\",\n \"amountPaid\": \"1025900\",\n \"exchangeRates\": {\n \"BTC\": {\n \"EUR\": 8721.690715789999,\n \"USD\": 10817.99\n }\n }\n}";
await FillUsers(vm);
return View("PointOfSale/UpdatePointOfSale", vm); return View("PointOfSale/UpdatePointOfSale", vm);
} }
@ -655,6 +657,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
} }
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
await FillUsers(vm);
return View("PointOfSale/UpdatePointOfSale", vm); return View("PointOfSale/UpdatePointOfSale", vm);
} }
@ -715,5 +718,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
private StoreData GetCurrentStore() => HttpContext.GetStoreData(); private StoreData GetCurrentStore() => HttpContext.GetStoreData();
private AppData GetCurrentApp() => HttpContext.GetAppData(); private AppData GetCurrentApp() => HttpContext.GetAppData();
private async Task FillUsers(UpdatePointOfSaleViewModel vm)
{
var users = await _storeRepository.GetStoreUsers(GetCurrentStore().Id);
vm.StoreUsers = users.Select(u => (u.Id, u.Email, u.StoreRole.Role)).ToDictionary(u => u.Id, u => $"{u.Email} ({u.Role})");
}
} }
} }

View file

@ -68,6 +68,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string CustomTipPercentages { get; set; } public string CustomTipPercentages { get; set; }
public string Id { get; set; } public string Id { get; set; }
public Dictionary<string, string> StoreUsers { get; set; }
[Display(Name = "Redirect invoice to redirect url automatically after paid")] [Display(Name = "Redirect invoice to redirect url automatically after paid")]
public string RedirectAutomatically { get; set; } = string.Empty; public string RedirectAutomatically { get; set; } = string.Empty;

View file

@ -12,6 +12,7 @@
ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id); ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id);
Csp.UnsafeEval(); Csp.UnsafeEval();
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId); var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
var posPath = Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id });
} }
@section PageHeadContent { @section PageHeadContent {
@ -23,14 +24,7 @@
@section PageFootContent { @section PageFootContent {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script> <script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/pos/admin.js" asp-append-version="true"></script> <script src="~/pos/admin.js" asp-append-version="true"></script>
<script>
const qrApp = initQRShow({ title: "Scan to view the Point of Sale" });
const posViewUrl = @Safe.Json($"{Context.Request.GetAbsoluteRoot()}{Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id })}");
delegate('click', '#displayQRCode', () => { qrApp.showData(posViewUrl); });
</script>
} }
<form method="post" permissioned="@Policies.CanModifyStoreSettings"> <form method="post" permissioned="@Policies.CanModifyStoreSettings">
@ -46,7 +40,7 @@
{ {
<div class="btn-group" role="group" aria-label="View Point of Sale"> <div class="btn-group" role="group" aria-label="View Point of Sale">
<a class="btn btn-secondary" asp-controller="UIPointOfSale" asp-action="ViewPointOfSale" asp-route-appId="@Model.Id" id="ViewApp" target="_blank">View</a> <a class="btn btn-secondary" asp-controller="UIPointOfSale" asp-action="ViewPointOfSale" asp-route-appId="@Model.Id" id="ViewApp" target="_blank">View</a>
<button type="button" class="btn btn-secondary px-3 d-inline-flex align-items-center" id="displayQRCode"> <button type="button" class="btn btn-secondary px-3 d-inline-flex align-items-center" data-bs-toggle="modal" data-bs-target="#OpenPosModal">
<vc:icon symbol="qr-code" /> <vc:icon symbol="qr-code" />
</button> </button>
</div> </div>
@ -316,6 +310,19 @@
</div> </div>
<partial name="_Confirm" model="@(new ConfirmModel("Delete app", "This app will be removed from this store.", "Delete"))" permission="@Policies.CanModifyStoreSettings" /> <partial name="_Confirm" model="@(new ConfirmModel("Delete app", "This app will be removed from this store.", "Delete"))" permission="@Policies.CanModifyStoreSettings" />
<partial name="ShowQR" />
<div class="modal fade" id="OpenPosModal" tabindex="-1" aria-labelledby="ConfirmTitle" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Scan the QR code to open the Point of Sale</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body pt-0">
<component type="typeof(BTCPayServer.Blazor.PosLoginCode)" render-mode="ServerPrerendered" param-Users="@Model.StoreUsers" param-PosPath="@posPath" />
</div>
</div>
</div>
</div>

View file

@ -1,43 +1,11 @@
@model string @inject UserManager<ApplicationUser> UserManager;
@{ @{
ViewData.SetActivePage(ManageNavPages.LoginCodes, "Login Codes"); ViewData.SetActivePage(ManageNavPages.LoginCodes, "Login Codes");
} }
<div class="sticky-header"> <div class="sticky-header">
<h2 text-translate="true">@ViewData["Title"]</h2> <h2 text-translate="true">@ViewData["Title"]</h2>
<a id="page-primary" class="btn btn-primary" asp-action="LoginCodes">Regenerate code</a>
</div> </div>
<partial name="_StatusMessage" /> <partial name="_StatusMessage" />
<p>Easily log into BTCPay Server on another device using a simple login code from an already authenticated device.</p> <p>Easily log into BTCPay Server on another device using a simple login code from an already authenticated device.</p>
<div class="d-inline-flex flex-column" style="width:256px"> <component type="typeof(BTCPayServer.Blazor.UserLoginCode)" render-mode="ServerPrerendered" param-UserId="@UserManager.GetUserId(User)" param-id="@("LoginCode")"/>
<div class="qr-container mb-2">
<vc:qr-code data="@Model"/>
</div>
<input type="hidden" value="@Model" id="logincode">
<p class="text-center text-muted mb-1" id="progress">Valid for 60 seconds</p>
<div class="progress only-for-js" data-bs-toggle="tooltip" data-bs-placement="top">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width:100%" id="progressbar"></div>
</div>
</div>
@section PageFootContent
{
<link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true"/>
<script src="~/js/copy-to-clipboard.js"></script>
<script>
const SECONDS = 60
const progress = document.getElementById('progress')
const progressbar = document.getElementById('progressbar')
let remaining = SECONDS
const update = () => {
remaining--
const percent = Math.round(remaining/SECONDS * 100)
progress.innerText = `Valid for ${remaining} seconds`
progressbar.style.width = `${percent}%`
if (percent < 15) progressbar.classList.add('bg-warning')
if (percent < 1) document.getElementById('regeneratecode').click()
}
setInterval(update, 1000)
update()
</script>
}