KYC revocation changes

-- listing approved addresses
-- revocation is done with selection UI
-- added search option
This commit is contained in:
sairaj mote 2023-04-01 02:52:56 +05:30
parent 82e2b0993e
commit 80b7d70d86
7 changed files with 473 additions and 101 deletions

View File

@ -367,6 +367,10 @@ sm-chip[selected] {
--background: var(--accent-color); --background: var(--accent-color);
} }
sm-input {
--border-radius: 0.5rem;
}
.multi-state-button { .multi-state-button {
display: grid; display: grid;
text-align: center; text-align: center;
@ -381,6 +385,43 @@ sm-chip[selected] {
width: 100%; width: 100%;
} }
#confirmation_popup,
#prompt_popup {
flex-direction: column;
}
#confirmation_popup h4,
#prompt_popup h4 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
#confirmation_popup .flex,
#prompt_popup .flex {
margin-top: 1rem;
}
.popup__header {
position: relative;
display: grid;
gap: 0.5rem;
width: 100%;
padding: 0 1.5rem;
align-items: center;
}
.popup__header > * {
grid-row: 1;
}
.popup__header h3,
.popup__header h4 {
grid-column: 1/-1;
justify-self: center;
align-self: center;
}
.popup__header__close {
grid-column: 1;
margin-left: -1rem;
justify-self: flex-start;
}
#loading { #loading {
position: fixed; position: fixed;
display: grid; display: grid;
@ -414,6 +455,10 @@ header {
.address-input sm-input { .address-input sm-input {
width: 100%; width: 100%;
} }
.address-input button {
margin-bottom: auto;
height: 3.2rem;
}
#verification_result { #verification_result {
display: grid; display: grid;
@ -444,6 +489,31 @@ header {
min-width: 5rem; min-width: 5rem;
} }
.revoke-card {
list-style: none;
}
.revoke-card label {
gap: 1rem;
padding: 0.8rem 0;
font-weight: 500;
cursor: pointer;
}
.revoke-card input {
accent-color: var(--accent-color);
height: 1.3em;
width: 1.3em;
}
.revoke-card span:last-of-type {
font-size: 0.9rem;
color: rgba(var(--text-color), 0.8);
font-weight: 400;
}
@media screen and (min-width: 40rem) {
sm-popup {
--width: 24rem;
}
}
.error { .error {
color: var(--danger-color); color: var(--danger-color);
} }

2
css/main.min.css vendored

File diff suppressed because one or more lines are too long

View File

@ -350,6 +350,9 @@ sm-chip {
--background: var(--accent-color); --background: var(--accent-color);
} }
} }
sm-input {
--border-radius: 0.5rem;
}
.multi-state-button { .multi-state-button {
display: grid; display: grid;
text-align: center; text-align: center;
@ -363,6 +366,41 @@ sm-chip {
width: 100%; width: 100%;
} }
} }
#confirmation_popup,
#prompt_popup {
flex-direction: column;
h4 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.flex {
margin-top: 1rem;
}
}
.popup__header {
position: relative;
display: grid;
gap: 0.5rem;
width: 100%;
padding: 0 1.5rem;
align-items: center;
& > * {
grid-row: 1;
}
h3,
h4 {
grid-column: 1/-1;
justify-self: center;
align-self: center;
}
&__close {
grid-column: 1;
margin-left: -1rem;
justify-self: flex-start;
}
}
#loading { #loading {
position: fixed; position: fixed;
display: grid; display: grid;
@ -394,6 +432,10 @@ header {
sm-input { sm-input {
width: 100%; width: 100%;
} }
button {
margin-bottom: auto;
height: 3.2rem;
}
} }
#verification_section { #verification_section {
@ -429,6 +471,32 @@ header {
min-width: 5rem; min-width: 5rem;
} }
} }
#approved_kyc_addresses {
}
.revoke-card {
list-style: none;
label {
gap: 1rem;
padding: 0.8rem 0;
font-weight: 500;
cursor: pointer;
}
input {
accent-color: var(--accent-color);
height: 1.3em;
width: 1.3em;
}
span:last-of-type {
font-size: 0.9rem;
color: rgba(var(--text-color), 0.8);
font-weight: 400;
}
}
@media screen and (min-width: 40rem) {
sm-popup {
--width: 24rem;
}
}
.error { .error {
color: var(--danger-color); color: var(--danger-color);
} }

