Merge pull request #540 from lamassu/dev

prepare for 7.5.0-beta.1
This commit is contained in:
Rafael Taranto 2020-12-17 16:26:31 +00:00 committed by GitHub
commit e381efed6d
36 changed files with 5881 additions and 216 deletions

View file

@ -225,6 +225,7 @@
"it-CH", "it-CH",
"it-IT", "it-IT",
"ja-JP", "ja-JP",
"ka-GE",
"ko-KR", "ko-KR",
"ky-KG", "ky-KG",
"lt-LT", "lt-LT",

View file

@ -1,22 +1,52 @@
const db = require('./db') const db = require('./db')
function blocked (address, cryptoCode) { // Get all blacklist rows from the DB "blacklist" table
const sql = `select * from blacklist where address = $1 and crypto_code = $2` const getBlacklist = () => {
return db.any(sql, [ return db.any('select * from blacklist').then(res =>
address, res.map(item => ({
cryptoCode cryptoCode: item.crypto_code,
]) address: item.address,
createdByOperator: item.created_by_operator
}))
)
} }
function addToUsedAddresses (address, cryptoCode) { // Delete row from blacklist table by crypto code and address
const deleteFromBlacklist = (cryptoCode, address) => {
return db.none(
'delete from blacklist where crypto_code = $1 and address = $2;',
[cryptoCode, address]
)
}
const insertIntoBlacklist = (cryptoCode, address) => {
return db
.any(
'insert into blacklist(crypto_code, address, created_by_operator) values($1, $2, $3);',
[cryptoCode, address, true]
)
.then(() => {
return { cryptoCode, address }
})
}
function blocked(address, cryptoCode) {
const sql = `select * from blacklist where address = $1 and crypto_code = $2`
return db.any(sql, [address, cryptoCode])
}
function addToUsedAddresses(address, cryptoCode) {
// ETH reuses addresses // ETH reuses addresses
if (cryptoCode === 'ETH') return Promise.resolve() if (cryptoCode === 'ETH') return Promise.resolve()
const sql = `insert into blacklist(crypto_code, address, created_by_operator) values ($1, $2, 'f')` const sql = `insert into blacklist(crypto_code, address, created_by_operator) values ($1, $2, 'f')`
return db.oneOrNone(sql, [ return db.oneOrNone(sql, [cryptoCode, address])
cryptoCode,
address
])
} }
module.exports = { blocked, addToUsedAddresses } module.exports = {
blocked,
addToUsedAddresses,
getBlacklist,
deleteFromBlacklist,
insertIntoBlacklist
}

View file

@ -25,5 +25,6 @@ connections=40
keypool=10000 keypool=10000
prune=4000 prune=4000
daemon=0 daemon=0
addresstype=p2sh-segwit` addresstype=p2sh-segwit
walletrbf=1`
} }

View file

@ -25,24 +25,24 @@ const BINARIES = {
dir: 'bitcoin-0.20.1/bin' dir: 'bitcoin-0.20.1/bin'
}, },
ETH: { ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.23-8c2f2715.tar.gz', url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.24-cc05b050.tar.gz',
dir: 'geth-linux-amd64-1.9.23-8c2f2715' dir: 'geth-linux-amd64-1.9.24-cc05b050'
}, },
ZEC: { ZEC: {
url: 'https://download.z.cash/downloads/zcash-4.1.0-linux64-debian-stretch.tar.gz', url: 'https://z.cash/downloads/zcash-4.1.1-linux64-debian-stretch.tar.gz',
dir: 'zcash-4.1.0/bin' dir: 'zcash-4.1.1/bin'
}, },
DASH: { DASH: {
url: 'https://github.com/dashpay/dash/releases/download/v0.16.0.1/dashcore-0.16.0.1-x86_64-linux-gnu.tar.gz', url: 'https://github.com/dashpay/dash/releases/download/v0.16.1.1/dashcore-0.16.1.1-x86_64-linux-gnu.tar.gz',
dir: 'dashcore-0.16.0/bin' dir: 'dashcore-0.16.1/bin'
}, },
LTC: { LTC: {
url: 'https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz', url: 'https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz',
dir: 'litecoin-0.18.1/bin' dir: 'litecoin-0.18.1/bin'
}, },
BCH: { BCH: {
url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v22.1.0/bitcoin-cash-node-22.1.0-x86_64-linux-gnu.tar.gz', url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v22.2.0/bitcoin-cash-node-22.2.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-cash-node-22.1.0/bin', dir: 'bitcoin-cash-node-22.2.0/bin',
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']] files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
} }
} }

View file

@ -24,7 +24,6 @@ module.exports = { update }
function mapCoin (rates, deviceId, settings, cryptoCode) { function mapCoin (rates, deviceId, settings, cryptoCode) {
const config = settings.config const config = settings.config
const buildedRates = plugins(settings, deviceId).buildRates(rates)[cryptoCode] || { cashIn: null, cashOut: null } const buildedRates = plugins(settings, deviceId).buildRates(rates)[cryptoCode] || { cashIn: null, cashOut: null }
const commissions = configManager.getCommissions(cryptoCode, deviceId, config) const commissions = configManager.getCommissions(cryptoCode, deviceId, config)
const coinAtmRadar = configManager.getCoinAtmRadar(config) const coinAtmRadar = configManager.getCoinAtmRadar(config)
@ -64,28 +63,17 @@ function mapMachine (rates, settings, machineRow) {
const coinAtmRadar = configManager.getCoinAtmRadar(config) const coinAtmRadar = configManager.getCoinAtmRadar(config)
const triggers = configManager.getTriggers(config) const triggers = configManager.getTriggers(config)
const triggerCashLimit = complianceTriggers.getCashLimit(triggers)
const locale = configManager.getLocale(deviceId, config) const locale = configManager.getLocale(deviceId, config)
const cashOutConfig = configManager.getCashOut(deviceId, config) const cashOutConfig = configManager.getCashOut(deviceId, config)
const cashOutEnabled = cashOutConfig.active ? cashOutConfig.active : false
const lastOnline = machineRow.last_online.toISOString() const lastOnline = machineRow.last_online.toISOString()
const status = machineRow.stale ? 'online' : 'offline' const status = machineRow.stale ? 'online' : 'offline'
const showSupportedCryptocurrencies = coinAtmRadar.supportedCryptocurrencies
const showSupportedFiat = coinAtmRadar.supportedFiat
const showSupportedBuySellDirection = coinAtmRadar.supportedBuySellDirection
const showLimitsAndVerification = coinAtmRadar.limitsAndVerification const showLimitsAndVerification = coinAtmRadar.limitsAndVerification
const cashLimit = showLimitsAndVerification ? ( complianceTriggers.getCashLimit(triggers)?.threshold || Infinity ) : null
const cashLimit = showLimitsAndVerification ? ( triggerCashLimit || Infinity ) : null
const cryptoCurrencies = locale.cryptoCurrencies const cryptoCurrencies = locale.cryptoCurrencies
const cashInEnabled = showSupportedBuySellDirection ? true : null
const cashOutEnabled = showSupportedBuySellDirection ? cashOutConfig.active : null
const fiat = showSupportedFiat ? locale.fiatCurrency : null
const identification = mapIdentification(config) const identification = mapIdentification(config)
const coins = showSupportedCryptocurrencies ? const coins = _.map(_.partial(mapCoin, [rates, deviceId, settings]), cryptoCurrencies)
_.map(_.partial(mapCoin, [rates, deviceId, settings]), cryptoCurrencies)
: null
return { return {
machineId: deviceId, machineId: deviceId,
address: { address: {
@ -102,14 +90,14 @@ function mapMachine (rates, settings, machineRow) {
}, },
status, status,
lastOnline, lastOnline,
cashIn: cashInEnabled, cashIn: true,
cashOut: cashOutEnabled, cashOut: cashOutEnabled,
manufacturer: 'lamassu', manufacturer: 'lamassu',
cashInTxLimit: cashLimit, cashInTxLimit: cashLimit,
cashOutTxLimit: cashLimit, cashOutTxLimit: cashLimit,
cashInDailyLimit: cashLimit, cashInDailyLimit: cashLimit,
cashOutDailyLimit: cashLimit, cashOutDailyLimit: cashLimit,
fiatCurrency: fiat, fiatCurrency: locale.fiatCurrency,
identification, identification,
coins coins
} }
@ -120,7 +108,6 @@ function getMachines (rates, settings) {
where display=TRUE and where display=TRUE and
paired=TRUE paired=TRUE
order by created` order by created`
return db.any(sql, [STALE_INTERVAL]) return db.any(sql, [STALE_INTERVAL])
.then(_.map(_.partial(mapMachine, [rates, settings]))) .then(_.map(_.partial(mapMachine, [rates, settings])))
} }
@ -140,9 +127,7 @@ function sendRadar (data) {
maxContentLength: MAX_CONTENT_LENGTH maxContentLength: MAX_CONTENT_LENGTH
} }
console.log('%j', data) return axios.default(config)
return axios(config)
.then(r => console.log(r.status)) .then(r => console.log(r.status))
} }

