Merge branch 'dev' into fix/lam-266/profits-calculation

This commit is contained in:
André Sá 2022-01-28 16:49:44 +00:00
commit 94089620ad
107 changed files with 1298 additions and 416 deletions

View file

@ -18,7 +18,7 @@ function setup (dataDir) {
function updateCore (coinRec, isCurrentlyRunning) {
common.logger.info('Updating Bitcoin Core. This may take a minute...')
if (isCurrentlyRunning) common.es(`sudo supervisorctl stop bitcoin`)
common.es(`sudo supervisorctl stop bitcoin`)
common.es(`curl -#o /tmp/bitcoin.tar.gz ${coinRec.url}`)
common.es(`tar -xzf /tmp/bitcoin.tar.gz -C /tmp/`)
@ -48,5 +48,6 @@ addresstype=p2sh-segwit
changetype=bech32
walletrbf=1
bind=0.0.0.0:8332
rpcport=8333`
rpcport=8333
listenonion=0`
}

View file

@ -45,6 +45,6 @@ maxconnections=40
keypool=10000
prune=4000
daemon=0
bind=0.0.0.0:8334
rpcport=8335`
bind=0.0.0.0:8335
rpcport=8336`
}

View file

@ -9,6 +9,8 @@ const _ = require('lodash/fp')
const { utils: coinUtils } = require('lamassu-coins')
const options = require('../options')
const settingsLoader = require('../new-settings-loader')
const wallet = require('../wallet')
const common = require('./common')
const doVolume = require('./do-volume')
@ -112,6 +114,35 @@ function plugin (crypto) {
return plugin
}
function getBlockchainSyncStatus (cryptoList) {
const installedCryptos = _.reduce((acc, value) => ({ ...acc, [value.cryptoCode]: isInstalledSoftware(value) && isInstalledVolume(value) }), {}, cryptoList)
return settingsLoader.loadLatest()
.then(settings => {
const installedButNotConfigured = []
const blockchainStatuses = _.reduce((acc, value) => {
const processStatus = common.es(`sudo supervisorctl status ${value.code} | awk '{ print $2 }'`).trim()
return acc.then(a => {
return wallet.checkBlockchainStatus(settings, value.cryptoCode)
.then(res => _.includes(value.cryptoCode, _.keys(installedCryptos)) ? Promise.resolve({ ...a, [value.cryptoCode]: res }) : Promise.resolve({ ...a }))
.catch(() => {
if (processStatus === 'RUNNING') {
installedButNotConfigured.push(value.cryptoCode)
return Promise.resolve({ ...a, [value.cryptoCode]: 'syncing' })
}
return Promise.resolve({ ...a })
})
})
},
Promise.resolve({}),
cryptoList
)
return Promise.all([blockchainStatuses, installedButNotConfigured])
})
.then(([blockchainStatuses, installedButNotConfigured]) => ({ blockchainStatuses, installedButNotConfigured }))
}
function run () {
const choices = _.flow([
_.filter(c => c.type !== 'erc-20'),
@ -129,13 +160,40 @@ function run () {
const questions = []
const validateAnswers = async (answers) => {
if (_.size(answers) > 2) return { message: `Please insert a maximum of two coins to install.`, isValid: false }
return getBlockchainSyncStatus(cryptos)
.then(({ blockchainStatuses, installedButNotConfigured }) => {
if (!_.isEmpty(installedButNotConfigured)) {
logger.warn(`Detected ${_.join(' and ', installedButNotConfigured)} installed on this machine, but couldn't establish connection. ${_.size(installedButNotConfigured) === 1 ? `Is this plugin` : `Are these plugins`} configured via admin?`)
}
const result = _.reduce((acc, value) => ({ ...acc, [value]: _.isNil(acc[value]) ? 1 : acc[value] + 1 }), {}, _.values(blockchainStatuses))
if (_.size(answers) + result.syncing > 2) {
return { message: `Installing these coins would pass the 2 parallel blockchain synchronization limit. Please try again with fewer coins or try again later.`, isValid: false }
}
if (result.syncing > 2) {
return { message: `There are currently more than 2 blockchains in their initial synchronization. Please try again later.`, isValid: false }
}
return { message: null, isValid: true }
})
}
questions.push({
type: 'checkbox',
name: 'crypto',
message: 'Which cryptocurrencies would you like to install?',
message: 'Which cryptocurrencies would you like to install?\nTo prevent server resource overloading, only TWO coins should be syncing simultaneously.\nMore coins can be installed after this process is over.',
choices
})
inquirer.prompt(questions)
.then(answers => processCryptos(answers.crypto))
.then(answers => Promise.all([validateAnswers(answers.crypto), answers]))
.then(([res, answers]) => {
if (res.isValid) {
return processCryptos(answers.crypto)
}
logger.error(res.message)
})
}

View file

@ -13,14 +13,14 @@ function setup (dataDir) {
const auth = `lamassuserver:${common.randomPass()}`
const config = buildConfig(auth)
common.writeFile(path.resolve(dataDir, coinRec.configFile), config)
const cmd = `/usr/local/bin/${coinRec.daemon} --data-dir ${dataDir} --config-file ${dataDir}/${coinRec.configFile}`
const walletCmd = `/usr/local/bin/${coinRec.wallet} --stagenet --rpc-login ${auth} --daemon-host 127.0.0.1 --daemon-port 38081 --trusted-daemon --daemon-login ${auth} --rpc-bind-port 38083 --wallet-dir ${dataDir}/wallets`
const cmd = `/usr/local/bin/${coinRec.daemon} --no-zmq --data-dir ${dataDir} --config-file ${dataDir}/${coinRec.configFile}`
const walletCmd = `/usr/local/bin/${coinRec.wallet} --rpc-login ${auth} --daemon-host 127.0.0.1 --daemon-port 18081 --trusted-daemon --daemon-login ${auth} --rpc-bind-port 18082 --wallet-dir ${dataDir}/wallets`
common.writeSupervisorConfig(coinRec, cmd, walletCmd)
}
function buildConfig (auth) {
return `rpc-login=${auth}
stagenet=1
stagenet=0
restricted-rpc=1
db-sync-mode=safe
out-peers=20

View file

@ -8,7 +8,7 @@ const E = require('../error')
const PENDING_INTERVAL_MS = 60 * T.minutes
const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse', 'promoCodeApplied', 'failedWalletScore']
const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse', 'promoCodeApplied', 'validWalletScore']
const massageUpdateFields = _.concat(massageFields, 'cryptoAtoms')
const massage = _.flow(_.omit(massageFields),

View file

@ -15,7 +15,6 @@ const cashInLow = require('./cash-in-low')
const PENDING_INTERVAL = '60 minutes'
const MAX_PENDING = 10
const WALLET_SCORE_THRESHOLD = 10
const TRANSACTION_STATES = `
case
@ -34,13 +33,13 @@ function post (machineTx, pi) {
const updatedTx = r.tx
let blacklisted = false
let addressReuse = false
let failedWalletScore = false
let walletScore = {}
return Promise.all([settingsLoader.loadLatest(), checkForBlacklisted(updatedTx), doesTxReuseAddress(updatedTx), doesWalletScoreFail(updatedTx, pi)])
.then(([{ config }, blacklistItems, isReusedAddress, walletScoreFailed]) => {
return Promise.all([settingsLoader.loadLatest(), checkForBlacklisted(updatedTx), doesTxReuseAddress(updatedTx), getWalletScore(updatedTx, pi)])
.then(([{ config }, blacklistItems, isReusedAddress, fetchedWalletScore]) => {
const rejectAddressReuse = configManager.getCompliance(config).rejectAddressReuse
failedWalletScore = walletScoreFailed
walletScore = fetchedWalletScore
if (_.some(it => it.address === updatedTx.toAddress)(blacklistItems)) {
blacklisted = true
@ -49,13 +48,14 @@ function post (machineTx, pi) {
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, true)
addressReuse = true
}
return postProcess(r, pi, blacklisted, addressReuse, failedWalletScore)
return postProcess(r, pi, blacklisted, addressReuse, walletScore)
})
.then(changes => cashInLow.update(db, updatedTx, changes))
.then(tx => _.set('bills', machineTx.bills, tx))
.then(tx => _.set('blacklisted', blacklisted, tx))
.then(tx => _.set('addressReuse', addressReuse, tx))
.then(tx => _.set('failedWalletScore', failedWalletScore, tx))
.then(tx => _.set('validWalletScore', _.isNil(walletScore) ? true : walletScore.isValid, tx))
.then(tx => _.set('walletScore', _.isNil(walletScore) ? null : walletScore.score, tx))
})
}
@ -93,7 +93,7 @@ function checkForBlacklisted (tx) {
return Promise.resolve(false)
}
function postProcess (r, pi, isBlacklisted, addressReuse, failedWalletScore) {
function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) {
if (addressReuse) {
return Promise.resolve({
operatorCompleted: true,
@ -108,10 +108,11 @@ function postProcess (r, pi, isBlacklisted, addressReuse, failedWalletScore) {
})
}
if (failedWalletScore) {
if (!_.isNil(walletScore) && !walletScore.isValid) {
return Promise.resolve({
walletScore: walletScore.score,
operatorCompleted: true,
error: 'Failed wallet score'
error: 'Ciphertrace score is above defined threshold'
})
}
@ -171,12 +172,17 @@ function doesTxReuseAddress (tx) {
return Promise.resolve(false)
}
function doesWalletScoreFail (tx, pi) {
function getWalletScore (tx, pi) {
if (!tx.fiat || tx.fiat.isZero()) {
return pi.rateWallet(tx.toAddress)
.then(res => res >= WALLET_SCORE_THRESHOLD)
return pi.rateWallet(tx.cryptoCode, tx.toAddress)
}
return Promise.resolve(false)
// Passthrough the previous result
return pi.isValidWalletScore(tx.walletScore)
.then(isValid => ({
address: tx.toAddress,
score: tx.walletScore,
isValid
}))
}
function monitorPending (settings) {

View file

@ -8,7 +8,7 @@ const toObj = helper.toObj
const UPDATEABLE_FIELDS = ['txHash', 'txVersion', 'status', 'dispense', 'dispenseConfirmed',
'notified', 'redeem', 'phone', 'error', 'swept', 'publishedAt', 'confirmedAt', 'errorCode',
'receivedCryptoAtoms' ]
'receivedCryptoAtoms', 'walletScore' ]
module.exports = {upsert, update, insert}

View file

@ -107,9 +107,56 @@ function processTxStatus (tx, settings) {
return pi.getStatus(tx)
.then(res => _.assign(tx, { receivedCryptoAtoms: res.receivedCryptoAtoms, status: res.status }))
.then(_tx => getWalletScore(_tx, pi))
.then(_tx => selfPost(_tx, pi))
}
function getWalletScore (tx, pi) {
const statuses = ['published', 'authorized', 'rejected', 'insufficientFunds']
if (_.includes(tx.status, statuses) && _.isNil(tx.walletScore)) {
// Transaction shows up on the blockchain, we can request the sender address
return pi.getTransactionHash(tx)
.then(txHashes => pi.getInputAddresses(tx, txHashes))
.then(addresses => {
const addressesPromise = []
_.forEach(it => addressesPromise.push(pi.rateWallet(tx.cryptoCode, it)), addresses)
return Promise.all(addressesPromise)
})
.then(scores => {
if (_.isNil(scores) || _.isEmpty(scores)) return tx
const highestScore = _.maxBy(it => it.score, scores)
// Conservatively assign the highest risk of all input addresses to the risk of this transaction
return highestScore.isValid
? _.assign(tx, { walletScore: highestScore.score })
: _.assign(tx, {
walletScore: highestScore.score,
error: 'Ciphertrace score is above defined threshold',
errorCode: 'operatorCancel',
dispense: true
})
})
.catch(() => _.assign(tx, {
walletScore: 10,
error: 'Ciphertrace services not available',
errorCode: 'operatorCancel',
dispense: true
}))
}
if (_.includes(tx.status, statuses) && !_.isNil(tx.walletScore)) {
return pi.isValidWalletScore(tx.walletScore)
.then(isValid => isValid ? tx : _.assign(tx, {
error: 'Ciphertrace score is above defined threshold',
errorCode: 'operatorCancel',
dispense: true
}))
}
return tx
}
function monitorLiveIncoming (settings, applyFilter, coinFilter) {
const statuses = ['notSeen', 'published', 'insufficientFunds']
const toAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE

View file

@ -21,6 +21,8 @@ const MANUAL = 'manual'
const CASH_OUT_DISPENSE_READY = 'cash_out_dispense_ready'
const CONFIRMATION_CODE = 'sms_code'
const WALLET_SCORE_THRESHOLD = 9
module.exports = {
anonymousCustomer,
CASSETTE_MAX_CAPACITY,
@ -34,5 +36,6 @@ module.exports = {
CASH_OUT_DISPENSE_READY,
CONFIRMATION_CODE,
CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES,
CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES
CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES,
WALLET_SCORE_THRESHOLD
}

View file

@ -88,6 +88,10 @@ function update (id, data, userToken, txId) {
' where id=$1 returning *'
return db.one(sql, [id])
.then(customerData => {
return getEditedData(id)
.then(customerEditedData => selectLatestData(customerData, customerEditedData))
})
.then(addComplianceOverrides(id, updateData, userToken))
.then(populateOverrideUsernames)
.then(computeStatus)
@ -117,7 +121,8 @@ async function updateCustomer (id, data, userToken) {
'us_ssn_override',
'sanctions_override',
'front_camera_override',
'suspended_until'
'suspended_until',
'phone_override'
],
_.mapKeys(_.snakeCase, data))
@ -165,6 +170,7 @@ function edit (id, data, userToken) {
const filteredData = _.pick(defaults, _.mapKeys(_.snakeCase, _.omitBy(_.isNil, data)))
if (_.isEmpty(filteredData)) return getCustomerById(id)
const formattedData = enhanceEditedPhotos(enhanceEditedFields(filteredData, userToken))
const defaultDbData = {
customer_id: id,
created: new Date(),
@ -684,18 +690,18 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
function getCustomerById (id) {
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_at, front_camera_override,
phone, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration,
phone, phone_at, phone_override, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_at, id_card_photo_override, us_ssn_at, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, LEAST(created, last_transaction) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes, is_test_customer
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, subscriber_info_at, custom_fields, notes, is_test_customer
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended,
c.suspended_until > now() AS is_suspended,
c.front_camera_path, c.front_camera_override, c.front_camera_at,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration,
c.phone, c.phone_at, c.phone_override, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_at, c.id_card_photo_override, c.us_ssn, c.us_ssn_at, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, c.subscriber_info, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
c.sanctions_at, c.sanctions_override, c.subscriber_info, c.subscriber_info_at, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (PARTITION BY c.id ORDER BY t.created DESC) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY c.id) AS total_txs,
sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (PARTITION BY c.id) AS total_spent, ccf.custom_fields
@ -1053,5 +1059,7 @@ module.exports = {
updateEditedPhoto,
updateTxCustomerPhoto,
enableTestCustomer,
disableTestCustomer
disableTestCustomer,
selectLatestData,
getEditedData
}

View file

@ -151,9 +151,8 @@ function unpair (rec) {
}
function reboot (rec) {
return db.none('NOTIFY $1:name, $2', ['poller', JSON.stringify(
return db.none('NOTIFY $1:name, $2', ['machineAction', JSON.stringify(
{
type: 'machineAction',
action: 'reboot',
value: _.pick(['deviceId', 'operatorId', 'action'], rec)
}
@ -161,9 +160,8 @@ function reboot (rec) {
}
function shutdown (rec) {
return db.none('NOTIFY $1:name, $2', ['poller', JSON.stringify(
return db.none('NOTIFY $1:name, $2', ['machineAction', JSON.stringify(
{
type: 'machineAction',
action: 'shutdown',
value: _.pick(['deviceId', 'operatorId', 'action'], rec)
}
@ -171,9 +169,8 @@ function shutdown (rec) {
}
function restartServices (rec) {
return db.none('NOTIFY $1:name, $2', ['poller', JSON.stringify(
return db.none('NOTIFY $1:name, $2', ['machineAction', JSON.stringify(
{
type: 'machineAction',
action: 'restartServices',
value: _.pick(['deviceId', 'operatorId', 'action'], rec)
}

View file

@ -1,11 +1,56 @@
const _ = require('lodash/fp')
const db = require('../db')
const state = require('./state')
const newSettingsLoader = require('../new-settings-loader')
const helpers = require('../route-helpers')
const logger = require('../logger')
const { settingsCache } = state
db.connect({ direct: true }).then(sco => {
sco.client.on('notification', data => {
const parsedData = JSON.parse(data.payload)
return reload(parsedData.operatorId)
})
return sco.none('LISTEN $1:name', 'reload')
}).catch(console.error)
db.connect({ direct: true }).then(sco => {
sco.client.on('notification', data => {
const parsedData = JSON.parse(data.payload)
return machineAction(parsedData.action, parsedData.value)
})
return sco.none('LISTEN $1:name', 'machineAction')
}).catch(console.error)
function machineAction (type, value) {
const deviceId = value.deviceId
const operatorId = value.operatorId
const pid = state.pids?.[operatorId]?.[deviceId]?.pid
switch (type) {
case 'reboot':
logger.debug(`Rebooting machine '${deviceId}' from operator ${operatorId}`)
state.reboots[operatorId] = { [deviceId]: pid }
break
case 'shutdown':
logger.debug(`Shutting down machine '${deviceId}' from operator ${operatorId}`)
state.shutdowns[operatorId] = { [deviceId]: pid }
break
case 'restartServices':
logger.debug(`Restarting services of machine '${deviceId}' from operator ${operatorId}`)
state.restartServicesMap[operatorId] = { [deviceId]: pid }
break
default:
break
}
}
function reload (operatorId) {
state.needsSettingsReload[operatorId.operatorId] = true
}
const populateSettings = function (req, res, next) {
const { needsSettingsReload, settingsCache } = state
const operatorId = res.locals.operatorId
const versionId = req.headers['config-version']
if (versionId !== state.oldVersionId) {
@ -14,20 +59,21 @@ const populateSettings = function (req, res, next) {
try {
const operatorSettings = settingsCache.get(operatorId)
if (!versionId && operatorSettings) {
req.settings = operatorSettings
return next()
}
if (!versionId && !operatorSettings) {
if (!versionId && (!operatorSettings || !!needsSettingsReload[operatorId])) {
return newSettingsLoader.loadLatest()
.then(settings => {
settingsCache.set(operatorId, settings)
delete needsSettingsReload[operatorId]
req.settings = settings
})
.then(() => next())
.catch(next)
}
if (!versionId && operatorSettings) {
req.settings = operatorSettings
return next()
}
} catch (e) {
logger.error(e)
}

View file

@ -4,6 +4,7 @@ const SETTINGS_CACHE_REFRESH = 3600
module.exports = (function () {
return {
oldVersionId: 'unset',
needsSettingsReload: {},
settingsCache: new NodeCache({
stdTTL: SETTINGS_CACHE_REFRESH,
checkperiod: SETTINGS_CACHE_REFRESH // Clear cache every hour

View file

@ -14,6 +14,7 @@ const SMS = 'sms'
const ID_VERIFIER = 'idVerifier'
const EMAIL = 'email'
const ZERO_CONF = 'zeroConf'
const WALLET_SCORING = 'wallet_scoring'
const ALL_ACCOUNTS = [
{ code: 'binanceus', display: 'Binance.us', class: TICKER, cryptos: binanceus.CRYPTO },
@ -50,7 +51,9 @@ const ALL_ACCOUNTS = [
{ code: 'mailgun', display: 'Mailgun', class: EMAIL },
{ code: 'none', display: 'None', class: ZERO_CONF, cryptos: [BTC, ZEC, LTC, DASH, BCH, ETH, XMR] },
{ code: 'blockcypher', display: 'Blockcypher', class: ZERO_CONF, cryptos: [BTC] },
{ code: 'mock-zero-conf', display: 'Mock 0-conf', class: ZERO_CONF, cryptos: ALL_CRYPTOS, dev: true }
{ code: 'mock-zero-conf', display: 'Mock 0-conf', class: ZERO_CONF, cryptos: ALL_CRYPTOS, dev: true },
{ code: 'ciphertrace', display: 'CipherTrace', class: WALLET_SCORING, cryptos: [BTC, ETH, LTC, BCH] },
{ code: 'mock-scoring', display: 'Mock scoring', class: WALLET_SCORING, cryptos: ALL_CRYPTOS, dev: true }
]
const devMode = require('minimist')(process.argv.slice(2)).dev

View file

@ -4,25 +4,20 @@ const _ = require('lodash/fp')
const userManagement = require('../userManagement')
const credentials = require('../../../../hardware-credentials')
const options = require('../../../../options')
const T = require('../../../../time')
const users = require('../../../../users')
const domain = options.hostname
const devMode = require('minimist')(process.argv.slice(2)).dev
const REMEMBER_ME_AGE = 90 * T.day
const rpID = devMode ? `localhost:3001` : domain
const expectedOrigin = `https://${rpID}`
const generateAttestationOptions = (session, options) => {
return users.getUserById(options.userId).then(user => {
return Promise.all([credentials.getHardwareCredentialsByUserId(user.id), user])
}).then(([userDevices, user]) => {
const options = simpleWebauthn.generateAttestationOptions({
const opts = simpleWebauthn.generateAttestationOptions({
rpName: 'Lamassu',
rpID,
rpID: options.domain,
userName: user.username,
userID: user.id,
timeout: 60000,
@ -40,11 +35,11 @@ const generateAttestationOptions = (session, options) => {
session.webauthn = {
attestation: {
challenge: options.challenge
challenge: opts.challenge
}
}
return options
return opts
})
}
@ -59,7 +54,7 @@ const generateAssertionOptions = (session, options) => {
transports: ['usb', 'ble', 'nfc', 'internal']
})),
userVerification: 'discouraged',
rpID
rpID: options.domain
})
session.webauthn = {
@ -82,8 +77,8 @@ const validateAttestation = (session, options) => {
simpleWebauthn.verifyAttestationResponse({
credential: options.attestationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID
expectedOrigin: `https://${options.domain}${devMode ? `:3001` : ``}`,
expectedRPID: options.domain
})
])
.then(([user, verification]) => {
@ -142,8 +137,8 @@ const validateAssertion = (session, options) => {
verification = simpleWebauthn.verifyAssertionResponse({
credential: options.assertionResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID,
expectedOrigin: `https://${options.domain}${devMode ? `:3001` : ``}`,
expectedRPID: options.domain,
authenticator: convertedAuthenticator
})
} catch (err) {

View file

@ -3,25 +3,20 @@ const base64url = require('base64url')
const _ = require('lodash/fp')
const credentials = require('../../../../hardware-credentials')
const options = require('../../../../options')
const T = require('../../../../time')
const users = require('../../../../users')
const domain = options.hostname
const devMode = require('minimist')(process.argv.slice(2)).dev
const REMEMBER_ME_AGE = 90 * T.day
const rpID = devMode ? `localhost:3001` : domain
const expectedOrigin = `https://${rpID}`
const generateAttestationOptions = (session, options) => {
return users.getUserById(options.userId).then(user => {
return Promise.all([credentials.getHardwareCredentialsByUserId(user.id), user])
}).then(([userDevices, user]) => {
const opts = simpleWebauthn.generateAttestationOptions({
rpName: 'Lamassu',
rpID,
rpID: options.domain,
userName: user.username,
userID: user.id,
timeout: 60000,
@ -58,7 +53,7 @@ const generateAssertionOptions = (session, options) => {
transports: ['usb', 'ble', 'nfc', 'internal']
})),
userVerification: 'discouraged',
rpID
rpID: options.domain
})
session.webauthn = {
@ -81,8 +76,8 @@ const validateAttestation = (session, options) => {
simpleWebauthn.verifyAttestationResponse({
credential: options.attestationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID
expectedOrigin: `https://${options.domain}${devMode ? `:3001` : ``}`,
expectedRPID: options.domain
})
])
.then(([user, verification]) => {
@ -141,8 +136,8 @@ const validateAssertion = (session, options) => {
verification = simpleWebauthn.verifyAssertionResponse({
credential: options.assertionResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID,
expectedOrigin: `https://${options.domain}${devMode ? `:3001` : ``}`,
expectedRPID: options.domain,
authenticator: convertedAuthenticator
})
} catch (err) {

View file

@ -3,23 +3,18 @@ const base64url = require('base64url')
const _ = require('lodash/fp')
const credentials = require('../../../../hardware-credentials')
const options = require('../../../../options')
const T = require('../../../../time')
const users = require('../../../../users')
const domain = options.hostname
const devMode = require('minimist')(process.argv.slice(2)).dev
const REMEMBER_ME_AGE = 90 * T.day
const rpID = devMode ? `localhost:3001` : domain
const expectedOrigin = `https://${rpID}`
const generateAttestationOptions = (session, options) => {
return credentials.getHardwareCredentials().then(devices => {
const opts = simpleWebauthn.generateAttestationOptions({
rpName: 'Lamassu',
rpID,
rpID: options.domain,
userName: `Usernameless user created at ${new Date().toISOString()}`,
userID: options.userId,
timeout: 60000,
@ -46,9 +41,9 @@ const generateAttestationOptions = (session, options) => {
})
}
const generateAssertionOptions = session => {
const generateAssertionOptions = (session, options) => {
return credentials.getHardwareCredentials().then(devices => {
const options = simpleWebauthn.generateAssertionOptions({
const opts = simpleWebauthn.generateAssertionOptions({
timeout: 60000,
allowCredentials: devices.map(dev => ({
id: dev.data.credentialID,
@ -56,15 +51,15 @@ const generateAssertionOptions = session => {
transports: ['usb', 'ble', 'nfc', 'internal']
})),
userVerification: 'discouraged',
rpID
rpID: options.domain
})
session.webauthn = {
assertion: {
challenge: options.challenge
challenge: opts.challenge
}
}
return options
return opts
})
}
@ -77,8 +72,8 @@ const validateAttestation = (session, options) => {
simpleWebauthn.verifyAttestationResponse({
credential: options.attestationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID
expectedOrigin: `https://${options.domain}${devMode ? `:3001` : ``}`,
expectedRPID: options.domain
})
])
.then(([user, verification]) => {
@ -146,8 +141,8 @@ const validateAssertion = (session, options) => {
verification = simpleWebauthn.verifyAssertionResponse({
credential: options.assertionResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID,
expectedOrigin: `https://${options.domain}${devMode ? `:3001` : ``}`,
expectedRPID: options.domain,
authenticator: convertedAuthenticator
})
} catch (err) {

View file

@ -20,7 +20,7 @@ const txLogFields = ['txClass', 'id', 'deviceId', 'toAddress', 'cryptoAtoms',
'commissionPercentage', 'rawTickerPrice', 'receivedCryptoAtoms',
'discount', 'txHash', 'customerPhone', 'customerIdCardDataNumber',
'customerIdCardDataExpiration', 'customerIdCardData', 'customerName',
'customerFrontCameraPath', 'customerIdCardPhotoPath', 'expired', 'machineName']
'customerFrontCameraPath', 'customerIdCardPhotoPath', 'expired', 'machineName', 'walletScore']
const resolvers = {
Customer: {

View file

@ -6,11 +6,11 @@ const sessionManager = require('../../../session-manager')
const getAttestationQueryOptions = variables => {
switch (authentication.CHOSEN_STRATEGY) {
case 'FIDO2FA':
return { userId: variables.userID }
return { userId: variables.userID, domain: variables.domain }
case 'FIDOPasswordless':
return { userId: variables.userID }
return { userId: variables.userID, domain: variables.domain }
case 'FIDOUsernameless':
return { userId: variables.userID }
return { userId: variables.userID, domain: variables.domain }
default:
return {}
}
@ -19,11 +19,11 @@ const getAttestationQueryOptions = variables => {
const getAssertionQueryOptions = variables => {
switch (authentication.CHOSEN_STRATEGY) {
case 'FIDO2FA':
return { username: variables.username, password: variables.password }
return { username: variables.username, password: variables.password, domain: variables.domain }
case 'FIDOPasswordless':
return { username: variables.username }
return { username: variables.username, domain: variables.domain }
case 'FIDOUsernameless':
return {}
return { domain: variables.domain }
default:
return {}
}
@ -32,11 +32,11 @@ const getAssertionQueryOptions = variables => {
const getAttestationMutationOptions = variables => {
switch (authentication.CHOSEN_STRATEGY) {
case 'FIDO2FA':
return { userId: variables.userID, attestationResponse: variables.attestationResponse }
return { userId: variables.userID, attestationResponse: variables.attestationResponse, domain: variables.domain }
case 'FIDOPasswordless':
return { userId: variables.userID, attestationResponse: variables.attestationResponse }
return { userId: variables.userID, attestationResponse: variables.attestationResponse, domain: variables.domain }
case 'FIDOUsernameless':
return { userId: variables.userID, attestationResponse: variables.attestationResponse }
return { userId: variables.userID, attestationResponse: variables.attestationResponse, domain: variables.domain }
default:
return {}
}
@ -45,11 +45,11 @@ const getAttestationMutationOptions = variables => {
const getAssertionMutationOptions = variables => {
switch (authentication.CHOSEN_STRATEGY) {
case 'FIDO2FA':
return { username: variables.username, password: variables.password, rememberMe: variables.rememberMe, assertionResponse: variables.assertionResponse }
return { username: variables.username, password: variables.password, rememberMe: variables.rememberMe, assertionResponse: variables.assertionResponse, domain: variables.domain }
case 'FIDOPasswordless':
return { username: variables.username, rememberMe: variables.rememberMe, assertionResponse: variables.assertionResponse }
return { username: variables.username, rememberMe: variables.rememberMe, assertionResponse: variables.assertionResponse, domain: variables.domain }
case 'FIDOUsernameless':
return { assertionResponse: variables.assertionResponse }
return { assertionResponse: variables.assertionResponse, domain: variables.domain }
default:
return {}
}

View file

@ -33,6 +33,7 @@ const typeDef = gql`
lastTxClass: String
transactions: [Transaction]
subscriberInfo: JSONObject
phoneOverride: String
customFields: [CustomerCustomField]
customInfoRequests: [CustomRequestData]
notes: [CustomerNote]
@ -63,12 +64,14 @@ const typeDef = gql`
lastTxClass: String
suspendedUntil: Date
subscriberInfo: Boolean
phoneOverride: String
}
input CustomerEdit {
idCardData: JSONObject
idCardPhoto: UploadGQL
usSsn: String
subscriberInfo: JSONObject
}
type CustomerNote {

View file

@ -47,6 +47,7 @@ const typeDef = gql`
txCustomerPhotoAt: Date
batched: Boolean
batchTime: Date
walletScore: Int
}
type Filter {

View file

@ -3,14 +3,14 @@ const authentication = require('../modules/authentication')
const getFIDOStrategyQueryTypes = () => {
switch (authentication.CHOSEN_STRATEGY) {
case 'FIDO2FA':
return `generateAttestationOptions(userID: ID!): JSONObject
generateAssertionOptions(username: String!, password: String!): JSONObject`
return `generateAttestationOptions(userID: ID!, domain: String!): JSONObject
generateAssertionOptions(username: String!, password: String!, domain: String!): JSONObject`
case 'FIDOPasswordless':
return `generateAttestationOptions(userID: ID!): JSONObject
generateAssertionOptions(username: String!): JSONObject`
return `generateAttestationOptions(userID: ID!, domain: String!): JSONObject
generateAssertionOptions(username: String!, domain: String!): JSONObject`
case 'FIDOUsernameless':
return `generateAttestationOptions(userID: ID!): JSONObject
generateAssertionOptions: JSONObject`
return `generateAttestationOptions(userID: ID!, domain: String!): JSONObject
generateAssertionOptions(domain: String!): JSONObject`
default:
return ``
}
@ -19,14 +19,14 @@ const getFIDOStrategyQueryTypes = () => {
const getFIDOStrategyMutationsTypes = () => {
switch (authentication.CHOSEN_STRATEGY) {
case 'FIDO2FA':
return `validateAttestation(userID: ID!, attestationResponse: JSONObject!): Boolean
validateAssertion(username: String!, password: String!, rememberMe: Boolean!, assertionResponse: JSONObject!): Boolean`
return `validateAttestation(userID: ID!, attestationResponse: JSONObject!, domain: String!): Boolean
validateAssertion(username: String!, password: String!, rememberMe: Boolean!, assertionResponse: JSONObject!, domain: String!): Boolean`
case 'FIDOPasswordless':
return `validateAttestation(userID: ID!, attestationResponse: JSONObject!): Boolean
validateAssertion(username: String!, rememberMe: Boolean!, assertionResponse: JSONObject!): Boolean`
return `validateAttestation(userID: ID!, attestationResponse: JSONObject!, domain: String!): Boolean
validateAssertion(username: String!, rememberMe: Boolean!, assertionResponse: JSONObject!, domain: String!): Boolean`
case 'FIDOUsernameless':
return `validateAttestation(userID: ID!, attestationResponse: JSONObject!): Boolean
validateAssertion(assertionResponse: JSONObject!): Boolean`
return `validateAttestation(userID: ID!, attestationResponse: JSONObject!, domain: String!): Boolean
validateAssertion(assertionResponse: JSONObject!, domain: String!): Boolean`
default:
return ``
}

View file

@ -21,7 +21,6 @@ router.use('*', async (req, res, next) => getOperatorId('authentication').then((
cookie: {
httpOnly: true,
secure: true,
domain: hostname,
sameSite: true,
maxAge: 60 * 10 * 1000 // 10 minutes
}

View file

@ -2,6 +2,7 @@ const db = require('../../db')
const uuid = require('uuid')
const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const { loadLatestConfigOrNone, saveConfig } = require('../../../lib/new-settings-loader')
const getCustomInfoRequests = (onlyEnabled = false) => {
const sql = onlyEnabled
@ -23,7 +24,10 @@ const addCustomInfoRequest = (customRequest) => {
}
const removeCustomInfoRequest = (id) => {
return db.none('UPDATE custom_info_requests SET enabled = false WHERE id = $1', [id]).then(() => ({ id }))
return loadLatestConfigOrNone()
.then(cfg => saveConfig({triggers: _.remove(x => x.customInfoRequestId === id, cfg.triggers ?? [])}))
.then(() => db.none('UPDATE custom_info_requests SET enabled = false WHERE id = $1', [id]))
.then(() => ({ id }));
}
const editCustomInfoRequest = (id, customRequest) => {

View file

@ -69,7 +69,7 @@ function batch (
AND ($12 is null or txs.to_address = $12)
AND ($13 is null or txs.txStatus = $13)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
AND (fiat > 0)
AND (error IS NOT null OR fiat > 0)
ORDER BY created DESC limit $4 offset $5`
const cashOutSql = `SELECT 'cashOut' AS tx_class,

View file

@ -2,6 +2,7 @@ const _ = require('lodash/fp')
const db = require('./db')
const migration = require('./config-migration')
const { asyncLocalStorage } = require('./async-storage')
const { getOperatorId } = require('./operator')
const OLD_SETTINGS_LOADER_SCHEMA_VERSION = 1
const NEW_SETTINGS_LOADER_SCHEMA_VERSION = 2
@ -71,16 +72,25 @@ function showAccounts (schemaVersion) {
const configSql = 'insert into user_config (type, data, valid, schema_version) values ($1, $2, $3, $4)'
function saveConfig (config) {
return loadLatestConfigOrNone()
.then(currentConfig => {
return Promise.all([loadLatestConfigOrNone(), getOperatorId('middleware')])
.then(([currentConfig, operatorId]) => {
const newConfig = _.assign(currentConfig, config)
return db.tx(t => {
return t.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
.then(() => t.none('NOTIFY $1:name, $2', ['poller', JSON.stringify({ type: 'reload', schema: asyncLocalStorage.getStore().get('schema') })]))
.then(() => t.none('NOTIFY $1:name, $2', ['reload', JSON.stringify({ schema: asyncLocalStorage.getStore().get('schema'), operatorId })]))
}).catch(console.error)
})
}
function migrationSaveConfig (config) {
return loadLatestConfigOrNone()
.then(currentConfig => {
const newConfig = _.assign(currentConfig, config)
return db.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
.catch(console.error)
})
}
function resetConfig (schemaVersion) {
return db.none(
configSql,
@ -171,6 +181,7 @@ function migrate () {
module.exports = {
saveConfig,
migrationSaveConfig,
resetConfig,
saveAccounts,
resetAccounts,

View file

@ -828,9 +828,20 @@ function plugins (settings, deviceId) {
.then(buildRates)
}
function rateWallet (address) {
return walletScoring.rateWallet(settings, address)
.then(res => res.rating)
function rateWallet (cryptoCode, address) {
return walletScoring.rateWallet(settings, cryptoCode, address)
}
function isValidWalletScore (score) {
return walletScoring.isValidWalletScore(settings, score)
}
function getTransactionHash (tx) {
return walletScoring.getTransactionHash(settings, tx.cryptoCode, tx.toAddress)
}
function getInputAddresses (tx, txHashes) {
return walletScoring.getInputAddresses(settings, tx.cryptoCode, txHashes)
}
return {
@ -861,7 +872,10 @@ function plugins (settings, deviceId) {
notifyOperator,
fetchCurrentConfigVersion,
pruneMachinesHeartbeat,
rateWallet
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
}
}

View file

@ -6,7 +6,7 @@ const _ = require('lodash/fp')
const request = require('request-promise')
const { utils: coinUtils } = require('lamassu-coins')
const options = require('../../../options')
const options = require('../../options')
const blockchainDir = options.blockchainDir

View file

@ -0,0 +1,91 @@
const axios = require('axios')
const _ = require('lodash/fp')
const logger = require('../../../logger')
const NAME = 'CipherTrace'
const SUPPORTED_COINS = ['BTC', 'ETH', 'BCH', 'LTC', 'BNB', 'RSK']
function getClient(account) {
if (_.isNil(account) || !account.enabled) return null
const [ctv1, username, secretKey] = account.authorizationValue.split(':')
if (_.isNil(ctv1) || _.isNil(username) || _.isNil(secretKey)) {
throw new Error('Invalid CipherTrace configuration')
}
const apiVersion = ctv1.slice(-2)
const authHeader = {
"Authorization": account.authorizationValue
}
return { apiVersion, authHeader }
}
function rateWallet(account, cryptoCode, address) {
const client = getClient(account)
if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null)
const { apiVersion, authHeader } = client
const threshold = account.scoreThreshold
return axios.get(`https://rest.ciphertrace.com/aml/${apiVersion}/${_.toLower(cryptoCode)}/risk?address=${address}`, {
headers: authHeader
})
.then(res => ({ address, score: res.data.risk, isValid: res.data.risk < threshold }))
}
function isValidWalletScore(account, score) {
const client = getClient(account)
if (_.isNil(client)) return Promise.resolve(true)
const threshold = account.scoreThreshold
return _.isNil(account) ? Promise.resolve(true) : Promise.resolve(score < threshold)
}
function getTransactionHash(account, cryptoCode, receivingAddress) {
const client = getClient(account)
if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null)
const { apiVersion, authHeader } = client
return axios.get(`https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}address/search?features=tx&address=${receivingAddress}`, {
headers: authHeader
})
.then(res => {
const data = res.data
if (_.size(data.txHistory) > 1) {
logger.warn('An address generated by this wallet was used in more than one transaction')
}
return _.join(', ', _.map(it => it.txHash, data.txHistory))
})
}
function getInputAddresses(account, cryptoCode, txHashes) {
const client = getClient(account)
if (!_.includes(_.toUpper(cryptoCode), SUPPORTED_COINS) || _.isNil(client)) return Promise.resolve(null)
const { apiVersion, authHeader } = client
return axios.get(`https://rest.ciphertrace.com/api/${apiVersion}/${_.toLower(cryptoCode) !== 'btc' ? `${_.toLower(cryptoCode)}_` : ``}tx?txhashes=${txHashes}`, {
headers: authHeader
})
.then(res => {
const data = res.data
if (_.size(data.transactions) > 1) {
logger.warn('An address generated by this wallet was used in more than one transaction')
}
const transactionInputs = _.flatMap(it => it.inputs, data.transactions)
const inputAddresses = _.map(it => it.address, transactionInputs)
return inputAddresses
})
}
module.exports = {
NAME,
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
}

View file

@ -1,15 +1,45 @@
const NAME = 'FakeScoring'
function rateWallet (account, address) {
const { WALLET_SCORE_THRESHOLD } = require('../../../constants')
function rateWallet (account, cryptoCode, address) {
return new Promise((resolve, _) => {
setTimeout(() => {
console.log('[WALLET-SCORING] DEBUG: Mock scoring rating wallet address %s', address)
return resolve({ address, rating: 5 })
return Promise.resolve(2)
.then(score => resolve({ address, score, isValid: score < WALLET_SCORE_THRESHOLD }))
}, 100)
})
}
function isValidWalletScore (account, score) {
return new Promise((resolve, _) => {
setTimeout(() => {
return resolve(score < WALLET_SCORE_THRESHOLD)
}, 100)
})
}
function getTransactionHash (account, cryptoCode, receivingAddress) {
return new Promise((resolve, _) => {
setTimeout(() => {
return resolve('<Fake transaction hash>')
}, 100)
})
}
function getInputAddresses (account, cryptoCode, txHashes) {
return new Promise((resolve, _) => {
setTimeout(() => {
return resolve(['<Fake input address hash>'])
}, 100)
})
}
module.exports = {
NAME,
rateWallet
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
}

View file

@ -123,6 +123,12 @@ function supportsBatching (cryptoCode) {
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => fetch('getblockchaininfo'))
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
module.exports = {
balance,
sendCoins,
@ -130,5 +136,6 @@ module.exports = {
getStatus,
newFunding,
cryptoNetwork,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -176,6 +176,12 @@ function supportsBatching (cryptoCode) {
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => fetch('getblockchaininfo'))
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
module.exports = {
balance,
sendCoins,
@ -186,5 +192,6 @@ module.exports = {
fetchRBF,
estimateFee,
sendCoinsBatch,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -164,6 +164,11 @@ function supportsBatching (cryptoCode) {
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => Promise.resolve('ready'))
}
module.exports = {
NAME,
balance,
@ -172,5 +177,6 @@ module.exports = {
getStatus,
newFunding,
cryptoNetwork,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -118,11 +118,18 @@ function supportsBatching (cryptoCode) {
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => fetch('getblockchaininfo'))
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
module.exports = {
balance,
sendCoins,
newAddress,
getStatus,
newFunding,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -32,7 +32,8 @@ module.exports = {
privateKey,
isStrictAddress,
connect,
supportsBatching
supportsBatching,
checkBlockchainStatus
}
function connect (url) {
@ -230,3 +231,9 @@ function supportsBatching (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(pify(web3.eth.isSyncing))
.then(res => _.isObject(res) ? 'syncing' : 'ready')
}

View file

@ -118,11 +118,18 @@ function supportsBatching (cryptoCode) {
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => fetch('getblockchaininfo'))
.then(res => !!res['initialblockdownload'] ? 'syncing' : 'ready')
}
module.exports = {
balance,
sendCoins,
newAddress,
getStatus,
newFunding,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -3,7 +3,6 @@ const _ = require('lodash/fp')
const BN = require('../../../bn')
const E = require('../../../error')
const { utils: coinUtils } = require('lamassu-coins')
const consoleLogLevel = require('console-log-level')
const NAME = 'FakeWallet'
const BATCHABLE_COINS = ['BTC']
@ -116,6 +115,11 @@ function supportsBatching (cryptoCode) {
return Promise.resolve(_.includes(cryptoCode, BATCHABLE_COINS))
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => Promise.resolve('ready'))
}
module.exports = {
NAME,
balance,
@ -124,5 +128,6 @@ module.exports = {
newAddress,
getStatus,
newFunding,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -17,6 +17,8 @@ const configPath = utils.configPath(cryptoRec, blockchainDir)
const walletDir = path.resolve(utils.cryptoDir(cryptoRec, blockchainDir), 'wallets')
const unitScale = cryptoRec.unitScale
const SUPPORTS_BATCHING = false
function rpcConfig () {
try {
const config = jsonRpc.parseConf(configPath)
@ -186,9 +188,9 @@ function cryptoNetwork (account, cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => {
switch(parseInt(rpcConfig().port, 10)) {
case 18083:
case 18082:
return 'main'
case 28083:
case 28082:
return 'test'
case 38083:
return 'stage'
@ -198,11 +200,42 @@ function cryptoNetwork (account, cryptoCode) {
})
}
function supportsBatching (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => {
try {
const config = jsonRpc.parseConf(configPath)
// Daemon uses a different connection of the wallet
const rpcConfig = {
username: config['rpc-login'].split(':')[0],
password: config['rpc-login'].split(':')[1],
port: cryptoRec.defaultPort
}
return jsonRpc.fetchDigest(rpcConfig, 'get_info')
.then(res => {
console.log('res XMR', res)
return !!res.synchronized ? 'ready' : 'syncing'
})
} catch (err) {
throw new Error('XMR daemon is currently not installed')
}
})
}
module.exports = {
balance,
sendCoins,
newAddress,
getStatus,
newFunding,
cryptoNetwork
cryptoNetwork,
supportsBatching,
checkBlockchainStatus
}

View file

@ -144,11 +144,18 @@ function supportsBatching (cryptoCode) {
.then(() => SUPPORTS_BATCHING)
}
function checkBlockchainStatus (cryptoCode) {
return checkCryptoCode(cryptoCode)
.then(() => fetch('getblockchaininfo'))
.then(res => !!res['initial_block_download_complete'] ? 'ready' : 'syncing')
}
module.exports = {
balance,
sendCoins,
newAddress,
getStatus,
newFunding,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -81,16 +81,9 @@ cachedVariables.on('expired', (key, val) => {
db.connect({ direct: true }).then(sco => {
sco.client.on('notification', data => {
const parsedData = JSON.parse(data.payload)
switch (parsedData.type) {
case 'reload':
return reload(parsedData.schema)
case 'machineAction':
return machineAction(parsedData.action, parsedData.value)
default:
break
}
})
return sco.none('LISTEN $1:name', 'poller')
return sco.none('LISTEN $1:name', 'reload')
}).catch(console.error)
function reload (schema) {
@ -98,7 +91,8 @@ function reload (schema) {
store.set('schema', schema)
// set asyncLocalStorage so settingsLoader loads settings for the right schema
return asyncLocalStorage.run(store, () => {
return settingsLoader.loadLatest().then(settings => {
return settingsLoader.loadLatest()
.then(settings => {
const pi = plugins(settings)
cachedVariables.set(schema, { settings, pi, isReloading: false })
logger.debug(`Settings for schema '${schema}' reloaded in poller`)
@ -107,29 +101,6 @@ function reload (schema) {
})
}
function machineAction (type, value) {
const deviceId = value.deviceId
const operatorId = value.operatorId
const pid = state.pids?.[operatorId]?.[deviceId]?.pid
switch (type) {
case 'reboot':
logger.debug(`Rebooting machine '${deviceId}' from operator ${operatorId}`)
state.reboots[operatorId] = { [deviceId]: pid }
break
case 'shutdown':
logger.debug(`Shutting down machine '${deviceId}' from operator ${operatorId}`)
state.shutdowns[operatorId] = { [deviceId]: pid }
break
case 'restartServices':
logger.debug(`Restarting services of machine '${deviceId}' from operator ${operatorId}`)
state.restartServicesMap[operatorId] = { [deviceId]: pid }
break
default:
break
}
}
function pi () { return cachedVariables.get(asyncLocalStorage.getStore().get('schema')).pi }
function settings () { return cachedVariables.get(asyncLocalStorage.getStore().get('schema')).settings }

View file

@ -6,7 +6,7 @@ const _ = require('lodash/fp')
const compliance = require('../compliance')
const complianceTriggers = require('../compliance-triggers')
const configManager = require('../new-config-manager')
const customers = require('../customers')
const { get, add, getEditedData, selectLatestData, getCustomerById, update } = require('../customers')
const httpError = require('../route-helpers').httpError
const plugins = require('../plugins')
const Tx = require('../tx')
@ -20,12 +20,14 @@ function addOrUpdateCustomer (req) {
const compatTriggers = complianceTriggers.getBackwardsCompatibleTriggers(triggers)
const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers)
return customers.get(customerData.phone)
return get(customerData.phone)
.then(customer => {
if (customer) return customer
return customers.add(req.body)
return add(req.body)
})
.then(customer => Promise.all([getEditedData(customer.id), getCustomerById(customer.id)]))
.then(([customerEditedData, customerOriginalData]) => selectLatestData(customerOriginalData, customerEditedData))
.then(customer => {
// BACKWARDS_COMPATIBILITY 7.5
// machines before 7.5 expect customer with sanctions result
@ -36,7 +38,7 @@ function addOrUpdateCustomer (req) {
return compliance.validationPatch(req.deviceId, !!compatTriggers.sanctions, customer)
.then(patch => {
if (_.isEmpty(patch)) return customer
return customers.update(customer.id, patch)
return update(customer.id, patch)
})
})
.then(customer => {

View file

@ -3,25 +3,52 @@ const _ = require('lodash/fp')
const argv = require('minimist')(process.argv.slice(2))
function loadWalletScoring (settings) {
if (_.isNil(argv.mockScoring)) {
throw new Error('No wallet scoring API set!')
}
const pluginCode = argv.mockScoring ? 'mock-scoring' : ''
const pluginCode = argv.mockScoring ? 'mock-scoring' : 'ciphertrace'
const plugin = ph.load(ph.WALLET_SCORING, pluginCode)
const account = settings.accounts[pluginCode]
return { plugin, account }
}
function rateWallet (settings, address) {
function rateWallet (settings, cryptoCode, address) {
return Promise.resolve()
.then(() => {
const { plugin, account } = loadWalletScoring(settings)
return plugin.rateWallet(account, address)
return plugin.rateWallet(account, cryptoCode, address)
})
}
function isValidWalletScore (settings, score) {
return Promise.resolve()
.then(() => {
const { plugin, account } = loadWalletScoring(settings)
return plugin.isValidWalletScore(account, score)
})
}
function getTransactionHash (settings, cryptoCode, receivingAddress) {
return Promise.resolve()
.then(() => {
const { plugin, account } = loadWalletScoring(settings)
return plugin.getTransactionHash(account, cryptoCode, receivingAddress)
})
}
function getInputAddresses (settings, cryptoCode, txHashes) {
return Promise.resolve()
.then(() => {
const { plugin, account } = loadWalletScoring(settings)
return plugin.getInputAddresses(account, cryptoCode, txHashes)
})
}
module.exports = {
rateWallet
rateWallet,
isValidWalletScore,
getTransactionHash,
getInputAddresses
}

View file

@ -237,6 +237,11 @@ function supportsBatching (settings, cryptoCode) {
.then(r => r.wallet.supportsBatching(cryptoCode))
}
function checkBlockchainStatus (settings, cryptoCode) {
return fetchWallet(settings, cryptoCode)
.then(r => r.wallet.checkBlockchainStatus(cryptoCode))
}
const coinFilter = ['ETH']
const balance = (settings, cryptoCode) => {
@ -265,5 +270,6 @@ module.exports = {
isHd,
newFunding,
cryptoNetwork,
supportsBatching
supportsBatching,
checkBlockchainStatus
}

View file

@ -1,6 +1,6 @@
const db = require('./db')
const machineLoader = require('../lib/machine-loader')
const { saveConfig, saveAccounts, loadLatest } = require('../lib/new-settings-loader')
const { migrationSaveConfig, saveAccounts, loadLatest } = require('../lib/new-settings-loader')
const { migrate } = require('../lib/config-migration')
const _ = require('lodash/fp')
@ -11,7 +11,7 @@ module.exports.up = function (next) {
function migrateConfig (settings) {
const newSettings = migrate(settings.config, settings.accounts)
return Promise.all([
saveConfig(newSettings.config),
migrationSaveConfig(newSettings.config),
saveAccounts(newSettings.accounts)
])
.then(() => next())

View file

@ -33,7 +33,7 @@ exports.up = function (next) {
}
})(deviceIds)
return settingsLoader.saveConfig(config)
return settingsLoader.migrationSaveConfig(config)
})
.then(() => next())
.catch(err => next(err))

View file

@ -1,4 +1,4 @@
const { saveConfig } = require('../lib/new-settings-loader')
const { migrationSaveConfig } = require('../lib/new-settings-loader')
exports.up = function (next) {
const triggersDefault = {
@ -6,7 +6,7 @@ exports.up = function (next) {
triggersConfig_automation: 'Automatic'
}
return saveConfig(triggersDefault)
return migrationSaveConfig(triggersDefault)
.then(() => next())
.catch(err => {
console.log(err.message)

View file

@ -13,7 +13,7 @@ exports.up = async function (next) {
config[key] = 'none'
}
}, cryptoCodes)
return settingsLoader.saveConfig(config)
return settingsLoader.migrationSaveConfig(config)
}
exports.down = function (next) {

View file

@ -1,5 +1,5 @@
const _ = require('lodash/fp')
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
const { CASSETTE_MAX_CAPACITY } = require('../lib/constants')
exports.up = function (next) {
@ -49,7 +49,7 @@ exports.up = function (next) {
return newOverride
}, config.notifications_fiatBalanceOverrides)
}
return saveConfig(newConfig)
return migrationSaveConfig(newConfig)
.then(() => next())
})
.catch(err => {

View file

@ -6,7 +6,7 @@ exports.up = function (next) {
.then(({ config }) => {
if (!_.isEmpty(config))
config.locale_timezone = '0:0'
return settingsLoader.saveConfig(config)
return settingsLoader.migrationSaveConfig(config)
})
.then(() => next())
.catch(err => next(err))

View file

@ -1,4 +1,4 @@
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
exports.up = function (next) {
const newConfig = {
@ -6,7 +6,7 @@ exports.up = function (next) {
}
return loadLatest()
.then(config => {
return saveConfig(newConfig)
return migrationSaveConfig(newConfig)
.then(() => next())
.catch(err => {
if (err.message === 'lamassu-server is not configured') {

View file

@ -1,5 +1,5 @@
const _ = require('lodash/fp')
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
const { getCryptosFromWalletNamespace } = require('../lib/new-config-manager')
exports.up = function (next) {
@ -8,7 +8,7 @@ exports.up = function (next) {
.then(config => {
const coins = getCryptosFromWalletNamespace(config)
_.map(coin => { newConfig[`wallets_${coin}_feeMultiplier`] = '1' }, coins)
return saveConfig(newConfig)
return migrationSaveConfig(newConfig)
})
.then(next)
.catch(err => {

View file

@ -1,5 +1,5 @@
const db = require('./db')
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
exports.up = function (next) {
const sql = [
@ -19,7 +19,7 @@ exports.up = function (next) {
newConfig.notifications_notificationCenter_security = true
}
return saveConfig(newConfig)
return migrationSaveConfig(newConfig)
.then(() => db.multi(sql, next))
.catch(err => {
return next(err)

View file

@ -1,4 +1,4 @@
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
const { getCryptosFromWalletNamespace } = require('../lib/new-config-manager.js')
const { utils: coinUtils } = require('lamassu-coins')
const _ = require('lodash/fp')
@ -14,7 +14,7 @@ exports.up = function (next) {
newSettings[`wallets_${crypto}_cryptoUnits`] = defaultUnit
return newSettings
}, activeCryptos)
return saveConfig(newSettings)
return migrationSaveConfig(newSettings)
})
.then(() => next())
.catch(err => {

View file

@ -1,6 +1,6 @@
var db = require('./db')
const _ = require('lodash/fp')
const { saveConfig, loadLatest } = require('../lib/new-settings-loader')
const { migrationSaveConfig, loadLatest } = require('../lib/new-settings-loader')
const { getMachines } = require('../lib/machine-loader')
exports.up = function (next) {
@ -40,7 +40,7 @@ exports.up = function (next) {
return acc
}, {}, formattedMachines)
return saveConfig(newConfig)
return migrationSaveConfig(newConfig)
.then(() => db.multi(sql, next))
})
}

View file

@ -0,0 +1,13 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE cash_in_txs ADD COLUMN wallet_score SMALLINT`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,17 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE customers
ADD COLUMN phone_override VERIFICATION_TYPE NOT NULL DEFAULT 'automatic',
ADD COLUMN phone_override_by UUID,
ADD COLUMN phone_override_at TIMESTAMPTZ
`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,13 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE cash_out_txs ADD COLUMN wallet_score SMALLINT`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -10,13 +10,12 @@ const useStyles = makeStyles({
imgWrapper: {
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
width: 550
display: 'flex'
},
imgInner: {
objectFit: 'cover',
objectPosition: 'center',
width: 550,
width: 500,
marginBottom: 40
}
})
@ -33,17 +32,16 @@ export const Carousel = memo(({ photosData, slidePhoto }) => {
style: {
backgroundColor: 'transparent',
borderRadius: 0,
width: 50,
color: 'transparent',
opacity: 1
}
}}
// navButtonsWrapperProps={{
// style: {
// background: 'linear-gradient(to right, black 10%, transparent 80%)',
// opacity: '0.4'
// }
// }}
navButtonsWrapperProps={{
style: {
marginLeft: -22,
marginRight: -22
}
}}
autoPlay={false}
indicators={false}
navButtonsAlwaysVisible={true}

View file

@ -7,8 +7,15 @@ import styles from './Button.styles'
const useStyles = makeStyles(styles)
const ActionButton = memo(
({ size = 'lg', children, className, buttonClassName, ...props }) => {
const classes = useStyles({ size })
({
size = 'lg',
children,
className,
buttonClassName,
backgroundColor,
...props
}) => {
const classes = useStyles({ size, backgroundColor })
return (
<div className={classnames(className, classes.wrapper)}>
<button

View file

@ -5,6 +5,9 @@ import {
secondaryColor,
secondaryColorDark,
secondaryColorDarker,
offColor,
offDarkColor,
offDarkerColor,
spacer
} from 'src/styling/variables'
@ -28,11 +31,11 @@ export default {
const shadowSize = height / 12
return { height: height + shadowSize / 2 }
},
button: ({ size }) => {
button: ({ size, backgroundColor }) => {
const height = pickSize(size)
const shadowSize = size === 'xl' ? 3 : height / 12
const padding = size === 'xl' ? 20 : height / 2
const isGrey = backgroundColor === 'grey'
return {
extend: size === 'xl' ? h1 : h3,
border: 'none',
@ -40,7 +43,7 @@ export default {
cursor: 'pointer',
fontWeight: 900,
outline: 0,
backgroundColor: secondaryColor,
backgroundColor: isGrey ? offDarkColor : secondaryColor,
'&:disabled': {
backgroundColor: disabledColor,
boxShadow: 'none',
@ -56,15 +59,19 @@ export default {
height,
padding: `0 ${padding}px`,
borderRadius: height / 4,
boxShadow: `0 ${shadowSize}px ${secondaryColorDark}`,
boxShadow: `0 ${shadowSize}px ${isGrey ? offColor : secondaryColorDark}`,
'&:hover': {
backgroundColor: secondaryColorDark,
boxShadow: `0 ${shadowSize}px ${secondaryColorDarker}`
backgroundColor: isGrey ? offColor : secondaryColorDark,
boxShadow: `0 ${shadowSize}px ${
isGrey ? offDarkerColor : secondaryColorDarker
}`
},
'&:active': {
marginTop: shadowSize / 2,
backgroundColor: secondaryColorDark,
boxShadow: `0 ${shadowSize / 2}px ${secondaryColorDarker}`
backgroundColor: isGrey ? offDarkColor : secondaryColorDark,
boxShadow: `0 ${shadowSize / 2}px ${
isGrey ? offDarkerColor : secondaryColorDarker
}`
}
}
}

View file

@ -48,7 +48,9 @@ const Subheader = ({ item, classes, user }) => {
setPrev(it.route)
return true
}}>
<span className={classes.forceSize} forcesize={it.label}>
{it.label}
</span>
</NavLink>
</li>
)

View file

@ -185,12 +185,12 @@ const DataTable = ({
<TBody className={classes.body}>
{loading && <H4>Loading...</H4>}
{!loading && R.isEmpty(data) && <EmptyTable message={emptyText} />}
{!R.isEmpty(data) && (
{!loading && !R.isEmpty(data) && (
<AutoSizer disableWidth>
{({ height }) => (
<List
// this has to be in a style because of how the component works
style={{ overflow: 'inherit', outline: 'none' }}
style={{ outline: 'none' }}
{...props}
height={loading ? 0 : height}
width={width}

View file

@ -42,10 +42,10 @@ const InputFIDOState = ({ state, strategy }) => {
const GENERATE_ASSERTION = gql`
query generateAssertionOptions($username: String!${
strategy === 'FIDO2FA' ? `, $password: String!` : ``
}) {
}, $domain: String!) {
generateAssertionOptions(username: $username${
strategy === 'FIDO2FA' ? `, password: $password` : ``
})
}, domain: $domain)
}
`
@ -55,12 +55,14 @@ const InputFIDOState = ({ state, strategy }) => {
${strategy === 'FIDO2FA' ? `, $password: String!` : ``}
$rememberMe: Boolean!
$assertionResponse: JSONObject!
$domain: String!
) {
validateAssertion(
username: $username
${strategy === 'FIDO2FA' ? `password: $password` : ``}
rememberMe: $rememberMe
assertionResponse: $assertionResponse
domain: $domain
)
}
`
@ -90,10 +92,12 @@ const InputFIDOState = ({ state, strategy }) => {
strategy === 'FIDO2FA'
? {
username: state.clientField,
password: state.passwordField
password: state.passwordField,
domain: window.location.hostname
}
: {
username: localClientField
username: localClientField,
domain: window.location.hostname
},
onCompleted: ({ generateAssertionOptions: options }) => {
startAssertion(options)
@ -104,12 +108,14 @@ const InputFIDOState = ({ state, strategy }) => {
username: state.clientField,
password: state.passwordField,
rememberMe: state.rememberMeField,
assertionResponse: res
assertionResponse: res,
domain: window.location.hostname
}
: {
username: localClientField,
rememberMe: localRememberMeField,
assertionResponse: res
assertionResponse: res,
domain: window.location.hostname
}
validateAssertion({
variables

View file

@ -24,14 +24,17 @@ const LOGIN = gql`
`
const GENERATE_ASSERTION = gql`
query generateAssertionOptions {
generateAssertionOptions
query generateAssertionOptions($domain: String!) {
generateAssertionOptions(domain: $domain)
}
`
const VALIDATE_ASSERTION = gql`
mutation validateAssertion($assertionResponse: JSONObject!) {
validateAssertion(assertionResponse: $assertionResponse)
mutation validateAssertion(
$assertionResponse: JSONObject!
$domain: String!
) {
validateAssertion(assertionResponse: $assertionResponse, domain: $domain)
}
`
@ -117,7 +120,8 @@ const LoginState = ({ state, dispatch, strategy }) => {
.then(res => {
validateAssertion({
variables: {
assertionResponse: res
assertionResponse: res,
domain: window.location.hostname
}
})
})
@ -212,7 +216,9 @@ const LoginState = ({ state, dispatch, strategy }) => {
type="button"
onClick={() => {
return strategy === 'FIDOUsernameless'
? assertionOptions()
? assertionOptions({
variables: { domain: window.location.hostname }
})
: dispatch({
type: 'FIDO',
payload: {}

View file

@ -36,12 +36,7 @@ const DenominationsSchema = Yup.object().shape({
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
zeroConfLimit: Yup.number()
.label('0-conf Limit')
.required()
.min(0)
.max(CURRENCY_MAX)
.transform(transformNumber)
})
const getElements = (machines, locale = {}, classes) => {
@ -99,20 +94,6 @@ const getElements = (machines, locale = {}, classes) => {
1
)
elements.push({
name: 'zeroConfLimit',
header: '0-conf Limit',
size: 'sm',
stripe: true,
textAlign: 'right',
width: widthsByNumberOfCassettes[maxNumberOfCassettes].zeroConf,
input: NumberInput,
inputProps: {
decimalPlaces: 0
},
suffix: fiatCurrency
})
return elements
}

View file

@ -1,15 +1,15 @@
import { DialogActions, DialogContent, Dialog } from '@material-ui/core'
import Grid from '@material-ui/core/Grid'
import { makeStyles } from '@material-ui/core/styles'
import { parse, format } from 'date-fns/fp'
import _ from 'lodash/fp'
import * as R from 'ramda'
import { useState, React } from 'react'
import * as Yup from 'yup'
import ImagePopper from 'src/components/ImagePopper'
import { FeatureButton } from 'src/components/buttons'
import { FeatureButton, Button, IconButton } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { H3, Info3 } from 'src/components/typography'
import { H3, Info3, H2 } from 'src/components/typography'
import {
OVERRIDE_AUTHORIZED,
OVERRIDE_REJECTED
@ -17,19 +17,22 @@ import {
import { ReactComponent as CardIcon } from 'src/styling/icons/ID/card/comet.svg'
import { ReactComponent as PhoneIcon } from 'src/styling/icons/ID/phone/comet.svg'
import { ReactComponent as CrossedCameraIcon } from 'src/styling/icons/ID/photo/crossed-camera.svg'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/comet.svg'
import { ReactComponent as CustomerListViewReversedIcon } from 'src/styling/icons/circle buttons/customer-list-view/white.svg'
import { ReactComponent as CustomerListViewIcon } from 'src/styling/icons/circle buttons/customer-list-view/zodiac.svg'
import { ReactComponent as OverviewReversedIcon } from 'src/styling/icons/circle buttons/overview/white.svg'
import { ReactComponent as OverviewIcon } from 'src/styling/icons/circle buttons/overview/zodiac.svg'
import { URI } from 'src/utils/apollo'
import { onlyFirstToUpper } from 'src/utils/string'
import styles from './CustomerData.styles.js'
import { EditableCard } from './components'
import {
customerDataElements,
customerDataSchemas,
formatDates
formatDates,
getFormattedPhone
} from './helper.js'
const useStyles = makeStyles(styles)
@ -62,6 +65,7 @@ const Photo = ({ show, src }) => {
}
const CustomerData = ({
locale,
customer,
updateCustomer,
replacePhoto,
@ -69,10 +73,12 @@ const CustomerData = ({
deleteEditedData,
updateCustomRequest,
authorizeCustomRequest,
updateCustomEntry
updateCustomEntry,
retrieveAdditionalData
}) => {
const classes = useStyles()
const [listView, setListView] = useState(false)
const [retrieve, setRetrieve] = useState(false)
const idData = R.path(['idCardData'])(customer)
const rawExpirationDate = R.path(['expirationDate'])(idData)
@ -96,9 +102,12 @@ const CustomerData = ({
R.path(['customInfoRequests'])(customer) ?? []
)
const phone = R.path(['phone'])(customer)
const smsData = R.path(['subscriberInfo', 'result'])(customer)
const isEven = elem => elem % 2 === 0
const getVisibleCards = _.filter(elem => elem.isAvailable)
const getVisibleCards = R.filter(elem => elem.isAvailable)
const initialValues = {
idCardData: {
@ -126,9 +135,34 @@ const CustomerData = ({
},
idCardPhoto: {
idCardPhoto: null
},
smsData: {
phoneNumber: getFormattedPhone(phone, locale.country)
}
}
const smsDataElements = [
{
name: 'phoneNumber',
label: 'Phone number',
component: TextInput,
editable: false
}
]
const smsDataSchema = {
smsData: Yup.lazy(values => {
const additionalData = R.omit(['phoneNumber'])(values)
const fields = R.keys(additionalData)
if (R.length(fields) === 2) {
return Yup.object().shape({
[R.head(fields)]: Yup.string().required(),
[R.last(fields)]: Yup.string().required()
})
}
})
}
const cards = [
{
fields: customerDataElements.idCardData,
@ -141,19 +175,31 @@ const CustomerData = ({
deleteEditedData: () => deleteEditedData({ idCardData: null }),
save: values =>
editCustomer({
idCardData: _.merge(idData, formatDates(values))
idCardData: R.merge(idData, formatDates(values))
}),
validationSchema: customerDataSchemas.idCardData,
initialValues: initialValues.idCardData,
isAvailable: !_.isNil(idData)
isAvailable: !R.isNil(idData)
},
{
title: 'SMS Confirmation',
fields: smsDataElements,
title: 'SMS data',
titleIcon: <PhoneIcon className={classes.cardIcon} />,
authorize: () => {},
reject: () => {},
save: () => {},
isAvailable: false
state: R.path(['phoneOverride'])(customer),
authorize: () => updateCustomer({ phoneOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ phoneOverride: OVERRIDE_REJECTED }),
save: values => {
editCustomer({
subscriberInfo: {
result: R.merge(smsData, R.omit(['phoneNumber'])(values))
}
})
},
validationSchema: smsDataSchema.smsData,
retrieveAdditionalData: () => setRetrieve(true),
initialValues: initialValues.smsData,
isAvailable: !R.isNil(phone),
hasAdditionalData: !R.isNil(smsData) && !R.isEmpty(smsData)
},
{
title: 'Name',
@ -171,7 +217,7 @@ const CustomerData = ({
updateCustomer({ sanctionsOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ sanctionsOverride: OVERRIDE_REJECTED }),
children: <Info3>{sanctionsDisplay}</Info3>,
isAvailable: !_.isNil(sanctions)
isAvailable: !R.isNil(sanctions)
},
{
fields: customerDataElements.frontCamera,
@ -198,7 +244,7 @@ const CustomerData = ({
hasImage: true,
validationSchema: customerDataSchemas.frontCamera,
initialValues: initialValues.frontCamera,
isAvailable: !_.isNil(customer.frontCameraPath)
isAvailable: !R.isNil(customer.frontCameraPath)
},
{
fields: customerDataElements.idCardPhoto,
@ -223,7 +269,7 @@ const CustomerData = ({
hasImage: true,
validationSchema: customerDataSchemas.idCardPhoto,
initialValues: initialValues.idCardPhoto,
isAvailable: !_.isNil(customer.idCardPhotoPath)
isAvailable: !R.isNil(customer.idCardPhotoPath)
},
{
fields: customerDataElements.usSsn,
@ -236,7 +282,7 @@ const CustomerData = ({
deleteEditedData: () => deleteEditedData({ usSsn: null }),
validationSchema: customerDataSchemas.usSsn,
initialValues: initialValues.usSsn,
isAvailable: !_.isNil(customer.usSsn)
isAvailable: !R.isNil(customer.usSsn)
}
]
@ -247,7 +293,8 @@ const CustomerData = ({
name: it.customInfoRequest.id,
label: it.customInfoRequest.customRequest.name,
value: it.customerData.data ?? '',
component: TextInput
component: TextInput,
editable: true
}
],
title: it.customInfoRequest.customRequest.name,
@ -298,7 +345,8 @@ const CustomerData = ({
name: it.label,
label: it.label,
value: it.value ?? '',
component: TextInput
component: TextInput,
editable: true
}
],
title: it.label,
@ -319,6 +367,16 @@ const CustomerData = ({
})
}, R.path(['customFields'])(customer) ?? [])
R.forEach(it => {
initialValues.smsData[it] = smsData[it]
smsDataElements.push({
name: it,
label: onlyFirstToUpper(it),
component: TextInput,
editable: true
})
}, R.keys(smsData) ?? [])
const editableCard = (
{
title,
@ -329,10 +387,12 @@ const CustomerData = ({
fields,
save,
deleteEditedData,
retrieveAdditionalData,
children,
validationSchema,
initialValues,
hasImage
hasImage,
hasAdditionalData
},
idx
) => {
@ -345,12 +405,14 @@ const CustomerData = ({
state={state}
titleIcon={titleIcon}
hasImage={hasImage}
hasAdditionalData={hasAdditionalData}
fields={fields}
children={children}
validationSchema={validationSchema}
initialValues={initialValues}
save={save}
deleteEditedData={deleteEditedData}></EditableCard>
deleteEditedData={deleteEditedData}
retrieveAdditionalData={retrieveAdditionalData}></EditableCard>
)
}
@ -394,7 +456,7 @@ const CustomerData = ({
</Grid>
</Grid>
)}
{!_.isEmpty(customFields) && (
{!R.isEmpty(customFields) && (
<div className={classes.wrapper}>
<span className={classes.separator}>Custom data entry</span>
<Grid container>
@ -429,8 +491,67 @@ const CustomerData = ({
</div>
)}
</div>
<RetrieveDataDialog
setRetrieve={setRetrieve}
retrieveAdditionalData={retrieveAdditionalData}
open={retrieve}></RetrieveDataDialog>
</div>
)
}
const RetrieveDataDialog = ({
setRetrieve,
retrieveAdditionalData,
open,
props
}) => {
const classes = useStyles()
return (
<Dialog
open={open}
aria-labelledby="form-dialog-title"
PaperProps={{
style: {
borderRadius: 8,
minWidth: 656,
bottom: 125,
right: 7
}
}}
{...props}>
<div className={classes.closeButton}>
<IconButton
size={16}
aria-label="close"
onClick={() => setRetrieve(false)}>
<CloseIcon />
</IconButton>
</div>
<H2 className={classes.dialogTitle}>{'Retrieve API data from Twilio'}</H2>
<DialogContent className={classes.dialogContent}>
<Info3>{`With this action you'll be using Twilio's API to retrieve additional
data from this user. This includes name and address, if available.\n`}</Info3>
<Info3>{` There is a small cost from Twilio for each retrieval. Would you like
to proceed?`}</Info3>
</DialogContent>
<DialogActions className={classes.dialogActions}>
<Button
backgroundColor="grey"
className={classes.cancelButton}
onClick={() => setRetrieve(false)}>
Cancel
</Button>
<Button
onClick={() => {
retrieveAdditionalData()
setRetrieve(false)
}}>
Confirm
</Button>
</DialogActions>
</Dialog>
)
}
export default CustomerData

View file

@ -1,4 +1,4 @@
import { offColor } from 'src/styling/variables'
import { offColor, spacer } from 'src/styling/variables'
export default {
header: {
@ -45,5 +45,26 @@ export default {
left: '100%',
marginLeft: 15
}
},
closeButton: {
display: 'flex',
padding: [[spacer * 2, spacer * 2, 0, spacer * 2]],
paddingRight: spacer * 1.5,
justifyContent: 'end'
},
dialogTitle: {
margin: [[0, spacer * 2, spacer, spacer * 4 + spacer]]
},
dialogContent: {
width: 615,
marginLeft: 16
},
dialogActions: {
padding: spacer * 4,
paddingTop: spacer * 2
},
cancelButton: {
marginRight: 8,
padding: 0
}
}

View file

@ -69,6 +69,8 @@ const GET_CUSTOMER = gql`
daysSuspended
isSuspended
isTestCustomer
subscriberInfo
phoneOverride
customFields {
id
label
@ -138,6 +140,7 @@ const SET_CUSTOMER = gql`
lastTxFiatCode
lastTxClass
subscriberInfo
phoneOverride
}
}
`
@ -438,6 +441,16 @@ const CustomerProfile = memo(() => {
}
})
const retrieveAdditionalData = () =>
setCustomer({
variables: {
customerId,
customerInput: {
subscriberInfo: true
}
}
})
const onClickSidebarItem = code => setClickedItem(code)
const configData = R.path(['config'])(customerResponse) ?? []
@ -558,25 +571,6 @@ const CustomerProfile = memo(() => {
}>
{`${blocked ? 'Authorize' : 'Block'} customer`}
</ActionButton>
<ActionButton
color="primary"
className={classes.actionButton}
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
}
onClick={() =>
setCustomer({
variables: {
customerId,
customerInput: {
subscriberInfo: true
}
}
})
}>
{`Retrieve information`}
</ActionButton>
</div>
</div>
<div>
@ -628,6 +622,7 @@ const CustomerProfile = memo(() => {
{isCustomerData && (
<div>
<CustomerData
locale={locale}
customer={customerData}
updateCustomer={updateCustomer}
replacePhoto={replacePhoto}
@ -635,7 +630,8 @@ const CustomerProfile = memo(() => {
deleteEditedData={deleteEditedData}
updateCustomRequest={setCustomerCustomInfoRequest}
authorizeCustomRequest={authorizeCustomRequest}
updateCustomEntry={updateCustomEntry}></CustomerData>
updateCustomEntry={updateCustomEntry}
retrieveAdditionalData={retrieveAdditionalData}></CustomerData>
</div>
)}
{isNotes && (

View file

@ -8,7 +8,7 @@ import { useState, React } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { MainStatus } from 'src/components/Status'
import { HoverableTooltip } from 'src/components/Tooltip'
// import { HoverableTooltip } from 'src/components/Tooltip'
import { ActionButton } from 'src/components/buttons'
import { Label1, P, H3 } from 'src/components/typography'
import {
@ -23,6 +23,8 @@ import { ReactComponent as EditReversedIcon } from 'src/styling/icons/action/edi
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/white.svg'
import { ReactComponent as BlockIcon } from 'src/styling/icons/button/block/white.svg'
import { ReactComponent as CancelReversedIcon } from 'src/styling/icons/button/cancel/white.svg'
import { ReactComponent as DataReversedIcon } from 'src/styling/icons/button/data/white.svg'
import { ReactComponent as DataIcon } from 'src/styling/icons/button/data/zodiac.svg'
import { ReactComponent as ReplaceReversedIcon } from 'src/styling/icons/button/replace/white.svg'
import { ReactComponent as SaveReversedIcon } from 'src/styling/icons/circle buttons/save/white.svg'
import { comet } from 'src/styling/variables'
@ -67,6 +69,13 @@ const fieldStyles = {
fontSize: 14
}
}
},
readOnlyLabel: {
color: comet,
margin: [[3, 0, 3, 0]]
},
readOnlyValue: {
margin: 0
}
}
@ -105,6 +114,23 @@ const EditableField = ({ editing, field, value, size, ...props }) => {
)
}
const ReadOnlyField = ({ field, value, ...props }) => {
const classes = fieldUseStyles()
const classNames = {
[classes.field]: true,
[classes.notEditing]: true
}
return (
<>
<div className={classnames(classNames)}>
<Label1 className={classes.readOnlyLabel}>{field.label}</Label1>
<P className={classes.readOnlyValue}>{value}</P>
</div>
</>
)
}
const EditableCard = ({
fields,
save,
@ -117,7 +143,9 @@ const EditableCard = ({
children,
validationSchema,
initialValues,
deleteEditedData
deleteEditedData,
retrieveAdditionalData,
hasAdditionalData = true
}) => {
const classes = useStyles()
@ -148,7 +176,10 @@ const EditableCard = ({
<div className={classes.cardHeader}>
{titleIcon}
<H3 className={classes.cardTitle}>{title}</H3>
<HoverableTooltip width={304}></HoverableTooltip>
{
// TODO: Enable for next release
/* <HoverableTooltip width={304}></HoverableTooltip> */
}
</div>
{state && authorize && (
<div className={classnames(label1ClassNames)}>
@ -171,7 +202,7 @@ const EditableCard = ({
setEditing(false)
setError(false)
}}>
{({ values, touched, errors, setFieldValue }) => (
{({ setFieldValue }) => (
<Form>
<PromptWhenDirty />
<div className={classes.row}>
@ -180,12 +211,19 @@ const EditableCard = ({
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 0 && idx < 4 ? (
!field.editable ? (
<ReadOnlyField
field={field}
value={initialValues[field.name]}
/>
) : (
<EditableField
field={field}
value={initialValues[field.name]}
editing={editing}
size={180}
/>
)
) : null
})}
</Grid>
@ -193,12 +231,19 @@ const EditableCard = ({
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 4 ? (
!field.editable ? (
<ReadOnlyField
field={field}
value={initialValues[field.name]}
/>
) : (
<EditableField
field={field}
value={initialValues[field.name]}
editing={editing}
size={180}
/>
)
) : null
})}
</Grid>
@ -207,25 +252,34 @@ const EditableCard = ({
<div className={classes.edit}>
{!editing && (
<div className={classes.editButton}>
{// TODO: Remove false condition for next release
false && (
<div className={classes.deleteButton}>
{false && (
<ActionButton
color="primary"
type="button"
Icon={DeleteIcon}
InverseIcon={DeleteReversedIcon}
onClick={() => deleteEditedData()}>
{`Delete`}
Delete
</ActionButton>
</div>
)}
{!hasAdditionalData && (
<ActionButton
color="primary"
type="button"
Icon={DataIcon}
InverseIcon={DataReversedIcon}
onClick={() => retrieveAdditionalData()}>
Retrieve API data
</ActionButton>
)}
</div>
<ActionButton
color="primary"
Icon={EditIcon}
InverseIcon={EditReversedIcon}
onClick={() => setEditing(true)}>
{`Edit`}
Edit
</ActionButton>
</div>
)}

View file

@ -58,28 +58,36 @@ const ID_CARD_DATA = 'idCardData'
const getAuthorizedStatus = (it, triggers) => {
const fields = [
'frontCameraPath',
'frontCamera',
'idCardData',
'idCardPhotoPath',
'idCardPhoto',
'usSsn',
'sanctions'
]
const fieldsWithPathSuffix = ['frontCamera', 'idCardPhoto']
const isManualField = fieldName => {
const triggerName = R.equals(fieldName, 'frontCamera')
? 'facephoto'
: fieldName
const manualOverrides = R.filter(
ite => R.equals(R.toLower(ite.automation), MANUAL),
triggers?.overrides ?? []
)
return (
!!R.find(ite => R.equals(ite.requirement, fieldName), manualOverrides) ||
R.equals(triggers.automation, MANUAL)
!!R.find(
ite => R.equals(ite.requirement, triggerName),
manualOverrides
) || R.equals(R.toLower(triggers.automation), MANUAL)
)
}
const pendingFieldStatus = R.map(
ite =>
!R.isNil(it[`${ite}`])
!R.isNil(
R.includes(ite, fieldsWithPathSuffix) ? it[`${ite}Path`] : it[`${ite}`]
)
? isManualField(ite)
? R.equals(it[`${ite}Override`], 'automatic')
: false
@ -347,37 +355,44 @@ const customerDataElements = {
{
name: 'firstName',
label: 'First name',
component: TextInput
component: TextInput,
editable: true
},
{
name: 'documentNumber',
label: 'ID number',
component: TextInput
component: TextInput,
editable: true
},
{
name: 'dateOfBirth',
label: 'Birthdate',
component: TextInput
component: TextInput,
editable: true
},
{
name: 'gender',
label: 'Gender',
component: TextInput
component: TextInput,
editable: true
},
{
name: 'lastName',
label: 'Last name',
component: TextInput
component: TextInput,
editable: true
},
{
name: 'expirationDate',
label: 'Expiration Date',
component: TextInput
component: TextInput,
editable: true
},
{
name: 'country',
label: 'Country',
component: TextInput
component: TextInput,
editable: true
}
],
usSsn: [
@ -385,7 +400,8 @@ const customerDataElements = {
name: 'usSsn',
label: 'US SSN',
component: TextInput,
size: 190
size: 190,
editable: true
}
],
idCardPhoto: [{ name: 'idCardPhoto' }],

View file

@ -61,8 +61,10 @@ const Services = () => {
const updateSettings = element => {
const settings = element.settings
const wallet = R.lensPath(['config', 'wallets_BTC_wallet'])
const isEnabled = R.equals(R.view(wallet, data), settings.requirement)
const field = R.lensPath(['config', settings.field])
const isEnabled = R.isNil(settings.requirement)
? true
: R.equals(R.view(field, data), settings.requirement)
settings.enabled = isEnabled
return element
}

View file

@ -25,6 +25,7 @@ export default {
code: 'rbf',
component: CheckboxInput,
settings: {
field: 'wallets_BTC_wallet',
enabled: true,
disabledMessage: 'RBF verification not available',
label: 'Lower the confidence of RBF transactions',

View file

@ -0,0 +1,49 @@
import * as Yup from 'yup'
import CheckboxFormik from 'src/components/inputs/formik/Checkbox'
import NumberInputFormik from 'src/components/inputs/formik/NumberInput'
import SecretInputFormik from 'src/components/inputs/formik/SecretInput'
import secretTest from './helper'
export default {
code: 'ciphertrace',
name: 'CipherTrace',
title: 'CipherTrace (Scoring)',
elements: [
{
code: 'authorizationValue',
display: 'Authorization value',
component: SecretInputFormik
},
{
code: 'scoreThreshold',
display: 'Score threshold',
component: NumberInputFormik,
face: true,
long: true
},
{
code: 'enabled',
component: CheckboxFormik,
settings: {
enabled: true,
disabledMessage: 'This plugin is disabled',
label: 'Enabled',
requirement: null
},
face: true
}
],
getValidationSchema: account => {
return Yup.object().shape({
authorizationValue: Yup.string()
.max(100, 'Too long')
.test(secretTest(account?.authorizationValue)),
scoreThreshold: Yup.number()
.min(1, 'The number should be between 1 and 10')
.max(10, 'The number should be between 1 and 10')
.test(secretTest(account?.scoreThreshold))
})
}
}

View file

@ -3,6 +3,7 @@ import bitgo from './bitgo'
import bitstamp from './bitstamp'
import blockcypher from './blockcypher'
import cex from './cex'
import ciphertrace from './ciphertrace'
import ftx from './ftx'
import infura from './infura'
import itbit from './itbit'
@ -21,5 +22,6 @@ export default {
[twilio.code]: twilio,
[binanceus.code]: binanceus,
[cex.code]: cex,
[ftx.code]: ftx
[ftx.code]: ftx,
[ciphertrace.code]: ciphertrace
}

View file

@ -1,6 +1,7 @@
import { useLazyQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Box } from '@material-ui/core'
import BigNumber from 'bignumber.js'
import classNames from 'classnames'
import { add, differenceInYears, format, sub, parse } from 'date-fns/fp'
import FileSaver from 'file-saver'
import gql from 'graphql-tag'
@ -25,6 +26,12 @@ import { ReactComponent as DownloadInverseIcon } from 'src/styling/icons/button/
import { ReactComponent as Download } from 'src/styling/icons/button/download/zodiac.svg'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import {
primaryColor,
subheaderColor,
errorColor,
offErrorColor
} from 'src/styling/variables'
import { URI } from 'src/utils/apollo'
import { onlyFirstToUpper } from 'src/utils/string'
@ -186,6 +193,40 @@ const DetailsRow = ({ it: tx, timezone }) => {
</>
)
const walletScoreEl = (
<div className={classes.walletScore}>
<svg width={103} height={10}>
{R.map(
it => (
<circle
cx={it * 10 + 6}
cy={4}
r={3.5}
fill={
it < tx.walletScore
? !R.includes('score is above', tx.hasError ?? '')
? primaryColor
: errorColor
: !R.includes('score is above', tx.hasError ?? '')
? subheaderColor
: offErrorColor
}
/>
),
R.range(0, 10)
)}
</svg>
<P
noMargin
className={classNames({
[classes.bold]: true,
[classes.error]: R.includes('score is above', tx.hasError ?? '')
})}>
{tx.walletScore}
</P>
</div>
)
const getCancelMessage = () => {
const cashInMessage = `The user will not be able to redeem the inserted bills, even if they subsequently confirm the transaction. If they've already deposited bills, you'll need to reconcile this transaction with them manually.`
const cashOutMessage = `The user will not be able to redeem the cash, even if they subsequently send the required coins. If they've already sent you coins, you'll need to reconcile this transaction with them manually.`
@ -301,7 +342,14 @@ const DetailsRow = ({ it: tx, timezone }) => {
</div>
<div className={classes.secondRow}>
<div className={classes.address}>
<div className={classes.addressHeader}>
<Label>Address</Label>
{!R.isNil(tx.walletScore) && (
<HoverableTooltip parentElements={walletScoreEl}>
{`CipherTrace score: ${tx.walletScore}/10`}
</HoverableTooltip>
)}
</div>
<div>
<CopyToClipboard>
{formatAddress(tx.cryptoCode, tx.toAddress)}

View file

@ -1,5 +1,5 @@
import typographyStyles from 'src/components/typography/styles'
import { offColor, comet, white } from 'src/styling/variables'
import { offColor, comet, white, tomato } from 'src/styling/variables'
const { p } = typographyStyles
@ -113,5 +113,22 @@ export default {
otherActionsGroup: {
display: 'flex',
flexDirection: 'row'
},
addressHeader: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
},
walletScore: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
'& > p': {
marginLeft: 5
}
},
error: {
color: tomato
}
}

View file

@ -117,6 +117,7 @@ const GET_TRANSACTIONS = gql`
batched
batchTime
rawTickerPrice
walletScore
}
}
`

View file

@ -93,7 +93,7 @@ const CustomInfoRequests = ({
setToBeEdited(null)
toggleWizard()
},
refetchQueries: () => ['customInfoRequests']
refetchQueries: () => ['getData', 'customInfoRequests']
})
const [removeEntry] = useMutation(REMOVE_ROW, {
@ -105,7 +105,7 @@ const CustomInfoRequests = ({
setDeleteDialog(false)
setHasError(false)
},
refetchQueries: () => ['customInfoRequests']
refetchQueries: () => ['getData', 'customInfoRequests']
})
const handleDelete = id => {

View file

@ -16,11 +16,13 @@ import { ReactComponent as WhiteLockIcon } from 'src/styling/icons/button/lock/w
import { ReactComponent as LockIcon } from 'src/styling/icons/button/lock/zodiac.svg'
import { ReactComponent as WhiteUserRoleIcon } from 'src/styling/icons/button/user-role/white.svg'
import { ReactComponent as UserRoleIcon } from 'src/styling/icons/button/user-role/zodiac.svg'
import { IP_CHECK_REGEX } from 'src/utils/constants'
import styles from './UserManagement.styles'
import ChangeRoleModal from './modals/ChangeRoleModal'
import CreateUserModal from './modals/CreateUserModal'
import EnableUserModal from './modals/EnableUserModal'
import FIDOModal from './modals/FIDOModal'
import Reset2FAModal from './modals/Reset2FAModal'
import ResetPasswordModal from './modals/ResetPasswordModal'
@ -41,8 +43,8 @@ const GET_USERS = gql`
`
const GENERATE_ATTESTATION = gql`
query generateAttestationOptions($userID: ID!) {
generateAttestationOptions(userID: $userID)
query generateAttestationOptions($userID: ID!, $domain: String!) {
generateAttestationOptions(userID: $userID, domain: $domain)
}
`
@ -50,10 +52,12 @@ const VALIDATE_ATTESTATION = gql`
mutation validateAttestation(
$userID: ID!
$attestationResponse: JSONObject!
$domain: String!
) {
validateAttestation(
userID: $userID
attestationResponse: $attestationResponse
domain: $domain
)
}
`
@ -100,11 +104,12 @@ const Users = () => {
const [generateAttestationOptions] = useLazyQuery(GENERATE_ATTESTATION, {
onCompleted: ({ generateAttestationOptions: options }) => {
startAttestation(options).then(res => {
return startAttestation(options).then(res => {
validateAttestation({
variables: {
userID: userInfo.id,
attestationResponse: res
attestationResponse: res,
domain: window.location.hostname
}
})
})
@ -191,12 +196,20 @@ const Users = () => {
InverseIcon={WhiteUserRoleIcon}
color="primary"
onClick={() => {
if (IP_CHECK_REGEX.test(window.location.hostname)) {
dispatch({
type: 'open',
payload: 'showFIDOModal'
})
} else {
setUserInfo(u)
generateAttestationOptions({
variables: {
userID: u.id
userID: u.id,
domain: window.location.hostname
}
})
}
}}>
Add FIDO
</ActionButton>
@ -272,6 +285,7 @@ const Users = () => {
user={userInfo}
requiresConfirmation={userInfo?.role === 'superuser'}
/>
<FIDOModal state={state} dispatch={dispatch} />
</>
)
}

View file

@ -0,0 +1,47 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { Info2, P } from 'src/components/typography'
import styles from '../UserManagement.styles'
const useStyles = makeStyles(styles)
const ChangeRoleModal = ({ state, dispatch }) => {
const classes = useStyles()
const handleClose = () => {
dispatch({
type: 'close',
payload: 'showFIDOModal'
})
}
return (
<Modal
closeOnBackdropClick={true}
width={450}
height={275}
handleClose={handleClose}
open={state.showFIDOModal}>
<Info2 className={classes.modalTitle}>About FIDO authentication</Info2>
<P className={classes.info}>
This feature is only available for websites with configured domains, and
we detected that a domain is not configured at the moment.
</P>
<P>
Make sure that a domain is configured for this website and try again
later.
</P>
<div className={classes.footer}>
<Button className={classes.submit} onClick={() => handleClose()}>
Confirm
</Button>
</div>
</Modal>
)
}
export default ChangeRoleModal

View file

@ -8,6 +8,7 @@ import { ReactComponent as BitcoinCashLogo } from 'src/styling/logos/icon-bitcoi
import { ReactComponent as DashLogo } from 'src/styling/logos/icon-dash-colour.svg'
import { ReactComponent as EthereumLogo } from 'src/styling/logos/icon-ethereum-colour.svg'
import { ReactComponent as LitecoinLogo } from 'src/styling/logos/icon-litecoin-colour.svg'
import { ReactComponent as MoneroLogo } from 'src/styling/logos/icon-monero-colour.svg'
import { ReactComponent as TetherLogo } from 'src/styling/logos/icon-tether-colour.svg'
import { ReactComponent as ZCashLogo } from 'src/styling/logos/icon-zcash-colour.svg'
@ -53,6 +54,8 @@ const getLogo = code => {
return ZCashLogo
case 'USDT':
return TetherLogo
case 'XMR':
return MoneroLogo
default:
return null
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="13px" height="33px" viewBox="0 0 13 33" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<polygon id="Simple-Arrow-White" fill="#FFFFFF" fill-rule="nonzero" points="12.1912718 1.56064837 10.8306233 0.395663059 0.196798664 16.2200463 10.8250965 32.3956631 12.1967987 31.2473125 2.33241023 16.233075"></polygon>
<polygon id="Simple-Arrow-White" fill="#1b2559" fill-rule="nonzero" points="12.1912718 1.56064837 10.8306233 0.395663059 0.196798664 16.2200463 10.8250965 32.3956631 12.1967987 31.2473125 2.33241023 16.233075"></polygon>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 512 B

After

Width:  |  Height:  |  Size: 512 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="15px" height="34px" viewBox="0 0 15 34" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group-2-Copy" transform="translate(1.000000, 1.000000)" stroke="#FFFFFF" stroke-width="2">
<g id="Group-2-Copy" transform="translate(1.000000, 1.000000)" stroke="#1b2559" stroke-width="2">
<polyline id="Path-4-Copy" points="0 0 12 15.8202247 0 32"></polyline>
</g>
</g>

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 485 B

Before After
Before After

View file

@ -0,0 +1 @@
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M127.998 0C57.318 0 0 57.317 0 127.999c0 14.127 2.29 27.716 6.518 40.43H44.8V60.733l83.2 83.2 83.198-83.2v107.695h38.282c4.231-12.714 6.521-26.303 6.521-40.43C256 57.314 198.681 0 127.998 0" fill="#F60"/><path d="M108.867 163.062l-36.31-36.311v67.765H18.623c22.47 36.863 63.051 61.48 109.373 61.48s86.907-24.617 109.374-61.48h-53.933V126.75l-36.31 36.31-19.13 19.129-19.128-19.128h-.002z" fill="#4C4C4C"/></svg>

After

Width:  |  Height:  |  Size: 540 B

View file

@ -1,14 +1,19 @@
const CURRENCY_MAX = 9999999
const MIN_NUMBER_OF_CASSETTES = 2
const MAX_NUMBER_OF_CASSETTES = 4
const WALLET_SCORING_DEFAULT_THRESHOLD = 9
const AUTOMATIC = 'automatic'
const MANUAL = 'manual'
const IP_CHECK_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
export {
CURRENCY_MAX,
MIN_NUMBER_OF_CASSETTES,
MAX_NUMBER_OF_CASSETTES,
AUTOMATIC,
MANUAL
MANUAL,
WALLET_SCORING_DEFAULT_THRESHOLD,
IP_CHECK_REGEX
}

View file

@ -117,7 +117,7 @@
"test": "mocha --recursive tests",
"jtest": "jest --detectOpenHandles",
"build-admin": "npm run build-admin:css && npm run build-admin:main && npm run build-admin:lamassu",
"server": "nodemon bin/lamassu-server --mockSms --mockScoring",
"server": "nodemon bin/lamassu-server --mockSms",
"admin-server": "nodemon bin/lamassu-admin-server --dev",
"graphql-server": "nodemon bin/new-graphql-dev-insecure",
"watch": "concurrently \"npm:server\" \"npm:admin-server\" \"npm:graphql-server\"",

View file

@ -1,13 +1,13 @@
{
"files": {
"main.js": "/static/js/main.af6b9292.chunk.js",
"main.js.map": "/static/js/main.af6b9292.chunk.js.map",
"main.js": "/static/js/main.6ef30c04.chunk.js",
"main.js.map": "/static/js/main.6ef30c04.chunk.js.map",
"runtime-main.js": "/static/js/runtime-main.5b925903.js",
"runtime-main.js.map": "/static/js/runtime-main.5b925903.js.map",
"static/js/2.cbbac6fe.chunk.js": "/static/js/2.cbbac6fe.chunk.js",
"static/js/2.cbbac6fe.chunk.js.map": "/static/js/2.cbbac6fe.chunk.js.map",
"static/js/2.c4e7abab.chunk.js": "/static/js/2.c4e7abab.chunk.js",
"static/js/2.c4e7abab.chunk.js.map": "/static/js/2.c4e7abab.chunk.js.map",
"index.html": "/index.html",
"static/js/2.cbbac6fe.chunk.js.LICENSE.txt": "/static/js/2.cbbac6fe.chunk.js.LICENSE.txt",
"static/js/2.c4e7abab.chunk.js.LICENSE.txt": "/static/js/2.c4e7abab.chunk.js.LICENSE.txt",
"static/media/3-cassettes-open-1-left.d6d9aa73.svg": "/static/media/3-cassettes-open-1-left.d6d9aa73.svg",
"static/media/3-cassettes-open-2-left.a9ee8d4c.svg": "/static/media/3-cassettes-open-2-left.a9ee8d4c.svg",
"static/media/3-cassettes-open-3-left.08fed660.svg": "/static/media/3-cassettes-open-3-left.08fed660.svg",
@ -60,6 +60,7 @@
"static/media/icon-dash-colour.e01c021b.svg": "/static/media/icon-dash-colour.e01c021b.svg",
"static/media/icon-ethereum-colour.761723a2.svg": "/static/media/icon-ethereum-colour.761723a2.svg",
"static/media/icon-litecoin-colour.bd861b5e.svg": "/static/media/icon-litecoin-colour.bd861b5e.svg",
"static/media/icon-monero-colour.650b7bd1.svg": "/static/media/icon-monero-colour.650b7bd1.svg",
"static/media/icon-tether-colour.92d7fda4.svg": "/static/media/icon-tether-colour.92d7fda4.svg",
"static/media/icon-zcash-colour.68b1c20b.svg": "/static/media/icon-zcash-colour.68b1c20b.svg",
"static/media/keyboard.cc22b859.svg": "/static/media/keyboard.cc22b859.svg",
@ -91,6 +92,7 @@
"static/media/white.41439910.svg": "/static/media/white.41439910.svg",
"static/media/white.460daa02.svg": "/static/media/white.460daa02.svg",
"static/media/white.4676bf59.svg": "/static/media/white.4676bf59.svg",
"static/media/white.47196e40.svg": "/static/media/white.47196e40.svg",
"static/media/white.51296906.svg": "/static/media/white.51296906.svg",
"static/media/white.5750bfd1.svg": "/static/media/white.5750bfd1.svg",
"static/media/white.5a37327b.svg": "/static/media/white.5a37327b.svg",
@ -100,16 +102,17 @@
"static/media/white.81edd31f.svg": "/static/media/white.81edd31f.svg",
"static/media/white.8406a3ba.svg": "/static/media/white.8406a3ba.svg",
"static/media/white.87f75e06.svg": "/static/media/white.87f75e06.svg",
"static/media/white.8c4085b7.svg": "/static/media/white.8c4085b7.svg",
"static/media/white.8ccc4767.svg": "/static/media/white.8ccc4767.svg",
"static/media/white.958fe55d.svg": "/static/media/white.958fe55d.svg",
"static/media/white.9814829c.svg": "/static/media/white.9814829c.svg",
"static/media/white.9f2c5216.svg": "/static/media/white.9f2c5216.svg",
"static/media/white.b7754662.svg": "/static/media/white.b7754662.svg",
"static/media/white.bd0d7dca.svg": "/static/media/white.bd0d7dca.svg",
"static/media/white.cc7667ff.svg": "/static/media/white.cc7667ff.svg",
"static/media/white.e53d9d4a.svg": "/static/media/white.e53d9d4a.svg",
"static/media/white.e72682b5.svg": "/static/media/white.e72682b5.svg",
"static/media/white.e8851a0a.svg": "/static/media/white.e8851a0a.svg",
"static/media/white.efb6eb57.svg": "/static/media/white.efb6eb57.svg",
"static/media/white.f97c75d2.svg": "/static/media/white.f97c75d2.svg",
"static/media/white.fa4681e8.svg": "/static/media/white.fa4681e8.svg",
"static/media/white.fe6ed797.svg": "/static/media/white.fe6ed797.svg",
@ -117,6 +120,7 @@
"static/media/zodiac-resized.c4907e4b.svg": "/static/media/zodiac-resized.c4907e4b.svg",
"static/media/zodiac.088002a2.svg": "/static/media/zodiac.088002a2.svg",
"static/media/zodiac.13543418.svg": "/static/media/zodiac.13543418.svg",
"static/media/zodiac.1806a875.svg": "/static/media/zodiac.1806a875.svg",
"static/media/zodiac.1bc04c23.svg": "/static/media/zodiac.1bc04c23.svg",
"static/media/zodiac.1bd00dea.svg": "/static/media/zodiac.1bd00dea.svg",
"static/media/zodiac.2fe856d5.svg": "/static/media/zodiac.2fe856d5.svg",
@ -124,6 +128,7 @@
"static/media/zodiac.5547e32c.svg": "/static/media/zodiac.5547e32c.svg",
"static/media/zodiac.594ae9e7.svg": "/static/media/zodiac.594ae9e7.svg",
"static/media/zodiac.6cff3051.svg": "/static/media/zodiac.6cff3051.svg",
"static/media/zodiac.71910a69.svg": "/static/media/zodiac.71910a69.svg",
"static/media/zodiac.74570495.svg": "/static/media/zodiac.74570495.svg",
"static/media/zodiac.779a5bbc.svg": "/static/media/zodiac.779a5bbc.svg",
"static/media/zodiac.84e03611.svg": "/static/media/zodiac.84e03611.svg",
@ -136,17 +141,16 @@
"static/media/zodiac.aa028a2c.svg": "/static/media/zodiac.aa028a2c.svg",
"static/media/zodiac.b27733af.svg": "/static/media/zodiac.b27733af.svg",
"static/media/zodiac.bb7722c5.svg": "/static/media/zodiac.bb7722c5.svg",
"static/media/zodiac.c424c928.svg": "/static/media/zodiac.c424c928.svg",
"static/media/zodiac.cdf82496.svg": "/static/media/zodiac.cdf82496.svg",
"static/media/zodiac.ce4a1545.svg": "/static/media/zodiac.ce4a1545.svg",
"static/media/zodiac.cfe5467c.svg": "/static/media/zodiac.cfe5467c.svg",
"static/media/zodiac.e161cf6b.svg": "/static/media/zodiac.e161cf6b.svg",
"static/media/zodiac.e181d06a.svg": "/static/media/zodiac.e181d06a.svg",
"static/media/zodiac.eea12e4f.svg": "/static/media/zodiac.eea12e4f.svg",
"static/media/zodiac.f9cb5ba2.svg": "/static/media/zodiac.f9cb5ba2.svg"
"static/media/zodiac.f3536991.svg": "/static/media/zodiac.f3536991.svg"
},
"entrypoints": [
"static/js/runtime-main.5b925903.js",
"static/js/2.cbbac6fe.chunk.js",
"static/js/main.af6b9292.chunk.js"
"static/js/2.c4e7abab.chunk.js",
"static/js/main.6ef30c04.chunk.js"
]
}

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/><meta name="robots" content="noindex"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Lamassu Admin</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root" class="root"></div><script>!function(e){function r(r){for(var n,a,l=r[0],i=r[1],f=r[2],c=0,s=[];c<l.length;c++)a=l[c],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,f||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var i=t[l];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={1:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,function(r){return e[r]}.bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="/";var l=this["webpackJsonplamassu-admin"]=this["webpackJsonplamassu-admin"]||[],i=l.push.bind(l);l.push=r,l=l.slice();for(var f=0;f<l.length;f++)r(l[f]);var p=i;t()}([])</script><script src="/static/js/2.cbbac6fe.chunk.js"></script><script src="/static/js/main.af6b9292.chunk.js"></script></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/><meta name="robots" content="noindex"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Lamassu Admin</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root" class="root"></div><script>!function(e){function r(r){for(var n,a,l=r[0],i=r[1],f=r[2],c=0,s=[];c<l.length;c++)a=l[c],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,f||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var i=t[l];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={1:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,function(r){return e[r]}.bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="/";var l=this["webpackJsonplamassu-admin"]=this["webpackJsonplamassu-admin"]||[],i=l.push.bind(l);l.push=r,l=l.slice();for(var f=0;f<l.length;f++)r(l[f]);var p=i;t()}([])</script><script src="/static/js/2.c4e7abab.chunk.js"></script><script src="/static/js/main.6ef30c04.chunk.js"></script></body></html>

File diff suppressed because one or more lines are too long

View file

@ -17,15 +17,6 @@ object-assign
* @license MIT
*/
/*!
* UAParser.js v0.7.22
* Lightweight JavaScript-based User-Agent string parser
* https://github.com/faisalman/ua-parser-js
*
* Copyright © 2012-2019 Faisal Salman <f@faisalman.com>
* Licensed under MIT License
*/
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M127.998 0C57.318 0 0 57.317 0 127.999c0 14.127 2.29 27.716 6.518 40.43H44.8V60.733l83.2 83.2 83.198-83.2v107.695h38.282c4.231-12.714 6.521-26.303 6.521-40.43C256 57.314 198.681 0 127.998 0" fill="#F60"/><path d="M108.867 163.062l-36.31-36.311v67.765H18.623c22.47 36.863 63.051 61.48 109.373 61.48s86.907-24.617 109.374-61.48h-53.933V126.75l-36.31 36.31-19.13 19.129-19.128-19.128h-.002z" fill="#4C4C4C"/></svg>

After

Width:  |  Height:  |  Size: 540 B

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/user-role/white</title>
<g id="icon/button/user-role/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
<g id="User-Role-Icon-White" transform="translate(2.500000, 0.500000)" stroke="#FFFFFF">
<path d="M5.50008791,6.84274776 L5.5,11 L3.66666667,9.35927189 L1.83333333,11 L1.83223109,6.84216075 C2.37179795,7.15453375 2.99835187,7.33333333 3.66666667,7.33333333 C4.33456272,7.33333333 4.96075021,7.15475774 5.50008791,6.84274776 Z" id="Bottom"></path>
<circle id="Top" cx="3.66666667" cy="3.66666667" r="3.66666667"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 840 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/key/white</title>
<g id="icon/button/key/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group" transform="translate(0.500000, 0.500000)" stroke="#FFFFFF">
<circle id="Oval" cx="2.75" cy="8.25" r="2.75"></circle>
<line x1="5.04166667" y1="5.95833333" x2="11" y2="0" id="Path-13" stroke-linecap="round" stroke-linejoin="round"></line>
<line x1="8.25" y1="3.66666667" x2="10.5416667" y2="1.375" id="Path-13-Copy" stroke-width="2" stroke-linejoin="round"></line>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 773 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/lock/white</title>
<g id="icon/button/lock/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Lock-Icon-White" transform="translate(0.500000, 0.500000)">
<path d="M7.98058644,2.48058644 C7.98058644,1.11059638 6.86999006,0 5.5,0 C4.13000994,0 3.01941356,1.11059638 3.01941356,2.48058644 C3.01941356,3.39391315 3.01941356,4.09482878 3.01941356,4.58333333 L7.98058644,4.58333333 C7.98058644,4.09482878 7.98058644,3.39391315 7.98058644,2.48058644 Z" id="Lock" stroke="#FFFFFF" stroke-linejoin="round"></path>
<rect id="Body" stroke="#FFFFFF" stroke-linejoin="round" x="0" y="4.58333333" width="11" height="6.41666667"></rect>
<circle id="Key-Hole" fill="#FFFFFF" cx="5.5" cy="7.33333333" r="1"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1,010 B

Some files were not shown because too many files have changed in this diff Show more