View File

@ -116,45 +116,6 @@
router.addRoute('verify', async state => { router.addRoute('verify', async state => {
verify(state.params.address) verify(state.params.address)
}) })
floGlobals.approvedKyc = {};
function getApprovedKycs() {
return new Promise((resolve, reject) => {
const aggregatorTxs = Object.keys(floGlobals.approvedKycAggregators).map(aggregator => {
return floBlockchainAPI.readAllTxs(aggregator);
});
Promise.all(aggregatorTxs).then(aggregatorData => {
aggregatorData = aggregatorData.flat(1)
.filter(tx => tx.vin[0].addr in floGlobals.approvedKycAggregators && tx.floData.startsWith('KYC'))
.sort((a, b) => a.time - b.time);
for (const tx of aggregatorData) {
const { floData, time, vin, vout } = tx;
const [service, operationType, operationData, validity] = floData.split('|');
switch (operationType) {
case 'APPROVE_KYC':
operationData.split(',').forEach(address => {
floGlobals.approvedKyc[address] = {
validFrom: time * 1000,
validTo: validity || Date.now() + 10000000,
verifiedBy: vin[0].addr
};
});
break;
case 'REVOKE_KYC':
operationData.split(',').forEach(address => {
floGlobals.approvedKyc[address].validTo = time * 1000;
floGlobals.approvedKyc[address].revokedBy = vin[0].addr;
});
break;
default:
return;
}
}
resolve();
}).catch(e => {
reject(e);
})
})
}
function verify(address) { function verify(address) {
if (address) { if (address) {
if (getRef('address_verify').value.trim() !== address) if (getRef('address_verify').value.trim() !== address)

View File

@ -15,6 +15,14 @@
<body> <body>
<sm-notifications id="notification_drawer"></sm-notifications> <sm-notifications id="notification_drawer"></sm-notifications>
<sm-popup id="confirmation_popup">
<h4 id="confirm_title"></h4>
<p id="confirm_message"></p>
<div class="flex align-center gap-0-5 margin-left-auto">
<button class="button cancel-button">Cancel</button>
<button class="button button--primary confirm-button">OK</button>
</div>
</sm-popup>
<div id="loading"> <div id="loading">
<sm-spinner></sm-spinner> <sm-spinner></sm-spinner>
<h4>Loading</h4> <h4>Loading</h4>
@ -60,17 +68,18 @@
<sm-chip value="revoke">Revoke</sm-chip> <sm-chip value="revoke">Revoke</sm-chip>
</sm-chips> </sm-chips>
</div> </div>
<sm-form id="kyc_form">
<div class="grid gap-0-5"> <div class="grid gap-0-5">
<h4>Aggregator credentials</h4> <h4>Aggregator credentials</h4>
<sm-input id="aggregator_private_key" placeholder="Private key" error-text="Invalid private key" <sm-input id="aggregator_private_key" placeholder="Private key" error-text="Invalid private key"
type="password" required animate></sm-input> type="password" required animate></sm-input>
<p id="aggregator_balance" class="hidden"></p> <p id="aggregator_balance" class="hidden"></p>
</div> </div>
<div id="kyc_mode_wrapper">
<sm-form>
<div class="grid gap-0-5"> <div class="grid gap-0-5">
<h4 id="address_manage_title">KYCs to be approved</h4> <h4>KYCs to be approved</h4>
<p class="margin-bottom-1"> <p class="margin-bottom-1">
Enter the addresses of the KYCs you want to approve or revoke. You can add multiple addresses by Enter the addresses of the KYCs you want to approve. You can add multiple addresses by
clicking the "Add address" button. clicking the "Add address" button.
</p> </p>
<ul id="kyc_addresses_container" class="grid gap-0-3"></ul> <ul id="kyc_addresses_container" class="grid gap-0-3"></ul>
@ -78,10 +87,46 @@
address</button> address</button>
</div> </div>
<div class="multi-state-button"> <div class="multi-state-button">
<button id="submit_kyc" class="button button--primary" onclick="submitAddresses()" <button id="submit_kyc" class="button button--primary" onclick="approveAddresses()"
type="submit">Approve</button> type="submit" disabled>Approve</button>
</div> </div>
</sm-form> </sm-form>
<div class="grid gap-1 hidden">
<div class="grid gap-0-5">
<h4>Approved KYC addresses</h4>
<p>
To revoke a KYC, select one or multiple KYCs and click on the 'Revoke selected' button.
</p>
</div>
<div class="grid gap-0-5"
style="position: sticky; top: 0; z-index: 2; background-color: rgba(var(--foreground-color),1);">
<sm-input id="search_approved" type="search" placeholder="Search address">
<svg class="icon" slot="icon" xmlns="http://www.w3.org/2000/svg" height="24px"
viewBox="0 0 24 24" width="24px" fill="#000000">
<path d="M0 0h24v24H0V0z" fill="none" />
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
</sm-input>
<div class="flex gap-0-5 align-center hidden">
<button class="button icon-only" title="Clear selection" onclick="clearSelection()">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24"
width="24px" fill="#000000">
<path d="M0 0h24v24H0V0z" fill="none" />
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</button>
<div id="selected_addresses"></div>
<div class="multi-state-button">
<button id="revoke_kyc_button" class="button button--primary margin-left-auto"
onclick="revokeAddresses()">Revoke selected</button>
</div>
</div>
</div>
<ul id="approved_kyc_addresses" class="grid gap-0-3"></ul>
</div>
</div>
</section> </section>
</article> </article>
<template id="key_address_template"> <template id="key_address_template">
@ -153,20 +198,110 @@
if (potentialTarget) potentialTarget.remove(); if (potentialTarget) potentialTarget.remove();
} }
} }
function showChildElement(id, index, options = {}) {
return new Promise((resolve) => {
const { mobileView = false, entry, exit } = options
const animOptions = {
duration: 150,
easing: 'ease',
fill: 'forwards'
}
const parent = typeof id === 'string' ? document.getElementById(id) : id;
const visibleElement = [...parent.children].find(elem => !elem.classList.contains(mobileView ? 'hide-on-mobile' : 'hidden'));
if (visibleElement === parent.children[index]) return;
visibleElement.getAnimations().forEach(anim => anim.cancel())
parent.children[index].getAnimations().forEach(anim => anim.cancel())
if (visibleElement) {
if (exit) {
visibleElement.animate(exit, animOptions).onfinish = () => {
visibleElement.classList.add(mobileView ? 'hide-on-mobile' : 'hidden')
parent.children[index].classList.remove(mobileView ? 'hide-on-mobile' : 'hidden')
if (entry)
parent.children[index].animate(entry, animOptions).onfinish = () => resolve()
}
} else {
visibleElement.classList.add(mobileView ? 'hide-on-mobile' : 'hidden')
parent.children[index].classList.remove(mobileView ? 'hide-on-mobile' : 'hidden')
resolve()
}
} else {
parent.children[index].classList.remove(mobileView ? 'hide-on-mobile' : 'hidden')
parent.children[index].animate(entry, animOptions).onfinish = () => resolve()
}
})
}
</script> </script>
<script> <script>
router.addRoute('', state => { router.addRoute('', state => {
console.log(state) console.log(state)
}) })
const render = {
approvedKycAddressCard(address) {
const floID = floCrypto.toFloID(address)
const btcID = btcOperator.convert.legacy2bech(floID)
return html`
<li class="revoke-card" data-address=${btcID} data-search-key=${`${btcID}-${floID}`}>
<label class="flex align-center">
<input type="checkbox" value=${btcID}/>
<div class="grid gap-0-3">
<span>BTC: ${btcID}</span>
<span>FLO: ${floID}</span>
</div>
</label>
</li>
`
},
approvedKycs() {
const approvedKycs = Object.keys(floGlobals.approvedKyc)
.filter(address => !floGlobals.approvedKyc[address].revokedBy)
.map(address => render.approvedKycAddressCard(address))
renderElem(getRef('approved_kyc_addresses'), html`${approvedKycs}`)
}
}
const addressesToRevoke = new Set()
getRef('kyc_type').addEventListener('change', e => { getRef('kyc_type').addEventListener('change', e => {
if (e.target.value === 'approve') { if (e.target.value === 'approve') {
getRef('address_manage_title').textContent = 'KYCs to be approved' showChildElement('kyc_mode_wrapper', 0)
getRef('submit_kyc').textContent = 'Approve'
} else { } else {
getRef('address_manage_title').textContent = 'KYCs to be revoked' showChildElement('kyc_mode_wrapper', 1)
getRef('submit_kyc').textContent = 'Revoke' render.approvedKycs()
} }
}) })
getRef('search_approved').addEventListener('input', e => {
const searchKey = e.target.value.toLowerCase()
getRef('approved_kyc_addresses').querySelectorAll('li').forEach(li => {
if (li.dataset.searchKey.toLowerCase().includes(searchKey)) {
li.classList.remove('hidden')
} else {
li.classList.add('hidden')
}
})
})
getRef('approved_kyc_addresses').addEventListener('change', e => {
if (e.target.checked) {
const addresses = processAddresses([...addressesToRevoke])
const floData = `KYC|REVOKE_KYC|${addresses.join(',')}`
if (floData.length > 1040) {
return notify('Maximum limit reached for this batch. Revoke selected and continue the process.', 'error')
}
addressesToRevoke.add(e.target.value)
} else {
addressesToRevoke.delete(e.target.value)
}
if (addressesToRevoke.size) {
getRef('selected_addresses').textContent = `${addressesToRevoke.size} selected`
getRef('revoke_kyc_button').parentElement.classList.remove('hidden')
} else {
getRef('selected_addresses').textContent = ``
getRef('revoke_kyc_button').parentElement.classList.add('hidden')
}
})
function clearSelection() {
addressesToRevoke.clear()
getRef('selected_addresses').textContent = ``
getRef('revoke_kyc_button').parentElement.classList.add('hidden')
getRef('approved_kyc_addresses').querySelectorAll('input').forEach(input => input.checked = false)
}
getRef('aggregator_private_key').addEventListener('input', e => { getRef('aggregator_private_key').addEventListener('input', e => {
checkBalance(floCrypto.getAddress(e.target.value.trim())) checkBalance(floCrypto.getAddress(e.target.value.trim()))
}) })
@ -231,43 +366,44 @@
addressInputObserver.observe(getRef('kyc_addresses_container'), { addressInputObserver.observe(getRef('kyc_addresses_container'), {
childList: true childList: true
}) })
function processAddresses() { function processAddresses(addresses = []) {
const addressInputs = document.querySelectorAll('.kyc-address') const uniqueAddresses = new Set()
const addresses = new Set() addresses.forEach(address => {
addressInputs.forEach(input => {
const address = input.value.trim()
if (address !== '') {
let equivalentBtcAddress = address let equivalentBtcAddress = address
if (floCrypto.validateFloID(address)) if (floCrypto.validateFloID(address))
equivalentBtcAddress = btcOperator.convert.legacy2bech(address) equivalentBtcAddress = btcOperator.convert.legacy2bech(address)
addresses.add(equivalentBtcAddress) uniqueAddresses.add(equivalentBtcAddress)
}
}) })
return [...addresses] return [...uniqueAddresses]
} }
function submitAddresses() { function approveAddresses() {
const aggregatorPrivateKey = getRef('aggregator_private_key').value.trim() const aggregatorPrivateKey = getRef('aggregator_private_key').value.trim()
if (!aggregatorPrivateKey) {
return notify('Enter aggregator private key', 'error')
}
const aggregatorAddress = floCrypto.getAddress(aggregatorPrivateKey) const aggregatorAddress = floCrypto.getAddress(aggregatorPrivateKey)
if (!floGlobals.approvedKycAggregators.hasOwnProperty(aggregatorAddress)) { if (!floGlobals.approvedKycAggregators.hasOwnProperty(aggregatorAddress)) {
notify('KYC aggregator address is not approved', 'error') return notify('KYC aggregator address is not approved', 'error')
return
} }
const addresses = processAddresses() const addressInputs = [...document.querySelectorAll('.kyc-address')]
.filter(input => input.value.trim() !== '')
const addresses = processAddresses(addressInputs)
if (addresses.length === 0) { if (addresses.length === 0) {
notify('No addresses to process', 'error') return notify('Enter at least one address to approve', 'error')
return
} }
const kycType = getRef('kyc_type').value const floData = `KYC|APPROVE_KYC|${addresses.join(',')}`
const floData = `KYC|${kycType === 'approve' ? 'APPROVE_KYC' : 'REVOKE_KYC'}|${addresses.join(',')}`
if (floData.length > 1040) { if (floData.length > 1040) {
notify('Too many addresses. Try removing one and resubmitting.', 'error') return notify('Too many addresses. Try removing one and resubmitting.', 'error')
return
} }
getConfirmation('Approve entered addresses?', {
confirmText: 'Approve',
}).then((res) => {
if (!res) return
buttonLoader(getRef('submit_kyc'), true) buttonLoader(getRef('submit_kyc'), true)
floBlockchainAPI.writeData(aggregatorAddress, floData, aggregatorPrivateKey, aggregatorAddress).then(txId => { floBlockchainAPI.writeData(aggregatorAddress, floData, aggregatorPrivateKey, aggregatorAddress).then(txId => {
notify(`${kycType === 'approve' ? 'Approval' : 'Revoke'} request submitted. TXID: ${txId}`, 'success') notify(`Approval request submitted. TXID: ${txId}`, 'success')
getRef('kyc_addresses_container').innerHTML = ''; getRef('kyc_addresses_container').innerHTML = '';
getRef('kyc_form').reset() getRef('aggregator_private_key').value = ''
addAddressInput() addAddressInput()
}).catch(e => { }).catch(e => {
notify(e, 'error') notify(e, 'error')
@ -277,10 +413,51 @@
checkBalance() checkBalance()
}, 1000) }, 1000)
}) })
})
}
function revokeAddresses() {
const aggregatorPrivateKey = getRef('aggregator_private_key').value.trim()
if (aggregatorPrivateKey === '') {
return notify('Please enter KYC aggregator private key', 'error')
}
const aggregatorAddress = floCrypto.getAddress(aggregatorPrivateKey)
if (!floGlobals.approvedKycAggregators.hasOwnProperty(aggregatorAddress)) {
return notify('KYC aggregator address is not approved', 'error')
}
const addresses = processAddresses([...addressesToRevoke])
if (addresses.length === 0) {
return notify('Select at least one address to revoke', 'error')
}
getConfirmation('Revoke selected addresses?', {
confirmText: 'Revoke',
}).then((res) => {
if (!res) return
const floData = `KYC|REVOKE_KYC|${addresses.join(',')}`
buttonLoader(getRef('revoke_kyc_button'), true)
floBlockchainAPI.writeData(aggregatorAddress, floData, aggregatorPrivateKey, aggregatorAddress).then(txId => {
notify(`Revoke request submitted. TXID: ${txId}`, 'success')
getRef('aggregator_private_key').value = ''
addressesToRevoke.forEach(address => {
if (!floGlobals.approvedKyc.hasOwnProperty(address)) return
floGlobals.approvedKyc[address].revokedBy = aggregatorAddress
floGlobals.approvedKyc[address].validTo = Date.now()
})
addressesToRevoke.clear()
render.approvedKycs()
}).catch(e => {
notify(e, 'error')
}).finally(() => {
buttonLoader(getRef('revoke_kyc_button'), false)
setTimeout(() => {
checkBalance()
}, 1000)
})
})
} }
window.onload = async () => { window.onload = async () => {
try { try {
await getApprovedAggregators() await getApprovedAggregators()
await getApprovedKycs()
router.routeTo(window.location.hash) router.routeTo(window.location.hash)
getRef('aggregator_private_key').customValidation = floCrypto.getPubKeyHex getRef('aggregator_private_key').customValidation = floCrypto.getPubKeyHex
addAddressInput() addAddressInput()

View File

@ -19,6 +19,61 @@ function getRef(elementId) {
} }
} }
} }
let zIndex = 50
// function required for popups or modals to appear
function openPopup(popupId, pinned) {
zIndex++
getRef(popupId).setAttribute('style', `z-index: ${zIndex}`)
return getRef(popupId).show({ pinned })
}
// hides the popup or modal
function closePopup(options = {}) {
if (popupStack.peek() === undefined)
return;
popupStack.peek().popup.hide(options)
}
// displays a popup for asking permission. Use this instead of JS confirm
const getConfirmation = (title, options = {}) => {
return new Promise(resolve => {
const { message = '', cancelText = 'Cancel', confirmText = 'OK', danger = false } = options
getRef('confirm_title').innerText = title;
getRef('confirm_message').innerText = message;
const cancelButton = getRef('confirmation_popup').querySelector('.cancel-button');
const confirmButton = getRef('confirmation_popup').querySelector('.confirm-button')
confirmButton.textContent = confirmText
cancelButton.textContent = cancelText
if (danger)
confirmButton.classList.add('button--danger')
else
confirmButton.classList.remove('button--danger')
const { closed } = openPopup('confirmation_popup')
confirmButton.onclick = () => {
closePopup({ payload: true })
}
cancelButton.onclick = () => {
closePopup()
}
closed.then((payload) => {
confirmButton.onclick = null
cancelButton.onclick = null
if (payload)
resolve(true)
else
resolve(false)
})
})
}
// Use when a function needs to be executed after user finishes changes
const debounce = (callback, wait) => {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback.apply(null, args);
}, wait);
};
}
class Router { class Router {
constructor(options = {}) { constructor(options = {}) {
@ -83,8 +138,8 @@ function loading(show = true) {
} }
} }
floGlobals.approvedKycAggregators = {};
function getApprovedAggregators() { function getApprovedAggregators() {
floGlobals.approvedKycAggregators = {};
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
floBlockchainAPI.readAllTxs(floGlobals.masterAddress).then(txs => { floBlockchainAPI.readAllTxs(floGlobals.masterAddress).then(txs => {
txs.filter(tx => floCrypto.isSameAddr(tx.vin[0].addr, floGlobals.masterAddress) && tx.floData.startsWith('KYC')) txs.filter(tx => floCrypto.isSameAddr(tx.vin[0].addr, floGlobals.masterAddress) && tx.floData.startsWith('KYC'))
@ -117,3 +172,43 @@ function getApprovedAggregators() {
}) })
}) })
} }
function getApprovedKycs() {
floGlobals.approvedKyc = {};
return new Promise((resolve, reject) => {
const aggregatorTxs = Object.keys(floGlobals.approvedKycAggregators).map(aggregator => {
return floBlockchainAPI.readAllTxs(aggregator);
});
Promise.all(aggregatorTxs).then(aggregatorData => {
aggregatorData = aggregatorData.flat(1)
.filter(tx => tx.vin[0].addr in floGlobals.approvedKycAggregators && tx.floData.startsWith('KYC'))
.sort((a, b) => a.time - b.time);
for (const tx of aggregatorData) {
const { floData, time, vin, vout } = tx;
const [service, operationType, operationData, validity] = floData.split('|');
switch (operationType) {
case 'APPROVE_KYC':
operationData.split(',').forEach(address => {
floGlobals.approvedKyc[address] = {
validFrom: time * 1000,
validTo: validity || Date.now() + 10000000,
verifiedBy: vin[0].addr
};
});
break;
case 'REVOKE_KYC':
operationData.split(',').forEach(address => {
floGlobals.approvedKyc[address].validTo = time * 1000;
floGlobals.approvedKyc[address].revokedBy = vin[0].addr;
});
break;
default:
return;
}
}
resolve();
}).catch(e => {
reject(e);
})
})
}

File diff suppressed because one or more lines are too long