View file

@ -0,0 +1,259 @@
const yup = require('yup')
const BigNumber = require('../../../lib/bn')
const car = require('../coinatmradar')
const db = require('../../db')
jest.mock('../../db')
afterEach(() => {
// https://stackoverflow.com/questions/58151010/difference-between-resetallmocks-resetmodules-resetmoduleregistry-restoreallm
jest.restoreAllMocks()
})
const settings = {
config: {
wallets_BTC_coin: 'BTC',
wallets_BTC_wallet: 'mock-wallet',
wallets_BTC_ticker: 'kraken',
wallets_BTC_exchange: 'mock-exchange',
wallets_BTC_zeroConf: 'all-zero-conf',
locale_id: '1983951f-6c73-4308-ae6e-f6f56dfa5e11',
locale_country: 'US',
locale_fiatCurrency: 'USD',
locale_languages: ['en-US'],
locale_cryptoCurrencies: ['BTC', 'ETH', 'LTC', 'DASH', 'ZEC', 'BCH'],
commissions_minimumTx: 1,
commissions_fixedFee: 2,
commissions_cashOut: 11,
commissions_cashIn: 11,
commissions_id: '960bb192-db37-40eb-9b59-2c2c78620de6',
wallets_ETH_active: true,
wallets_ETH_ticker: 'bitstamp',
wallets_ETH_wallet: 'mock-wallet',
wallets_ETH_exchange: 'mock-exchange',
wallets_ETH_zeroConf: 'mock-zero-conf',
wallets_LTC_active: true,
wallets_LTC_ticker: 'kraken',
wallets_LTC_wallet: 'mock-wallet',
wallets_LTC_exchange: 'mock-exchange',
wallets_LTC_zeroConf: 'mock-zero-conf',
wallets_DASH_active: true,
wallets_DASH_ticker: 'coinbase',
wallets_DASH_wallet: 'mock-wallet',
wallets_DASH_exchange: 'mock-exchange',
wallets_DASH_zeroConf: 'mock-zero-conf',
wallets_ZEC_active: true,
wallets_ZEC_ticker: 'coinbase',
wallets_ZEC_wallet: 'mock-wallet',
wallets_ZEC_exchange: 'mock-exchange',
wallets_ZEC_zeroConf: 'mock-zero-conf',
wallets_BCH_active: true,
wallets_BCH_ticker: 'bitpay',
wallets_BCH_wallet: 'mock-wallet',
wallets_BCH_exchange: 'mock-exchange',
wallets_BCH_zeroConf: 'mock-zero-conf',
cashOut_7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4_zeroConfLimit: 50,
cashOut_7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4_bottom: 20,
cashOut_7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4_top: 5,
cashOut_7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4_active: true,
cashOut_f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05_zeroConfLimit: 200,
cashOut_f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05_bottom: 20,
cashOut_f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05_top: 5,
cashOut_f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05_active: true,
notifications_email_active: false,
notifications_sms_active: true,
notifications_email_errors: false,
notifications_sms_errors: true,
coinAtmRadar_active: true,
coinAtmRadar_commissions: true,
coinAtmRadar_limitsAndVerification: true,
triggers: [
{
requirement: 'suspend',
suspensionDays: 1,
threshold: 123,
id: '9c3b5af8-b1d1-4125-b169-0e913b33894c',
direction: 'both',
triggerType: 'txAmount'
},
{
requirement: 'sms',
threshold: 999,
thresholdDays: 1,
id: 'b0e1e6a8-be1b-4e43-ac5f-3e4951e86f8b',
direction: 'both',
triggerType: 'txVelocity'
},
{
requirement: 'sms',
threshold: 888,
thresholdDays: 1,
id: '6ac38fe6-172c-48a4-8a7f-605213cbd600',
direction: 'both',
triggerType: 'txVolume'
}
],
notifications_sms_transactions: true,
notifications_highValueTransaction: 50
},
accounts: {}
}
const rates = [
{
rates: {
ask: BigNumber(19164.3),
bid: BigNumber(19164.2)
},
timestamp: +new Date()
},
{
rates: {
ask: BigNumber(594.54),
bid: BigNumber(594.09)
},
timestamp: +new Date()
},
{
rates: {
ask: BigNumber(84.38),
bid: BigNumber(84.37)
},
timestamp: +new Date()
},
{
rates: {
ask: BigNumber(102.8),
bid: BigNumber(101.64)
},
timestamp: +new Date()
},
{
rates: {
ask: BigNumber(74.91),
bid: BigNumber(74.12)
},
timestamp: +new Date()
},
{
rates: {
ask: BigNumber(284.4),
bid: BigNumber(284.4)
},
timestamp: +new Date()
}
]
const dbResponse = [
{
device_id:
'mock7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
last_online: new Date('2020-11-16T13:11:03.169Z'),
stale: false
},
{
device_id:
'9871e58aa2643ff9445cbc299b50397430ada75157d6c29b4c93548fff0f48f7',
last_online: new Date('2020-11-16T16:21:35.948Z'),
stale: false
},
{
device_id:
'5ae0d02dedeb77b6521bd5eb7c9159bdc025873fa0bcb6f87aaddfbda0c50913',
last_online: new Date('2020-11-19T15:07:57.089Z'),
stale: false
},
{
device_id:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
last_online: new Date('2020-11-26T20:05:57.792Z'),
stale: false
},
{
device_id:
'490ab16ee0c124512dc769be1f3e7ee3894ce1e5b4b8b975e134fb326e551e88',
last_online: new Date('2020-12-04T16:48:05.129Z'),
stale: false
}
]
function validateData(data) {
const schema = yup.object().shape({
operatorId: yup.string().required('operatorId not provided'),
operator: yup.object().shape({
name: yup.string().nullable(),
phone: yup.string().nullable(),
email: yup.string().email().nullable()
}),
timestamp: yup.string().required('timestamp not provided'),
machines: yup.array().of(
yup.object().shape({
machineId: yup.string().required('machineId not provided'),
address: yup.object().required('address object not provided').shape({
streetAddress: yup.string().nullable(),
city: yup.string().nullable(),
region: yup.string().nullable(),
postalCode: yup.string().nullable(),
country: yup.string().nullable()
}),
location: yup.object().required('location object not provided').shape({
name: yup.string().nullable(),
url: yup.string().nullable(),
phone: yup.string().nullable()
}),
status: yup
.string()
.required('status not provided')
.oneOf(['online', 'offline']),
lastOnline: yup
.string()
.required('date in isostring format not provided'),
cashIn: yup.boolean().required('cashIn boolean not defined'),
cashOut: yup.boolean().required('cashOut boolean not defined'),
manufacturer: yup.string().required('manufacturer not provided'),
cashInTxLimit: yup.number().nullable(),
cashOutTxLimit: yup.number().nullable(),
cashInDailyLimit: yup.number().nullable(),
cashOutDailyLimit: yup.number().nullable(),
fiatCurrency: yup.string().required('fiatCurrency not provided'),
identification: yup.object().shape({
isPhone: yup.boolean().required('isPhone boolean not defined'),
isPalmVein: yup.boolean().required('isPalmVein boolean not defined'),
isPhoto: yup.boolean().required('isPhoto boolean not defined'),
isIdDocScan: yup
.boolean()
.required('isIdDocScan boolean not defined'),
isFingerprint: yup
.boolean()
.required('isFingerprint boolean not defined')
}),
coins: yup.array().of(
yup.object().shape({
cryptoCode: yup.string().required('cryptoCode not provided'),
cashInFee: yup.number().nullable(),
cashOutFee: yup.number().nullable(),
cashInFixedFee: yup.number().nullable(),
cashInRate: yup.number().nullable(),
cashOutRate: yup.number().nullable()
})
)
})
)
})
return schema.validate(data)
}
test('Verify axios request schema', async () => {
const axios = require('axios')
jest.spyOn(axios, 'default').mockImplementation(
jest.fn(req =>
validateData(req.data)
.then(() => ({ status: 'mock status 200' }))
.catch(e => fail(e))
)
)
db.any.mockResolvedValue(dbResponse)
await car.update(rates, settings)
})

View file

