Add OTP feature to redesign
parent
0b85c8a7f6
commit
d7c00945a7
@ -0,0 +1,188 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
var totp = new jsOTP.totp();
|
||||
|
||||
function parseOTP(jsontext, reload) {
|
||||
var keys = [];
|
||||
if (jsontext !== null && jsontext != "") {
|
||||
var keys = JSON.parse(jsontext || "[]");
|
||||
if (keys.length > 0) {
|
||||
//$("#nokeys").css("display", "none");
|
||||
}
|
||||
var tmpldata = [];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var code = totp.getOtp(keys[i]["secret"]);
|
||||
// Escape HTML characters
|
||||
var label = $('<div/>').html(keys[i]["label"]).html();
|
||||
var issuer = $('<div/>').text(keys[i]["issuer"]).html();
|
||||
tmpldata.push({
|
||||
code: code,
|
||||
secret: keys[i]["secret"],
|
||||
label: label,
|
||||
issuer: issuer,
|
||||
index: i
|
||||
});
|
||||
}
|
||||
router.navigate("/otp", {
|
||||
context: {
|
||||
otpcodes: tmpldata
|
||||
},
|
||||
reloadCurrent: (reload == true ? true : false)
|
||||
});
|
||||
setInterval(function () {
|
||||
refreshCountdown();
|
||||
refreshCodes();
|
||||
}, 1000);
|
||||
|
||||
refreshCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
function loadOTPPage(reload) {
|
||||
var ls_text = localStorage.getItem("otp");
|
||||
if (ls_text === null || ls_text == "") {
|
||||
// Recover from NativeStorage
|
||||
NativeStorage.getItem("otp", function (data) {
|
||||
localStorage.setItem("otp");
|
||||
parseOTP(data, reload);
|
||||
}, function (error) {
|
||||
parseOTP("[]", reload);
|
||||
});
|
||||
} else {
|
||||
parseOTP(ls_text, reload);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCountdown() {
|
||||
var percent = ((30 - ((new Date).getSeconds() % 30)) / 30) * 100;
|
||||
$("#countdown").css("width", percent + "%");
|
||||
}
|
||||
|
||||
function refreshCodes() {
|
||||
$(".otpcard").each(function () {
|
||||
var code = totp.getOtp($(this).data("secret"));
|
||||
$(".otpcode", this).text(code);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteOTPCode(index) {
|
||||
var label = $(".otpcard[data-index=" + index + "] .otplabel").text();
|
||||
navigator.notification.confirm("Delete auth key? This cannot be undone, so make sure you don't need this key to login anymore!", function (result) {
|
||||
if (result != 1) {
|
||||
return;
|
||||
}
|
||||
var keys = JSON.parse(localStorage.getItem("otp"));
|
||||
keys.splice(index, 1);
|
||||
localStorage.setItem("otp", JSON.stringify(keys));
|
||||
NativeStorage.setItem("otp", JSON.stringify(keys));
|
||||
loadOTPPage(true);
|
||||
}, "Delete " + label + "?");
|
||||
}
|
||||
|
||||
function addOTPCode(key, label, issuer) {
|
||||
if (key == "") {
|
||||
navigator.notification.alert("Missing secret key.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
key = key.toUpperCase();
|
||||
/* Thanks to https://stackoverflow.com/a/27362880 for the regex */
|
||||
if (!key.match(/^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}={6}|[A-Z2-7]{4}={4}|[A-Z2-7]{5}={3}|[A-Z2-7]{7}=)?$/)) {
|
||||
navigator.notification.alert("Secret key is not valid base32.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
if (label == "") {
|
||||
navigator.notification.alert("Missing label.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
var ls_text = localStorage.getItem("otp");
|
||||
var keys = [];
|
||||
if (ls_text != null && ls_text != "") {
|
||||
keys = JSON.parse(ls_text || "[]");
|
||||
}
|
||||
|
||||
keys.push({"secret": key, "label": label, "issuer": issuer});
|
||||
localStorage.setItem("otp", JSON.stringify(keys));
|
||||
NativeStorage.setItem("otp", JSON.stringify(keys));
|
||||
|
||||
app.toast.create({
|
||||
text: '2-factor key saved.',
|
||||
closeButton: true,
|
||||
}).open();
|
||||
loadOTPPage(true);
|
||||
}
|
||||
|
||||
function scanOTPCode() {
|
||||
try {
|
||||
cordova.plugins.barcodeScanner.scan(
|
||||
function (result) {
|
||||
if (!result.cancelled) {
|
||||
try {
|
||||
var url = decodeURI(result.text);
|
||||
} catch (e) {
|
||||
navigator.notification.alert("Could not decode OTP URI.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("otpauth://")) {
|
||||
navigator.notification.alert("Invalid OTP code. Try again.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("otpauth://totp/")) {
|
||||
navigator.notification.alert("Unsupported key type.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
var stripped = url.replace("otpauth://totp/", "");
|
||||
var params = stripped.split("?")[1].split("&");
|
||||
var label = stripped.split("?")[0];
|
||||
var secret = "";
|
||||
var issuer = "";
|
||||
for (var i = 0; i < params.length; i++) {
|
||||
var param = params[i].split("=");
|
||||
if (param[0] == "secret") {
|
||||
secret = param[1].toUpperCase();
|
||||
} else if (param[0] == "issuer") {
|
||||
issuer = param[1];
|
||||
} else if (param[0] == "algorithm" && param[1].toLowerCase() != "sha1") {
|
||||
navigator.notification.alert("Unsupported hash algorithm.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
} else if (param[0] == "digits" && param[1] != "6") {
|
||||
navigator.notification.alert("Unsupported digit count.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
} else if (param[0] == "period" && param[1] != "30") {
|
||||
navigator.notification.alert("Unsupported period.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
secret = decodeURIComponent(secret);
|
||||
issuer = decodeURIComponent(issuer);
|
||||
label = decodeURIComponent(label);
|
||||
} catch (e) {
|
||||
navigator.notification.alert("Could not decode OTP URI.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
addOTPCode(secret, label, issuer);
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
navigator.notification.alert("Scanning failed: " + error, null, "Error", 'Dismiss');
|
||||
},
|
||||
{
|
||||
"showFlipCameraButton": false,
|
||||
"prompt": "Scan OTP QR code."
|
||||
}
|
||||
);
|
||||
} catch (ex) {
|
||||
navigator.notification.alert(ex.message, null, "Error", 'Dismiss');
|
||||
}
|
||||
}
|
||||
|
||||
function addManualOTP() {
|
||||
var label = $("#addotppopup #label").val();
|
||||
var secret = $("#addotppopup #secret").val().replace(/\s+/g, '');
|
||||
app.popup.close("#addotppopup");
|
||||
addOTPCode(secret, label, "");
|
||||
}
|
@ -1,149 +0,0 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<div class="panel panel-blue">
|
||||
<div class="panel-body">
|
||||
<p></p>
|
||||
<span class="btn btn-primary btn-lg" onclick="scanCode()" id="scancodebtn">
|
||||
<i class="fa fa-qrcode"></i> Scan QR Code
|
||||
</span>
|
||||
<span class="btn btn-link" onclick="manualshow()" id="manualaddbtn">
|
||||
Manual Entry
|
||||
</span>
|
||||
<div id="manual_add" class="well" style="display: none;">
|
||||
<input type="text" id="key" class="form-control" placeholder="Secret key" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> <br />
|
||||
<input type="text" id="label" class="form-control" placeholder="Label" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> <br />
|
||||
<input type="text" id="issuer" class="form-control" placeholder="Issuer" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||
<br />
|
||||
<div class="btn btn-primary" onclick="manualadd()">
|
||||
Continue
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$("#key").on("keyup", function () {
|
||||
if (window.getSelection().toString() !== '') {
|
||||
return;
|
||||
}
|
||||
var text = $('#key').val().replace(/\s+/g, '');
|
||||
var formatted = "";
|
||||
for (var i = 1; i <= text.length; i++) {
|
||||
formatted = formatted + text[i - 1];
|
||||
if (i % 4 == 0 && i > 1 && i < text.length) {
|
||||
// add a space every 5 characters,
|
||||
// unless it's the first character
|
||||
// or the last character
|
||||
formatted = formatted + " ";
|
||||
}
|
||||
}
|
||||
$('#key').val(formatted.toUpperCase());
|
||||
});
|
||||
|
||||
function manualadd() {
|
||||
var key = $('#key').val().replace(/\s+/g, '');
|
||||
var label = $('#label').val();
|
||||
var issuer = $('#issuer').val();
|
||||
addOTP(key, label, issuer);
|
||||
}
|
||||
|
||||
function manualshow() {
|
||||
$('#manual_add').css('display', 'block');
|
||||
}
|
||||
|
||||
function addOTP(key, label, issuer) {
|
||||
if (key == "") {
|
||||
navigator.notification.alert("Missing secret key.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
key = key.toUpperCase();
|
||||
/* Thanks to https://stackoverflow.com/a/27362880 for the regex */
|
||||
if (!key.match(/^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}={6}|[A-Z2-7]{4}={4}|[A-Z2-7]{5}={3}|[A-Z2-7]{7}=)?$/)) {
|
||||
navigator.notification.alert("Secret key is not valid base32.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
if (label == "") {
|
||||
navigator.notification.alert("Missing label.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
var ls_text = localStorage.getItem("otp");
|
||||
var keys = [];
|
||||
if (ls_text != null && ls_text != "") {
|
||||
keys = JSON.parse(ls_text || "[]");
|
||||
}
|
||||
|
||||
keys.push({"secret": key, "label": label, "issuer": issuer});
|
||||
localStorage.setItem("otp", JSON.stringify(keys));
|
||||
NativeStorage.setItem("otp", JSON.stringify(keys));
|
||||
navigator.notification.alert("2-factor key saved.", null, "Key added", 'Dismiss');
|
||||
openscreen("otp");
|
||||
}
|
||||
|
||||
function scanCode() {
|
||||
try {
|
||||
cordova.plugins.barcodeScanner.scan(
|
||||
function (result) {
|
||||
if (!result.cancelled) {
|
||||
try {
|
||||
var url = decodeURI(result.text);
|
||||
} catch (e) {
|
||||
navigator.notification.alert("Could not decode OTP URI.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("otpauth://")) {
|
||||
navigator.notification.alert("Invalid OTP code. Try again.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("otpauth://totp/")) {
|
||||
navigator.notification.alert("Unsupported key type.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
var stripped = url.replace("otpauth://totp/", "");
|
||||
var params = stripped.split("?")[1].split("&");
|
||||
var label = stripped.split("?")[0];
|
||||
var secret = "";
|
||||
var issuer = "";
|
||||
for (var i = 0; i < params.length; i++) {
|
||||
var param = params[i].split("=");
|
||||
if (param[0] == "secret") {
|
||||
secret = param[1].toUpperCase();
|
||||
} else if (param[0] == "issuer") {
|
||||
issuer = param[1];
|
||||
} else if (param[0] == "algorithm" && param[1].toLowerCase() != "sha1") {
|
||||
navigator.notification.alert("Unsupported hash algorithm.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
} else if (param[0] == "digits" && param[1] != "6") {
|
||||
navigator.notification.alert("Unsupported digit count.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
} else if (param[0] == "period" && param[1] != "30") {
|
||||
navigator.notification.alert("Unsupported period.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
secret = decodeURIComponent(secret);
|
||||
issuer = decodeURIComponent(issuer);
|
||||
label = decodeURIComponent(label);
|
||||
} catch (e) {
|
||||
navigator.notification.alert("Could not decode OTP URI.", null, "Error", 'Dismiss');
|
||||
return;
|
||||
}
|
||||
addOTP(secret, label, issuer);
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
navigator.notification.alert("Scanning failed: " + error, null, "Error", 'Dismiss');
|
||||
},
|
||||
{
|
||||
"showFlipCameraButton": false,
|
||||
"prompt": "Scan OTP QR code."
|
||||
}
|
||||
);
|
||||
} catch (ex) {
|
||||
navigator.notification.alert(ex.message, null, "Error", 'Dismiss');
|
||||
}
|
||||
}
|
||||
|
||||
setnavbar("app", "Add Auth Key", "otp");
|
||||
</script>
|
@ -1,91 +0,0 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<div class="progress">
|
||||
<div class="progress-bar" id="countdown" style="width: 0%;"></div>
|
||||
</div>
|
||||
<div class="circle-btn btn btn-light-blue" onclick="openscreen('addotp', 'FADE');">
|
||||
<img src="icons/ic_add.svg" />
|
||||
</div>
|
||||
<div id="nokeys">
|
||||
<div class="app-dock-container">
|
||||
<div class="app-dock" id="app-dock">
|
||||
<div style="margin-top: 50px; text-align: center; font-size: 120%;">
|
||||
<img src="img/nokeys.svg" alt="" style="max-width: 80%; max-height: 25%;" />
|
||||
<br /><br />
|
||||
<p style="max-width: 80%; margin: 0 auto;">You haven't added any authentication keys yet. Press <i class="fa fa-plus"></i> to add one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group" id="codelist">
|
||||
</div>
|
||||
<script src="js/jsOTP.min.js"></script>
|
||||
<script>
|
||||
setnavbar("otp", "", "home");
|
||||
|
||||
var totp = new jsOTP.totp();
|
||||
|
||||
function load(jsontext) {
|
||||
var keys = [];
|
||||
if (jsontext !== null && jsontext != "") {
|
||||
var keys = JSON.parse(jsontext || "[]");
|
||||
if (keys.length > 0) {
|
||||
$("#nokeys").css("display", "none");
|
||||
}
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var code = totp.getOtp(keys[i]["secret"]);
|
||||
// Escape HTML characters
|
||||
var label = $('<div/>').html(keys[i]["label"]).html();
|
||||
var issuer = $('<div/>').text(keys[i]["issuer"]).html();
|
||||
$("#codelist").append("<div class=\"list-group-item\" id=\"codeitem_" + i + "\">"
|
||||
+ "<span class=\"pull-right\" style=\"color: red;\" onclick=\"deleteCode(" + i + ")\"><i class=\"fa fa-trash-o\"></i></span>"
|
||||
+ "<p class=\"h6\">" + label + "</p>"
|
||||
+ "<div class=\"h3 code\" style=\"font-weight: bold;\">" + code + "</div>"
|
||||
+ "<p class=\"small\">" + issuer + "</p>"
|
||||
+ "</div>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ls_text = localStorage.getItem("otp");
|
||||
if (ls_text === null || ls_text == "") {
|
||||
// Recover from NativeStorage
|
||||
NativeStorage.getItem("otp", function (data) {
|
||||
localStorage.setItem("otp");
|
||||
load(data);
|
||||
});
|
||||
} else {
|
||||
load(ls_text);
|
||||
}
|
||||
|
||||
function refreshCountdown() {
|
||||
var percent = ((30 - ((new Date).getSeconds() % 30)) / 30) * 100;
|
||||
$("#countdown").css("width", percent + "%");
|
||||
}
|
||||
|
||||
function refreshCodes() {
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var code = totp.getOtp(keys[i]["secret"]);
|
||||
$("#codelist #codeitem_" + i + " .code").text(code);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCode(index) {
|
||||
navigator.notification.confirm("Delete auth key? This cannot be undone, so make sure you don't need this key to login anymore!", function (result) {
|
||||
if (result != 1) {
|
||||
return;
|
||||
}
|
||||
keys.splice(index, 1);
|
||||
localStorage.setItem("otp", JSON.stringify(keys));
|
||||
openscreen("otp");
|
||||
}, "Delete " + keys[index]["label"] + "?");
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
refreshCountdown();
|
||||
refreshCodes();
|
||||
}, 1000);
|
||||
|
||||
refreshCountdown();
|
||||
</script>
|
@ -1,44 +0,0 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<br />
|
||||
<div class="panel panel-blue">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Setup</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>Almost done!
|
||||
<br /><br />
|
||||
Please enter your password, then press Finish.
|
||||
</p>
|
||||
<input type="password" id="passbox" class="form-control" placeholder="Password" style="display: block;" />
|
||||
<br />
|
||||
<div class="btn btn-primary" onclick="savePassword()"><i class="fa fa-check"></i> Finish</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function savePassword() {
|
||||
$.post(setupsyncurl, {
|
||||
username: setupusername,
|
||||
key: setupsynckey,
|
||||
password: $('#passbox').val(),
|
||||
action: "check_password"
|
||||
}, function (data) {
|
||||
if (data.status === 'OK') {
|
||||
setuppassword = $('#passbox').val();
|
||||
var accid = addaccount(setupusername, setuppassword, setupsyncurl, setupsynckey);
|
||||
switchaccount(accid);
|
||||
localStorage.setItem("firstrun", "1");
|
||||
navigator.notification.alert("Account connected!", null, "Success", 'Continue');
|
||||
restartApplication();
|
||||
} else {
|
||||
navigator.notification.alert(data.msg, null, "Error", 'Dismiss');
|
||||
}
|
||||
}, "json").fail(function () {
|
||||
navigator.notification.alert("Could not connect to the server. Try again later.", null, "Error", 'Dismiss');
|
||||
});
|
||||
}
|
||||
|
||||
setnavbar("setup");
|
||||
</script>
|
@ -0,0 +1,89 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<div class="page" data-name="otp">
|
||||
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="left">
|
||||
<a href="#" class="link icon-only back">
|
||||
<i class="icon icon-back"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="title">Auth Keys</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="popup" id="addotppopup">
|
||||
<div class="view">
|
||||
<div class="page">
|
||||
<div class="page-content">
|
||||
<div class="block">
|
||||
<div class="list">
|
||||
<ul>
|
||||
<li class="item-content item-input">
|
||||
<div class="item-inner">
|
||||
<div class="item-input-wrap">
|
||||
<input type="text" id="label" placeholder="Label" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||
<span class="input-clear-button"></span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="item-content item-input">
|
||||
<div class="item-inner">
|
||||
<div class="item-input-wrap">
|
||||
<input type="text" id="secret" placeholder="Secret" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||
<span class="input-clear-button"></span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="button button-fill" onclick="addManualOTP()">
|
||||
Save
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fab fab-right-bottom">
|
||||
<a href="#">
|
||||
<!-- First icon is visible when Speed Dial actions are closed -->
|
||||
<i class="material-icons">add</i>
|
||||
<!-- Second icon is visible when Speed Dial actions are opened -->
|
||||
<i class="material-icons">close</i>
|
||||
</a>
|
||||
<!-- Speed Dial action buttons -->
|
||||
<div class="fab-buttons fab-buttons-top">
|
||||
<a href="#" data-popup="#addotppopup" class="fab-label-button popup-open">
|
||||
<span><i class="fas fa-edit"></i></span>
|
||||
<span class="fab-label">Manual</span>
|
||||
</a>
|
||||
<a href="#" onclick="scanOTPCode()" class="fab-label-button">
|
||||
<span><i class="fas fa-qrcode"></i></span>
|
||||
<span class="fab-label">QR code</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
{{#each otpcodes}}
|
||||
<div class="card otpcard" data-secret="{{secret}}" data-index="{{index}}">
|
||||
<div class="card-content card-content-padding">
|
||||
<div>
|
||||
<h1 class="otpcode">{{code}}</h1>
|
||||
<a class="link float-right" onclick="deleteOTPCode('{{index}}')">
|
||||
<i class="material-icons">delete</i>
|
||||
</a>
|
||||
</div>
|
||||
<p class="otplabel">{{label}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
</div>
|
Loading…
Reference in New Issue