@ -15,9 +15,8 @@ function maxDaysThreshold (triggers) {
} }
function getCashLimit (triggers) { function getCashLimit (triggers) {
const withFiat = _.filter(({ triggerType }) => _.includes(['txVolume', 'txAmount'])(triggerType)) const withFiat = _.filter(({ triggerType }) => _.includes(triggerType, ['txVolume', 'txAmount']))
const blocking = _.filter(({ requirement }) => _.includes(['block', 'suspend'])(requirement)) const blocking = _.filter(({ requirement }) => _.includes(requirement, ['block', 'suspend']))
return _.compose(_.minBy('threshold'), blocking, withFiat)(triggers) return _.compose(_.minBy('threshold'), blocking, withFiat)(triggers)
} }

View file

@ -97,7 +97,7 @@ function update (id, data, userToken, txId) {
* *
* @returns {Promise} Newly updated Customer * @returns {Promise} Newly updated Customer
*/ */
async function updateCustomer (id, data) { async function updateCustomer (id, data, userToken) {
const formattedData = _.pick( const formattedData = _.pick(
[ [
'authorized_override', 'authorized_override',
@ -110,7 +110,10 @@ async function updateCustomer (id, data) {
], ],
_.mapKeys(_.snakeCase, data)) _.mapKeys(_.snakeCase, data))
const sql = Pgp.helpers.update(formattedData, _.keys(formattedData), 'customers') + const enhancedUpdateData = enhanceAtFields(enhanceOverrideFields(formattedData, userToken))
const updateData = updateOverride(enhancedUpdateData)
const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') +
' where id=$1' ' where id=$1'
await db.none(sql, [id]) await db.none(sql, [id])

View file

@ -9,7 +9,7 @@ const dbm = require('./postgresql_interface')
const configManager = require('./new-config-manager') const configManager = require('./new-config-manager')
const settingsLoader = require('./new-settings-loader') const settingsLoader = require('./new-settings-loader')
module.exports = {getMachineName, getMachines, getMachineNames, setMachine} module.exports = {getMachineName, getMachines, getMachine, getMachineNames, setMachine}
function getMachines () { function getMachines () {
return db.any('select * from devices where display=TRUE order by created') return db.any('select * from devices where display=TRUE order by created')
@ -88,6 +88,11 @@ function getMachineName (machineId) {
.then(it => it.name) .then(it => it.name)
} }
function getMachine (machineId) {
const sql = 'select * from devices where device_id=$1'
return db.oneOrNone(sql, [machineId]).then(res => _.mapKeys(_.camelCase)(res))
}
function renameMachine (rec) { function renameMachine (rec) {
const sql = 'update devices set name=$1 where device_id=$2' const sql = 'update devices set name=$1 where device_id=$2'
return db.none(sql, [rec.newName, rec.deviceId]) return db.none(sql, [rec.newName, rec.deviceId])

View file

@ -49,6 +49,7 @@ const apolloServer = new ApolloServer({
const success = await login.authenticate(token) const success = await login.authenticate(token)
if (!success) throw new AuthenticationError('Authentication failed') if (!success) throw new AuthenticationError('Authentication failed')
return { req: { ...req } }
} }
}) })
@ -68,10 +69,12 @@ app.use('/front-camera-photo', serveStatic(frontCameraBasedir, { index: false })
app.get('/api/register', (req, res, next) => { app.get('/api/register', (req, res, next) => {
const otp = req.query.otp const otp = req.query.otp
const ua = req.headers['user-agent']
const ip = req.ip
if (!otp) return next() if (!otp) return next()
return login.register(otp) return login.register(otp, ua, ip)
.then(r => { .then(r => {
if (r.expired) return res.status(401).send('OTP expired, generate new registration link') if (r.expired) return res.status(401).send('OTP expired, generate new registration link')

View file

@ -3,12 +3,16 @@ const { parseAsync } = require('json2csv')
const { GraphQLDateTime } = require('graphql-iso-date') const { GraphQLDateTime } = require('graphql-iso-date')
const { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json') const { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json')
const got = require('got') const got = require('got')
const DataLoader = require('dataloader')
const machineLoader = require('../../machine-loader') const machineLoader = require('../../machine-loader')
const customers = require('../../customers') const customers = require('../../customers')
const { machineAction } = require('../machines') const { machineAction } = require('../machines')
const logs = require('../../logs') const logs = require('../../logs')
const settingsLoader = require('../../new-settings-loader') const settingsLoader = require('../../new-settings-loader')
const tokenManager = require('../../token-manager')
const blacklist = require('../../blacklist')
const machineEventsByIdBatch = require("../../postgresql_interface").machineEventsByIdBatch
const serverVersion = require('../../../package.json').version const serverVersion = require('../../../package.json').version
@ -17,7 +21,13 @@ const funding = require('../funding')
const supervisor = require('../supervisor') const supervisor = require('../supervisor')
const serverLogs = require('../server-logs') const serverLogs = require('../server-logs')
const pairing = require('../pairing') const pairing = require('../pairing')
const { accounts: accountsConfig, coins, countries, currencies, languages } = require('../config') const {
accounts: accountsConfig,
coins,
countries,
currencies,
languages
} = require('../config')
const typeDefs = gql` const typeDefs = gql`
scalar JSON scalar JSON
@ -61,6 +71,7 @@ const typeDefs = gql`
cassette1: Int cassette1: Int
cassette2: Int cassette2: Int
statuses: [MachineStatus] statuses: [MachineStatus]
latestEvent: MachineEvent
} }
type Customer { type Customer {
@ -155,6 +166,14 @@ const typeDefs = gql`
uptime: Int! uptime: Int!
} }
type UserToken {
token: String!
name: String!
created: Date!
user_agent: String
ip_address: String
}
type Transaction { type Transaction {
id: ID! id: ID!
txClass: String! txClass: String!
@ -197,6 +216,22 @@ const typeDefs = gql`
machineName: String machineName: String
} }
type Blacklist {
createdByOperator: Boolean!
cryptoCode: String!
address: String!
}
type MachineEvent {
id: ID
deviceId: String
eventType: String
note: String
created: Date
age: Float
deviceTime: Date
}
type Query { type Query {
countries: [Country] countries: [Country]
currencies: [Currency] currencies: [Currency]
@ -204,6 +239,7 @@ const typeDefs = gql`
accountsConfig: [AccountConfig] accountsConfig: [AccountConfig]
cryptoCurrencies: [CryptoCurrency] cryptoCurrencies: [CryptoCurrency]
machines: [Machine] machines: [Machine]
machine(deviceId: ID!): Machine
customers: [Customer] customers: [Customer]
customer(customerId: ID!): Customer customer(customerId: ID!): Customer
machineLogs(deviceId: ID!, from: Date, until: Date, limit: Int, offset: Int): [MachineLog] machineLogs(deviceId: ID!, from: Date, until: Date, limit: Int, offset: Int): [MachineLog]
@ -217,6 +253,8 @@ const typeDefs = gql`
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int): String transactionsCsv(from: Date, until: Date, limit: Int, offset: Int): String
accounts: JSONObject accounts: JSONObject
config: JSONObject config: JSONObject
blacklist: [Blacklist]
userTokens: [UserToken]
} }
enum MachineAction { enum MachineAction {
@ -235,9 +273,17 @@ const typeDefs = gql`
saveConfig(config: JSONObject): JSONObject saveConfig(config: JSONObject): JSONObject
createPairingTotem(name: String!): String createPairingTotem(name: String!): String
saveAccounts(accounts: JSONObject): JSONObject saveAccounts(accounts: JSONObject): JSONObject
revokeToken(token: String!): UserToken
deleteBlacklistRow(cryptoCode: String!, address: String!): Blacklist
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
} }
` `
const transactionsLoader = new DataLoader(ids => transactions.getCustomerTransactionsBatch(ids))
const machineEventsLoader = new DataLoader(ids => {
return machineEventsByIdBatch(ids)
}, { cache: false })
const notify = () => got.post('http://localhost:3030/dbChange') const notify = () => got.post('http://localhost:3030/dbChange')
.catch(e => console.error('Error: lamassu-server not responding')) .catch(e => console.error('Error: lamassu-server not responding'))
@ -246,7 +292,10 @@ const resolvers = {
JSONObject: GraphQLJSONObject, JSONObject: GraphQLJSONObject,
Date: GraphQLDateTime, Date: GraphQLDateTime,
Customer: { Customer: {
transactions: parent => transactions.getCustomerTransactions(parent.id) transactions: parent => transactionsLoader.load(parent.id)
},
Machine: {
latestEvent: parent => machineEventsLoader.load(parent.deviceId)
}, },
Query: { Query: {
countries: () => countries, countries: () => countries,
@ -255,6 +304,7 @@ const resolvers = {
accountsConfig: () => accountsConfig, accountsConfig: () => accountsConfig,
cryptoCurrencies: () => coins, cryptoCurrencies: () => coins,
machines: () => machineLoader.getMachineNames(), machines: () => machineLoader.getMachineNames(),
machine: (...[, { deviceId }]) => machineLoader.getMachine(deviceId),
customers: () => customers.getCustomersList(), customers: () => customers.getCustomersList(),
customer: (...[, { customerId }]) => customers.getCustomerById(customerId), customer: (...[, { customerId }]) => customers.getCustomerById(customerId),
funding: () => funding.getFunding(), funding: () => funding.getFunding(),
@ -273,18 +323,28 @@ const resolvers = {
transactionsCsv: (...[, { from, until, limit, offset }]) => transactionsCsv: (...[, { from, until, limit, offset }]) =>
transactions.batch(from, until, limit, offset).then(parseAsync), transactions.batch(from, until, limit, offset).then(parseAsync),
config: () => settingsLoader.loadLatestConfigOrNone(), config: () => settingsLoader.loadLatestConfigOrNone(),
accounts: () => settingsLoader.loadAccounts() accounts: () => settingsLoader.loadAccounts(),
blacklist: () => blacklist.getBlacklist(),
userTokens: () => tokenManager.getTokenList()
}, },
Mutation: { Mutation: {
machineAction: (...[, { deviceId, action, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cassette1, cassette2, newName }), machineAction: (...[, { deviceId, action, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cassette1, cassette2, newName }),
createPairingTotem: (...[, { name }]) => pairing.totem(name), createPairingTotem: (...[, { name }]) => pairing.totem(name),
saveAccounts: (...[, { accounts }]) => settingsLoader.saveAccounts(accounts), saveAccounts: (...[, { accounts }]) => settingsLoader.saveAccounts(accounts),
setCustomer: (...[, { customerId, customerInput } ]) => customers.updateCustomer(customerId, customerInput), setCustomer: (root, args, context, info) => {
const token = context.req.cookies && context.req.cookies.token
return customers.updateCustomer(args.customerId, args.customerInput, token)
},
saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config) saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config)
.then(it => { .then(it => {
notify() notify()
return it return it
}) }),
deleteBlacklistRow: (...[, { cryptoCode, address }]) =>
blacklist.deleteFromBlacklist(cryptoCode, address),
insertBlacklistRow: (...[, { cryptoCode, address }]) =>
blacklist.insertIntoBlacklist(cryptoCode, address),
revokeToken: (...[, { token }]) => tokenManager.revokeToken(token)
} }
} }

View file

@ -21,15 +21,15 @@ function validateOTP (otp) {
.catch(() => ({ success: false, expired: false })) .catch(() => ({ success: false, expired: false }))
} }
function register (otp) { function register (otp, ua, ip) {
return validateOTP(otp) return validateOTP(otp)
.then(r => { .then(r => {
if (!r.success) return r if (!r.success) return r
const token = crypto.randomBytes(32).toString('hex') const token = crypto.randomBytes(32).toString('hex')
const sql = 'insert into user_tokens (token, name) values ($1, $2)' const sql = 'insert into user_tokens (token, name, user_agent, ip_address) values ($1, $2, $3, $4)'
return db.none(sql, [token, r.name]) return db.none(sql, [token, r.name, ua, ip])
.then(() => ({ success: true, token: token })) .then(() => ({ success: true, token: token }))
}) })
.catch(() => ({ success: false, expired: false })) .catch(() => ({ success: false, expired: false }))

View file

@ -1,4 +1,5 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const db = require('../db') const db = require('../db')
const machineLoader = require('../machine-loader') const machineLoader = require('../machine-loader')
@ -65,9 +66,8 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
.then(packager) .then(packager)
} }
function getCustomerTransactions (customerId) { function getCustomerTransactionsBatch (ids) {
const packager = _.flow(it => { const packager = _.flow(it => {
console.log()
return it return it
}, _.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames) }, _.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
@ -82,7 +82,7 @@ function getCustomerTransactions (customerId) {
((not txs.send_confirmed) and (txs.created <= now() - interval $2)) as expired ((not txs.send_confirmed) and (txs.created <= now() - interval $2)) as expired
from cash_in_txs as txs from cash_in_txs as txs
left outer join customers c on txs.customer_id = c.id left outer join customers c on txs.customer_id = c.id
where c.id = $1 where c.id IN ($1^)
order by created desc limit $3` order by created desc limit $3`
const cashOutSql = `select 'cashOut' as tx_class, const cashOutSql = `select 'cashOut' as tx_class,
@ -100,14 +100,16 @@ function getCustomerTransactions (customerId) {
inner join cash_out_actions actions on txs.id = actions.tx_id inner join cash_out_actions actions on txs.id = actions.tx_id
and actions.action = 'provisionAddress' and actions.action = 'provisionAddress'
left outer join customers c on txs.customer_id = c.id left outer join customers c on txs.customer_id = c.id
where c.id = $1 where c.id IN ($1^)
order by created desc limit $2` order by created desc limit $2`
return Promise.all([ return Promise.all([
db.any(cashInSql, [customerId, cashInTx.PENDING_INTERVAL, NUM_RESULTS]), db.any(cashInSql, [_.map(pgp.as.text, ids).join(','), cashInTx.PENDING_INTERVAL, NUM_RESULTS]),
db.any(cashOutSql, [customerId, NUM_RESULTS, REDEEMABLE_AGE]) db.any(cashOutSql, [_.map(pgp.as.text, ids).join(','), NUM_RESULTS, REDEEMABLE_AGE])
]) ])
.then(packager) .then(packager).then(transactions => {
const transactionMap = _.groupBy('customerId', transactions)
return ids.map(id => transactionMap[id])
})
} }
function single (txId) { function single (txId) {
@ -156,4 +158,4 @@ function cancel (txId) {
.then(() => single(txId)) .then(() => single(txId))
} }
module.exports = { batch, getCustomerTransactions, single, cancel } module.exports = { batch, single, cancel, getCustomerTransactionsBatch }

View file

@ -637,7 +637,7 @@ function plugins (settings, deviceId) {
const notifications = configManager.getNotifications(null, device.deviceId, settings.config) const notifications = configManager.getNotifications(null, device.deviceId, settings.config)
const machineName = device.machineName const machineName = device.name
const cashInAlert = device.cashbox > notifications.cashInAlertThreshold const cashInAlert = device.cashbox > notifications.cashInAlertThreshold
? { ? {

View file

@ -1,4 +1,6 @@
const _ = require('lodash/fp')
const db = require('./db') const db = require('./db')
const pgp = require('pg-promise')()
function getInsertQuery (tableName, fields) { function getInsertQuery (tableName, fields) {
// outputs string like: '$1, $2, $3...' with proper No of items // outputs string like: '$1, $2, $3...' with proper No of items
@ -48,6 +50,16 @@ exports.machineEvent = function machineEvent (rec) {
.then(() => db.none(deleteSql)) .then(() => db.none(deleteSql))
} }
exports.machineEventsByIdBatch = function machineEventsByIdBatch (machineIds) {
const formattedIds = _.map(pgp.as.text, machineIds).join(',')
const sql = `SELECT *, (EXTRACT(EPOCH FROM (now() - created))) * 1000 AS age FROM machine_events WHERE device_id IN ($1^) ORDER BY age ASC LIMIT 1`
return db.any(sql, [formattedIds]).then(res => {
const events = _.map(_.mapKeys(_.camelCase))(res)
const eventMap = _.groupBy('deviceId', events)
return machineIds.map(id => _.prop([0], eventMap[id]))
})
}
exports.machineEvents = function machineEvents () { exports.machineEvents = function machineEvents () {
const sql = 'SELECT *, (EXTRACT(EPOCH FROM (now() - created))) * 1000 AS age FROM machine_events' const sql = 'SELECT *, (EXTRACT(EPOCH FROM (now() - created))) * 1000 AS age FROM machine_events'

13
lib/token-manager.js Normal file
View file

@ -0,0 +1,13 @@
const db = require('./db')
function getTokenList () {
const sql = `select * from user_tokens`
return db.any(sql)
}
function revokeToken (token) {
const sql = `delete from user_tokens where token = $1`
return db.none(sql, [token])
}
module.exports = { getTokenList, revokeToken }

View file

@ -0,0 +1,14 @@
const db = require('./db')
exports.up = function (next) {
var sql = [
'ALTER TABLE user_tokens ADD COLUMN user_agent text',
'ALTER TABLE user_tokens ADD COLUMN ip_address inet',
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -1,4 +1,5 @@
import CssBaseline from '@material-ui/core/CssBaseline' import CssBaseline from '@material-ui/core/CssBaseline'
import Grid from '@material-ui/core/Grid'
import { import {
StylesProvider, StylesProvider,
jssPreset, jssPreset,
@ -8,12 +9,18 @@ import {
import { create } from 'jss' import { create } from 'jss'
import extendJss from 'jss-plugin-extend' import extendJss from 'jss-plugin-extend'
import React, { createContext, useContext, useState } from 'react' import React, { createContext, useContext, useState } from 'react'
import { useLocation, BrowserRouter as Router } from 'react-router-dom' import {
useLocation,
useHistory,
BrowserRouter as Router
} from 'react-router-dom'
import Sidebar from 'src/components/layout/Sidebar'
import TitleSection from 'src/components/layout/TitleSection'
import ApolloProvider from 'src/utils/apollo' import ApolloProvider from 'src/utils/apollo'
import Header from './components/layout/Header' import Header from './components/layout/Header'
import { tree, Routes } from './routing/routes' import { tree, hasSidebar, Routes, getParent } from './routing/routes'
import global from './styling/global' import global from './styling/global'
import theme from './styling/theme' import theme from './styling/theme'
import { backgroundColor, mainWidth } from './styling/variables' import { backgroundColor, mainWidth } from './styling/variables'
@ -46,6 +53,18 @@ const useStyles = makeStyles({
flex: 1, flex: 1,
display: 'flex', display: 'flex',
flexDirection flexDirection
},
grid: {
flex: 1,
height: '100%'
},
contentWithSidebar: {
flex: 1,
marginLeft: 48,
paddingTop: 15
},
contentWithoutSidebar: {
width: mainWidth
} }
}) })
@ -54,15 +73,45 @@ const AppContext = createContext()
const Main = () => { const Main = () => {
const classes = useStyles() const classes = useStyles()
const location = useLocation() const location = useLocation()
const history = useHistory()
const { wizardTested } = useContext(AppContext) const { wizardTested } = useContext(AppContext)
const route = location.pathname
const sidebar = hasSidebar(route)
const parent = sidebar ? getParent(route) : {}
const is404 = location.pathname === '/404' const is404 = location.pathname === '/404'
const isSelected = it => location.pathname === it.route
const onClick = it => history.push(it.route)
const contentClassName = sidebar
? classes.contentWithSidebar
: classes.contentWithoutSidebar
return ( return (
<div className={classes.root}> <div className={classes.root}>
{!is404 && wizardTested && <Header tree={tree} />} {!is404 && wizardTested && <Header tree={tree} />}
<main className={classes.wrapper}> <main className={classes.wrapper}>
<Routes /> {sidebar && !is404 && wizardTested && (
<TitleSection title={parent.title}></TitleSection>
)}
<Grid container className={classes.grid}>
{sidebar && !is404 && wizardTested && (
<Sidebar
data={parent.children}
isSelected={isSelected}
displayName={it => it.label}
onClick={onClick}
/>
)}
<div className={contentClassName}>
<Routes />
</div>
</Grid>
</main> </main>
</div> </div>
) )

View file

@ -65,6 +65,7 @@ export const ConfirmDialog = memo(
onConfirmed, onConfirmed,
onDissmised, onDissmised,
initialValue = '', initialValue = '',
disabled = false,
...props ...props
}) => { }) => {
const classes = useStyles() const classes = useStyles()
@ -101,6 +102,7 @@ export const ConfirmDialog = memo(
<DialogContent className={classes.dialogContent}> <DialogContent className={classes.dialogContent}>
{message && <P>{message}</P>} {message && <P>{message}</P>}
<TextInput <TextInput
disabled={disabled}
label={confirmationMessage} label={confirmationMessage}
name="confirm-input" name="confirm-input"
autoFocus autoFocus

View file

@ -3,9 +3,11 @@ import classnames from 'classnames'
import React, { memo, useState } from 'react' import React, { memo, useState } from 'react'
import { NavLink, useHistory } from 'react-router-dom' import { NavLink, useHistory } from 'react-router-dom'
import { Link } from 'src/components/buttons' import ActionButton from 'src/components/buttons/ActionButton'
import { H4 } from 'src/components/typography' import { H4 } from 'src/components/typography'
import AddMachine from 'src/pages/AddMachine' import AddMachine from 'src/pages/AddMachine'
import { ReactComponent as AddIconReverse } from 'src/styling/icons/button/add/white.svg'
import { ReactComponent as AddIcon } from 'src/styling/icons/button/add/zodiac.svg'
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg' import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg'
import styles from './Header.styles' import styles from './Header.styles'
@ -76,9 +78,13 @@ const Header = memo(({ tree }) => {
</NavLink> </NavLink>
))} ))}
</ul> </ul>
<Link color="action" onClick={() => setOpen(true)}> <ActionButton
Add Machine color="secondary"
</Link> Icon={AddIcon}
InverseIcon={AddIconReverse}
onClick={() => setOpen(true)}>
Add machine
</ActionButton>
</nav> </nav>
</div> </div>
</div> </div>

View file

@ -0,0 +1,181 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { Box } from '@material-ui/core'
import Grid from '@material-ui/core/Grid'
import { makeStyles } from '@material-ui/core/styles'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import Tooltip from 'src/components/Tooltip'
import { Link } from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import Sidebar from 'src/components/layout/Sidebar'
import TitleSection from 'src/components/layout/TitleSection'
import { H4, Label2, P } from 'src/components/typography'
import { fromNamespace, toNamespace } from 'src/utils/config'
import styles from './Blacklist.styles'
import BlackListModal from './BlacklistModal'
import BlacklistTable from './BlacklistTable'
const useStyles = makeStyles(styles)
const groupByCode = R.groupBy(obj => obj.cryptoCode)
const DELETE_ROW = gql`
mutation DeleteBlacklistRow($cryptoCode: String!, $address: String!) {
deleteBlacklistRow(cryptoCode: $cryptoCode, address: $address) {
cryptoCode
address
}
}
`
const GET_BLACKLIST = gql`
query getBlacklistData {
blacklist {
cryptoCode
address
}
cryptoCurrencies {
display
code
}
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const GET_INFO = gql`
query getData {
config
}
`
const ADD_ROW = gql`
mutation InsertBlacklistRow($cryptoCode: String!, $address: String!) {
insertBlacklistRow(cryptoCode: $cryptoCode, address: $address) {
cryptoCode
address
}
}
`
const Blacklist = () => {
const { data: blacklistResponse } = useQuery(GET_BLACKLIST)
const { data: configData } = useQuery(GET_INFO)
const [showModal, setShowModal] = useState(false)
const [clickedItem, setClickedItem] = useState({
code: 'BTC',
display: 'Bitcoin'
})
const [deleteEntry] = useMutation(DELETE_ROW, {
onError: () => console.error('Error while deleting row'),
refetchQueries: () => ['getBlacklistData']
})
const [addEntry] = useMutation(ADD_ROW, {
onError: () => console.error('Error while adding row'),
onCompleted: () => setShowModal(false),
refetchQueries: () => ['getBlacklistData']
})
const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData']
})
const classes = useStyles()
const blacklistData = R.path(['blacklist'])(blacklistResponse) ?? []
const availableCurrencies =
R.path(['cryptoCurrencies'], blacklistResponse) ?? []
const formattedData = groupByCode(blacklistData)
const complianceConfig =
configData?.config && fromNamespace('compliance')(configData.config)
const rejectAddressReuse = complianceConfig?.rejectAddressReuse ?? false
const addressReuseSave = rawConfig => {
const config = toNamespace('compliance')(rawConfig)
return saveConfig({ variables: { config } })
}
const onClickSidebarItem = e => {
setClickedItem({ code: e.code, display: e.display })
}
const handleDeleteEntry = (cryptoCode, address) => {
deleteEntry({ variables: { cryptoCode, address } })
}
const addToBlacklist = (cryptoCode, address) => {
addEntry({ variables: { cryptoCode, address } })
}
return (
<>
<TitleSection title="Blacklisted addresses">
<Link onClick={() => setShowModal(false)}>Blacklist new addresses</Link>
</TitleSection>
<Grid container className={classes.grid}>
<Sidebar
data={availableCurrencies}
isSelected={R.propEq('code', clickedItem.code)}
displayName={it => it.display}
onClick={onClickSidebarItem}
/>
<div className={classes.content}>
<Box display="flex" justifyContent="space-between" mb={3}>
<H4 noMargin className={classes.subtitle}>
{clickedItem.display
? `${clickedItem.display} blacklisted addresses`
: ''}{' '}
</H4>
<Box
display="flex"
alignItems="center"
justifyContent="end"
mr="-5px">
<P>Reject reused addresses</P>
<Switch
checked={rejectAddressReuse}
onChange={event => {
addressReuseSave({ rejectAddressReuse: event.target.checked })
}}
value={rejectAddressReuse}
/>
<Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2>
<Tooltip width={304}>
<P>
The "Reject reused addresses" option means that all addresses
that are used once will be automatically rejected if there's
an attempt to use them again on a new transaction.
</P>
</Tooltip>
</Box>
</Box>
<BlacklistTable
data={formattedData}
selectedCoin={clickedItem}
handleDeleteEntry={handleDeleteEntry}
/>
</div>
</Grid>
{showModal && (
<BlackListModal
onClose={() => setShowModal(false)}
selectedCoin={clickedItem}
addToBlacklist={addToBlacklist}
/>
)}
</>
)
}
export default Blacklist

View file

@ -0,0 +1,39 @@
import { spacer, fontPrimary, primaryColor, white } from 'src/styling/variables'
export default {
grid: {
flex: 1,
height: '100%'
},
content: {
display: 'flex',
flexDirection: 'column',
flex: 1,
marginLeft: spacer * 6
},
footer: {
margin: [['auto', 0, spacer * 3, 'auto']]
},
modalTitle: {
lineHeight: '120%',
color: primaryColor,
fontSize: 14,
fontFamily: fontPrimary,
fontWeight: 900
},
subtitle: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row'
},
white: {
color: white
},
deleteButton: {
paddingLeft: 13
},
addressRow: {
marginLeft: 8
}
}

View file

@ -0,0 +1,76 @@
import { makeStyles } from '@material-ui/core/styles'
import { Formik, Form, Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import * as Yup from 'yup'
import Modal from 'src/components/Modal'
import { Link } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { H3 } from 'src/components/typography'
import styles from './Blacklist.styles'
const useStyles = makeStyles(styles)
const BlackListModal = ({ onClose, selectedCoin, addToBlacklist }) => {
const classes = useStyles()
const handleAddToBlacklist = address => {
addToBlacklist(selectedCoin.code, address)
}
const placeholderAddress = {
BTC: '1ADwinnimZKGgQ3dpyfoUZvJh4p1UWSSpD',
ETH: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
LTC: 'LPKvbjwV1Kaksktzkr7TMK3FQtQEEe6Wqa',
DASH: 'XqQ7gU8eM76rEfey726cJpT2RGKyJyBrcn',
ZEC: 't1KGyyv24eL354C9gjveBGEe8Xz9UoPKvHR',
BCH: 'qrd6za97wm03lfyg82w0c9vqgc727rhemg5yd9k3dm'
}
return (
<Modal
closeOnBackdropClick={true}
width={676}
height={200}
handleClose={onClose}
open={true}>
<Formik
initialValues={{
address: ''
}}
validationSchema={Yup.object({
address: Yup.string()
.trim()
.required('An address is required')
})}
onSubmit={({ address }, { resetForm }) => {
handleAddToBlacklist(address)
resetForm()
}}>
<Form id="address-form">
<H3>
{selectedCoin.display
? `Blacklist ${R.toLower(selectedCoin.display)} address`
: ''}
</H3>
<Field
name="address"
fullWidth
autoComplete="off"
label="Paste new address to blacklist here"
placeholder={`ex: ${placeholderAddress[selectedCoin.code]}`}
component={TextInput}
/>
</Form>
</Formik>
<div className={classes.footer}>
<Link type="submit" form="address-form">
Blacklist address
</Link>
</div>
</Modal>
)
}
export default BlackListModal

View file

@ -0,0 +1,60 @@
import { makeStyles } from '@material-ui/core/styles'
import * as R from 'ramda'
import React from 'react'
import { IconButton } from 'src/components/buttons'
import DataTable from 'src/components/tables/DataTable'
import { Label1 } from 'src/components/typography'
import CopyToClipboard from 'src/pages/Transactions/CopyToClipboard'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import styles from './Blacklist.styles'
const useStyles = makeStyles(styles)
const BlacklistTable = ({ data, selectedCoin, handleDeleteEntry }) => {
const classes = useStyles()
const elements = [
{
name: 'address',
header: <Label1 className={classes.white}>{'Addresses'}</Label1>,
width: 800,
textAlign: 'left',
size: 'sm',
view: it => (
<div className={classes.addressRow}>
<CopyToClipboard>{R.path(['address'], it)}</CopyToClipboard>
</div>
)
},
{
name: 'deleteButton',
header: <Label1 className={classes.white}>{'Delete'}</Label1>,
width: 130,
textAlign: 'center',
size: 'sm',
view: it => (
<IconButton
className={classes.deleteButton}
onClick={() =>
handleDeleteEntry(
R.path(['cryptoCode'], it),
R.path(['address'], it)
)
}>
<DeleteIcon />
</IconButton>
)
}
]
const dataToShow = selectedCoin
? data[selectedCoin.code]
: data[R.keys(data)[0]]
return (
<DataTable data={dataToShow} elements={elements} name="blacklistTable" />
)
}
export default BlacklistTable

View file

@ -0,0 +1,3 @@
import Blacklist from './Blacklist'
export default Blacklist

View file

@ -89,7 +89,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
<Tooltip width={304}> <Tooltip width={304}>
<P> <P>
Automatically accept customer deposits as complete if their Automatically accept customer deposits as complete if their
received amount is 10 crypto atoms or less. received amount is 100 crypto atoms or less.
</P> </P>
<P> <P>
(Crypto atoms are the smallest unit in each cryptocurrency. E.g., (Crypto atoms are the smallest unit in each cryptocurrency. E.g.,

View file

@ -1,4 +1,4 @@
import { useMutation } from '@apollo/react-hooks' import { useMutation, useLazyQuery } from '@apollo/react-hooks'
import { Grid, Divider } from '@material-ui/core' import { Grid, Divider } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import gql from 'graphql-tag' import gql from 'graphql-tag'
@ -32,6 +32,16 @@ const MACHINE_ACTION = gql`
} }
` `
const MACHINE = gql`
query getMachine($deviceId: ID!) {
machine(deviceId: $deviceId) {
latestEvent {
note
}
}
}
`
const supportArtices = [ const supportArtices = [
{ {
// Default article for non-maped statuses // Default article for non-maped statuses
@ -43,6 +53,24 @@ const supportArtices = [
// TODO add Stuck and Fully Functional statuses articles for the new-admins // TODO add Stuck and Fully Functional statuses articles for the new-admins
] ]
const isStaticState = machineState => {
if (!machineState) {
return true
}
const staticStates = [
'chooseCoin',
'idle',
'pendingIdle',
'dualIdle',
'networkDown',
'unpaired',
'maintenance',
'virgin',
'wifiList'
]
return staticStates.includes(machineState)
}
const article = ({ code: status }) => const article = ({ code: status }) =>
supportArtices.find(({ code: article }) => article === status) supportArtices.find(({ code: article }) => article === status)
@ -68,11 +96,37 @@ const Item = ({ children, ...props }) => (
</Grid> </Grid>
) )
const getState = machineEventsLazy =>
JSON.parse(machineEventsLazy.machine.latestEvent?.note ?? '{"state": null}')
.state
const MachineDetailsRow = ({ it: machine, onActionSuccess }) => { const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
const [action, setAction] = useState(null) const [action, setAction] = useState({ command: null })
const [errorMessage, setErrorMessage] = useState(null) const [errorMessage, setErrorMessage] = useState(null)
const classes = useMDStyles() const classes = useMDStyles()
const warningMessage = (
<span className={classes.warning}>
A user may be in the middle of a transaction and they could lose their
funds if you continue.
</span>
)
const [fetchMachineEvents, { loading: loadingEvents }] = useLazyQuery(
MACHINE,
{
variables: {
deviceId: machine.deviceId
},
onCompleted: machineEventsLazy => {
const message = !isStaticState(getState(machineEventsLazy))
? warningMessage
: null
setAction(action => ({ ...action, message }))
}
}
)
const [machineAction, { loading }] = useMutation(MACHINE_ACTION, { const [machineAction, { loading }] = useMutation(MACHINE_ACTION, {
onError: ({ message }) => { onError: ({ message }) => {
const errorMessage = message ?? 'An error ocurred' const errorMessage = message ?? 'An error ocurred'
@ -80,11 +134,12 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
}, },
onCompleted: () => { onCompleted: () => {
onActionSuccess && onActionSuccess() onActionSuccess && onActionSuccess()
setAction(null) setAction({ command: null })
} }
}) })
const confirmDialogOpen = Boolean(action) const confirmDialogOpen = Boolean(action.command)
const disabled = !!(action?.command === 'restartServices' && loadingEvents)
return ( return (
<> <>
@ -127,25 +182,26 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
className={classes.separator} className={classes.separator}
/> />
<ConfirmDialog <ConfirmDialog
disabled={disabled}
open={confirmDialogOpen} open={confirmDialogOpen}
title={`${action?.command} this machine?`} title={`${action?.display} this machine?`}
errorMessage={errorMessage} errorMessage={errorMessage}
toBeConfirmed={machine.name} toBeConfirmed={machine.name}
message={action?.message} message={action?.message}
confirmationMessage={action?.confirmationMessage} confirmationMessage={action?.confirmationMessage}
saveButtonAlwaysEnabled={action?.command === 'Rename'} saveButtonAlwaysEnabled={action?.command === 'rename'}
onConfirmed={value => { onConfirmed={value => {
setErrorMessage(null) setErrorMessage(null)
machineAction({ machineAction({
variables: { variables: {
deviceId: machine.deviceId, deviceId: machine.deviceId,
action: `${action?.command}`.toLowerCase(), action: `${action?.command}`,
...(action?.command === 'Rename' && { newName: value }) ...(action?.command === 'rename' && { newName: value })
} }
}) })
}} }}
onDissmised={() => { onDissmised={() => {
setAction(null) setAction({ command: null })
setErrorMessage(null) setErrorMessage(null)
}} }}
/> />
@ -174,7 +230,8 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
InverseIcon={EditReversedIcon} InverseIcon={EditReversedIcon}
onClick={() => onClick={() =>
setAction({ setAction({
command: 'Rename', command: 'rename',
display: 'Rename',
confirmationMessage: 'Write the new name for this machine' confirmationMessage: 'Write the new name for this machine'
}) })
}> }>
@ -188,7 +245,8 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
disabled={loading} disabled={loading}
onClick={() => onClick={() =>
setAction({ setAction({
command: 'Unpair' command: 'unpair',
display: 'Unpair'
}) })
}> }>
Unpair Unpair
@ -201,26 +259,43 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
disabled={loading} disabled={loading}
onClick={() => onClick={() =>
setAction({ setAction({
command: 'Reboot' command: 'reboot',
display: 'Reboot'
}) })
}> }>
Reboot Reboot
</ActionButton> </ActionButton>
<ActionButton <ActionButton
className={classes.inlineChip} className={classes.mr}
disabled={loading} disabled={loading}
color="primary" color="primary"
Icon={ShutdownIcon} Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon} InverseIcon={ShutdownReversedIcon}
onClick={() => onClick={() =>
setAction({ setAction({
command: 'Shutdown', command: 'shutdown',
display: 'Shutdown',
message: message:
'In order to bring it back online, the machine will need to be visited and its power reset.' 'In order to bring it back online, the machine will need to be visited and its power reset.'
}) })
}> }>
Shutdown Shutdown
</ActionButton> </ActionButton>
<ActionButton
color="primary"
className={classes.inlineChip}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
fetchMachineEvents()
setAction({
command: 'restartServices',
display: 'Restart services for'
})
}}>
Restart Services
</ActionButton>
</div> </div>
</Item> </Item>
</Container> </Container>

View file

@ -4,7 +4,13 @@ import {
detailsRowStyles, detailsRowStyles,
labelStyles labelStyles
} from 'src/pages/Transactions/Transactions.styles' } from 'src/pages/Transactions/Transactions.styles'
import { spacer, comet, primaryColor, fontSize4 } from 'src/styling/variables' import {
spacer,
comet,
primaryColor,
fontSize4,
errorColor
} from 'src/styling/variables'
const machineDetailsStyles = { const machineDetailsStyles = {
...detailsRowStyles, ...detailsRowStyles,
@ -58,6 +64,9 @@ const machineDetailsStyles = {
marginRight: 60, marginRight: 60,
marginLeft: 'auto', marginLeft: 'auto',
background: fade(comet, 0.5) background: fade(comet, 0.5)
},
warning: {
color: errorColor
} }
} }

View file

@ -9,8 +9,8 @@ import Autocomplete from 'src/components/inputs/formik/Autocomplete'
import NotificationsCtx from '../NotificationsContext' import NotificationsCtx from '../NotificationsContext'
import { transformNumber } from '../helper' import { transformNumber } from '../helper'
const CASSETTE_1_KEY = 'cassette1' const CASSETTE_1_KEY = 'fiatBalanceCassette1'
const CASSETTE_2_KEY = 'cassette2' const CASSETTE_2_KEY = 'fiatBalanceCassette2'
const MACHINE_KEY = 'machine' const MACHINE_KEY = 'machine'
const NAME = 'fiatBalanceOverrides' const NAME = 'fiatBalanceOverrides'

View file

@ -1,100 +0,0 @@
import { makeStyles } from '@material-ui/core'
import Grid from '@material-ui/core/Grid'
import React from 'react'
import {
Route,
Switch,
Redirect,
useLocation,
useHistory
} from 'react-router-dom'
import Sidebar from 'src/components/layout/Sidebar'
import TitleSection from 'src/components/layout/TitleSection'
import CoinAtmRadar from './CoinATMRadar'
import ContactInfo from './ContactInfo'
import ReceiptPrinting from './ReceiptPrinting'
import TermsConditions from './TermsConditions'
const styles = {
grid: {
flex: 1,
height: '100%'
},
content: {
flex: 1,
marginLeft: 48,
paddingTop: 15
}
}
const useStyles = makeStyles(styles)
const innerRoutes = [
{
label: 'Contact information',
route: '/settings/operator-info/contact-info',
component: ContactInfo
},
{
label: 'Receipt',
route: '/settings/operator-info/receipt-printing',
component: ReceiptPrinting
},
{
label: 'Coin ATM Radar',
route: '/settings/operator-info/coin-atm-radar',
component: CoinAtmRadar
},
{
label: 'Terms & Conditions',
route: '/settings/operator-info/terms-conditions',
component: TermsConditions
}
]
const Routes = ({ wizard }) => (
<Switch>
<Redirect
exact
from="/settings/operator-info"
to="/settings/operator-info/contact-info"
/>
<Route exact path="/" />
{innerRoutes.map(({ route, component: Page, key }) => (
<Route path={route} key={key}>
<Page name={key} wizard={wizard} />
</Route>
))}
</Switch>
)
const OperatorInfo = ({ wizard = false }) => {
const classes = useStyles()
const history = useHistory()
const location = useLocation()
const isSelected = it => location.pathname === it.route
const onClick = it => history.push(it.route)
return (
<>
<TitleSection title="Operator information"></TitleSection>
<Grid container className={classes.grid}>
<Sidebar
data={innerRoutes}
isSelected={isSelected}
displayName={it => it.label}
onClick={onClick}
/>
<div className={classes.content}>
<Routes wizard={wizard} />
</div>
</Grid>
</>
)
}
export default OperatorInfo

View file

@ -0,0 +1,95 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import gql from 'graphql-tag'
import moment from 'moment'
import * as R from 'ramda'
import React from 'react'
import { IconButton } from 'src/components/buttons'
import TitleSection from 'src/components/layout/TitleSection'
import DataTable from 'src/components/tables/DataTable'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
const GET_USER_TOKENS = gql`
query userTokens {
userTokens {
token
name
created
user_agent
ip_address
}
}
`
const REVOKE_USER_TOKEN = gql`
mutation revokeToken($token: String!) {
revokeToken(token: $token) {
token
}
}
`
const Tokens = () => {
const { data: tknResponse } = useQuery(GET_USER_TOKENS)
const [revokeToken] = useMutation(REVOKE_USER_TOKEN, {
refetchQueries: () => ['userTokens']
})
const elements = [
{
header: 'Name',
width: 257,
textAlign: 'center',
size: 'sm',
view: t => t.name
},
{
header: 'Token',
width: 505,
textAlign: 'center',
size: 'sm',
view: t => t.token
},
{
header: 'Date (UTC)',
width: 145,
textAlign: 'right',
size: 'sm',
view: t => moment.utc(t.created).format('YYYY-MM-DD')
},
{
header: 'Time (UTC)',
width: 145,
textAlign: 'right',
size: 'sm',
view: t => moment.utc(t.created).format('HH:mm:ss')
},
{
header: '',
width: 80,
textAlign: 'center',
size: 'sm',
view: t => (
<IconButton
onClick={() => {
revokeToken({ variables: { token: t.token } })
}}>
<DeleteIcon />
</IconButton>
)
}
]
return (
<>
<TitleSection title="Token Management" />
<DataTable
elements={elements}
data={R.path(['userTokens'])(tknResponse)}
/>
</>
)
}
export default Tokens

View file

@ -76,7 +76,10 @@ const threshold = Yup.object().shape({
}) })
const requirement = Yup.object().shape({ const requirement = Yup.object().shape({
requirement: Yup.string().required(), requirement: Yup.string().required(),
suspensionDays: Yup.number() suspensionDays: Yup.number().when('requirement', {
is: 'suspend',
then: Yup.number().required()
})
}) })
const Schema = Yup.object().shape({ const Schema = Yup.object().shape({

View file

@ -10,6 +10,7 @@ import {
import { AppContext } from 'src/App' import { AppContext } from 'src/App'
import AuthRegister from 'src/pages/AuthRegister' import AuthRegister from 'src/pages/AuthRegister'
import Blacklist from 'src/pages/Blacklist'
import Cashout from 'src/pages/Cashout' import Cashout from 'src/pages/Cashout'
import Commissions from 'src/pages/Commissions' import Commissions from 'src/pages/Commissions'
import { Customers, CustomerProfile } from 'src/pages/Customers' import { Customers, CustomerProfile } from 'src/pages/Customers'
@ -19,9 +20,13 @@ import MachineLogs from 'src/pages/MachineLogs'
import CashCassettes from 'src/pages/Maintenance/CashCassettes' import CashCassettes from 'src/pages/Maintenance/CashCassettes'
import MachineStatus from 'src/pages/Maintenance/MachineStatus' import MachineStatus from 'src/pages/Maintenance/MachineStatus'
import Notifications from 'src/pages/Notifications/Notifications' import Notifications from 'src/pages/Notifications/Notifications'
import OperatorInfo from 'src/pages/OperatorInfo/OperatorInfo' import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar'
import ContactInfo from 'src/pages/OperatorInfo/ContactInfo'
import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting'
import TermsConditions from 'src/pages/OperatorInfo/TermsConditions'
import ServerLogs from 'src/pages/ServerLogs' import ServerLogs from 'src/pages/ServerLogs'
import Services from 'src/pages/Services/Services' import Services from 'src/pages/Services/Services'
import TokenManagement from 'src/pages/TokenManagement/TokenManagement'
import Transactions from 'src/pages/Transactions/Transactions' import Transactions from 'src/pages/Transactions/Transactions'
import Triggers from 'src/pages/Triggers' import Triggers from 'src/pages/Triggers'
import WalletSettings from 'src/pages/Wallet/Wallet' import WalletSettings from 'src/pages/Wallet/Wallet'
@ -123,7 +128,36 @@ const tree = [
key: namespaces.OPERATOR_INFO, key: namespaces.OPERATOR_INFO,
label: 'Operator Info', label: 'Operator Info',
route: '/settings/operator-info', route: '/settings/operator-info',
component: OperatorInfo title: 'Operator Information',
get component() {
return () => <Redirect to={this.children[0].route} />
},
children: [
{
key: 'contact-info',
label: 'Contact information',
route: '/settings/operator-info/contact-info',
component: ContactInfo
},
{
key: 'receipt-printing',
label: 'Receipt',
route: '/settings/operator-info/receipt-printing',
component: ReceiptPrinting
},
{
key: 'coin-atm-radar',
label: 'Coin ATM Radar',
route: '/settings/operator-info/coin-atm-radar',
component: CoinAtmRadar
},
{
key: 'terms-conditions',
label: 'Terms & Conditions',
route: '/settings/operator-info/terms-conditions',
component: TermsConditions
}
]
} }
] ]
}, },
@ -147,20 +181,66 @@ const tree = [
route: '/compliance/customers', route: '/compliance/customers',
component: Customers component: Customers
}, },
{
key: 'blacklist',
label: 'Blacklist',
route: '/compliance/blacklist',
component: Blacklist
},
{ {
key: 'customer', key: 'customer',
route: '/compliance/customer/:id', route: '/compliance/customer/:id',
component: CustomerProfile component: CustomerProfile
} }
] ]
},
{
key: 'system',
label: 'System',
route: '/system',
get component() {
return () => <Redirect to={this.children[0].route} />
},
children: [
{
key: 'token-management',
label: 'Token Management',
route: '/system/token-management',
component: TokenManagement
}
]
} }
] ]
const map = R.map(R.when(R.has('children'), R.prop('children'))) const map = R.map(R.when(R.has('children'), R.prop('children')))
const leafRoutes = R.compose(R.flatten, map)(tree) const mappedRoutes = R.compose(R.flatten, map)(tree)
const parentRoutes = R.filter(R.has('children'))(tree) const parentRoutes = R.filter(R.has('children'))(mappedRoutes).concat(
R.filter(R.has('children'))(tree)
)
const leafRoutes = R.compose(R.flatten, map)(mappedRoutes)
const flattened = R.concat(leafRoutes, parentRoutes) const flattened = R.concat(leafRoutes, parentRoutes)
const hasSidebar = route =>
R.any(r => r.route === route)(
R.compose(
R.flatten,
R.map(R.prop('children')),
R.filter(R.has('children'))
)(mappedRoutes)
)
const getParent = route =>
R.find(
R.propEq(
'route',
R.dropLast(
1,
R.dropLastWhile(x => x !== '/', route)
)
)
)(flattened)
const Routes = () => { const Routes = () => {
const history = useHistory() const history = useHistory()
const location = useLocation() const location = useLocation()
@ -191,4 +271,4 @@ const Routes = () => {
</Switch> </Switch>
) )
} }
export { tree, Routes } export { tree, getParent, hasSidebar, Routes }

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 60.1 (88133) - https://sketch.com -->
<desc>Created with Sketch.</desc>
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="nav-/-primary-/-1440" transform="translate(-1239.000000, -19.000000)" stroke="#1B2559" stroke-width="2">
<g id="icon/menu/search" transform="translate(1240.000000, 20.000000)">
<path d="M12.3100952,6.15542857 C12.3100952,9.55504762 9.55428571,12.3108571 6.15466667,12.3108571 C2.75580952,12.3108571 -2.72670775e-13,9.55504762 -2.72670775e-13,6.15542857 C-2.72670775e-13,2.75580952 2.75580952,8.08242362e-14 6.15466667,8.08242362e-14 C9.55428571,8.08242362e-14 12.3100952,2.75580952 12.3100952,6.15542857 Z" id="Stroke-1"></path>
<line x1="10.5820952" y1="10.5829333" x2="15.2068571" y2="15.2076952" id="Stroke-3" stroke-linecap="round"></line>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

4714
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@
"console-log-level": "^1.4.0", "console-log-level": "^1.4.0",
"cookie-parser": "^1.4.3", "cookie-parser": "^1.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dataloader": "^2.0.0",
"ethereumjs-tx": "^1.3.3", "ethereumjs-tx": "^1.3.3",
"ethereumjs-util": "^5.2.0", "ethereumjs-util": "^5.2.0",
"ethereumjs-wallet": "^0.6.3", "ethereumjs-wallet": "^0.6.3",
@ -67,13 +68,15 @@
"socket.io-client": "^2.0.3", "socket.io-client": "^2.0.3",
"talisman": "^0.20.0", "talisman": "^0.20.0",
"twilio": "^3.6.1", "twilio": "^3.6.1",
"ua-parser-js": "^0.7.22",
"uuid": "^3.1.0", "uuid": "^3.1.0",
"web3": "^0.20.6", "web3": "^0.20.6",
"winston": "^2.4.2", "winston": "^2.4.2",
"winston-transport": "^4.3.0", "winston-transport": "^4.3.0",
"ws": "^3.1.0", "ws": "^3.1.0",
"xml-stream": "^0.4.5", "xml-stream": "^0.4.5",
"xmlrpc": "^1.3.2" "xmlrpc": "^1.3.2",
"yup": "^0.31.1"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -107,6 +110,7 @@
"scripts": { "scripts": {
"start": "node bin/lamassu-server", "start": "node bin/lamassu-server",
"test": "mocha --recursive tests", "test": "mocha --recursive tests",
"jtest": "jest",
"build-admin": "npm run build-admin:css && npm run build-admin:main && npm run build-admin:lamassu", "build-admin": "npm run build-admin:css && npm run build-admin:main && npm run build-admin:lamassu",
"server": "nodemon bin/lamassu-server --mockSms", "server": "nodemon bin/lamassu-server --mockSms",
"admin-server": "nodemon bin/lamassu-admin-server --dev", "admin-server": "nodemon bin/lamassu-admin-server --dev",
@ -121,6 +125,7 @@
"devDependencies": { "devDependencies": {
"ava": "3.8.2", "ava": "3.8.2",
"concurrently": "^5.3.0", "concurrently": "^5.3.0",
"jest": "^26.6.3",
"mocha": "^5.0.1", "mocha": "^5.0.1",
"nodemon": "^2.0.6", "nodemon": "^2.0.6",
"rewire": "^4.0.1", "rewire": "^4.0.1",