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-IT",
"ja-JP",
"ka-GE",
"ko-KR",
"ky-KG",
"lt-LT",

View file

@ -1,22 +1,52 @@
const db = require('./db')
function blocked (address, cryptoCode) {
const sql = `select * from blacklist where address = $1 and crypto_code = $2`
return db.any(sql, [
address,
cryptoCode
])
// Get all blacklist rows from the DB "blacklist" table
const getBlacklist = () => {
return db.any('select * from blacklist').then(res =>
res.map(item => ({
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
if (cryptoCode === 'ETH') return Promise.resolve()
const sql = `insert into blacklist(crypto_code, address, created_by_operator) values ($1, $2, 'f')`
return db.oneOrNone(sql, [
cryptoCode,
address
])
return db.oneOrNone(sql, [cryptoCode, address])
}
module.exports = { blocked, addToUsedAddresses }
module.exports = {
blocked,
addToUsedAddresses,
getBlacklist,
deleteFromBlacklist,
insertIntoBlacklist
}

View file

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

View file

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

View file

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

View file

@ -97,7 +97,7 @@ function update (id, data, userToken, txId) {
*
* @returns {Promise} Newly updated Customer
*/
async function updateCustomer (id, data) {
async function updateCustomer (id, data, userToken) {
const formattedData = _.pick(
[
'authorized_override',
@ -110,7 +110,10 @@ async function updateCustomer (id, 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'
await db.none(sql, [id])

View file

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

View file

@ -49,6 +49,7 @@ const apolloServer = new ApolloServer({
const success = await login.authenticate(token)
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) => {
const otp = req.query.otp
const ua = req.headers['user-agent']
const ip = req.ip
if (!otp) return next()
return login.register(otp)
return login.register(otp, ua, ip)
.then(r => {
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 { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json')
const got = require('got')
const DataLoader = require('dataloader')
const machineLoader = require('../../machine-loader')
const customers = require('../../customers')
const { machineAction } = require('../machines')
const logs = require('../../logs')
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
@ -17,7 +21,13 @@ const funding = require('../funding')
const supervisor = require('../supervisor')
const serverLogs = require('../server-logs')
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`
scalar JSON
@ -61,6 +71,7 @@ const typeDefs = gql`
cassette1: Int
cassette2: Int
statuses: [MachineStatus]
latestEvent: MachineEvent
}
type Customer {
@ -155,6 +166,14 @@ const typeDefs = gql`
uptime: Int!
}
type UserToken {
token: String!
name: String!
created: Date!
user_agent: String
ip_address: String
}
type Transaction {
id: ID!
txClass: String!
@ -183,7 +202,7 @@ const typeDefs = gql`
customerId: ID
txVersion: Int!
termsAccepted: Boolean
commissionPercentage: String
commissionPercentage: String
rawTickerPrice: String
isPaperWallet: Boolean
customerPhone: String
@ -197,6 +216,22 @@ const typeDefs = gql`
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 {
countries: [Country]
currencies: [Currency]
@ -204,6 +239,7 @@ const typeDefs = gql`
accountsConfig: [AccountConfig]
cryptoCurrencies: [CryptoCurrency]
machines: [Machine]
machine(deviceId: ID!): Machine
customers: [Customer]
customer(customerId: ID!): Customer
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
accounts: JSONObject
config: JSONObject
blacklist: [Blacklist]
userTokens: [UserToken]
}
enum MachineAction {
@ -235,9 +273,17 @@ const typeDefs = gql`
saveConfig(config: JSONObject): JSONObject
createPairingTotem(name: String!): String
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')
.catch(e => console.error('Error: lamassu-server not responding'))
@ -246,7 +292,10 @@ const resolvers = {
JSONObject: GraphQLJSONObject,
Date: GraphQLDateTime,
Customer: {
transactions: parent => transactions.getCustomerTransactions(parent.id)
transactions: parent => transactionsLoader.load(parent.id)
},
Machine: {
latestEvent: parent => machineEventsLoader.load(parent.deviceId)
},
Query: {
countries: () => countries,
@ -255,6 +304,7 @@ const resolvers = {
accountsConfig: () => accountsConfig,
cryptoCurrencies: () => coins,
machines: () => machineLoader.getMachineNames(),
machine: (...[, { deviceId }]) => machineLoader.getMachine(deviceId),
customers: () => customers.getCustomersList(),
customer: (...[, { customerId }]) => customers.getCustomerById(customerId),
funding: () => funding.getFunding(),
@ -273,18 +323,28 @@ const resolvers = {
transactionsCsv: (...[, { from, until, limit, offset }]) =>
transactions.batch(from, until, limit, offset).then(parseAsync),
config: () => settingsLoader.loadLatestConfigOrNone(),
accounts: () => settingsLoader.loadAccounts()
accounts: () => settingsLoader.loadAccounts(),
blacklist: () => blacklist.getBlacklist(),
userTokens: () => tokenManager.getTokenList()
},
Mutation: {
machineAction: (...[, { deviceId, action, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cassette1, cassette2, newName }),
createPairingTotem: (...[, { name }]) => pairing.totem(name),
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)
.then(it => {
notify()
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 }))
}
function register (otp) {
function register (otp, ua, ip) {
return validateOTP(otp)
.then(r => {
if (!r.success) return r
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 }))
})
.catch(() => ({ success: false, expired: false }))

View file

@ -1,4 +1,5 @@
const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const db = require('../db')
const machineLoader = require('../machine-loader')
@ -65,9 +66,8 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
.then(packager)
}
function getCustomerTransactions (customerId) {
function getCustomerTransactionsBatch (ids) {
const packager = _.flow(it => {
console.log()
return it
}, _.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
from cash_in_txs as txs
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`
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
and actions.action = 'provisionAddress'
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`
return Promise.all([
db.any(cashInSql, [customerId, cashInTx.PENDING_INTERVAL, NUM_RESULTS]),
db.any(cashOutSql, [customerId, NUM_RESULTS, REDEEMABLE_AGE])
db.any(cashInSql, [_.map(pgp.as.text, ids).join(','), cashInTx.PENDING_INTERVAL, NUM_RESULTS]),
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) {
@ -156,4 +158,4 @@ function cancel (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 machineName = device.machineName
const machineName = device.name
const cashInAlert = device.cashbox > notifications.cashInAlertThreshold
? {

View file

@ -1,4 +1,6 @@
const _ = require('lodash/fp')
const db = require('./db')
const pgp = require('pg-promise')()
function getInsertQuery (tableName, fields) {
// outputs string like: '$1, $2, $3...' with proper No of items
@ -48,6 +50,16 @@ exports.machineEvent = function machineEvent (rec) {
.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 () {
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 Grid from '@material-ui/core/Grid'
import {
StylesProvider,
jssPreset,
@ -8,12 +9,18 @@ import {
import { create } from 'jss'
import extendJss from 'jss-plugin-extend'
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 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 theme from './styling/theme'
import { backgroundColor, mainWidth } from './styling/variables'
@ -46,6 +53,18 @@ const useStyles = makeStyles({
flex: 1,
display: 'flex',
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 classes = useStyles()
const location = useLocation()
const history = useHistory()
const { wizardTested } = useContext(AppContext)
const route = location.pathname
const sidebar = hasSidebar(route)
const parent = sidebar ? getParent(route) : {}
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 (
<div className={classes.root}>
{!is404 && wizardTested && <Header tree={tree} />}
<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>
</div>
)

View file

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

View file

@ -3,9 +3,11 @@ import classnames from 'classnames'
import React, { memo, useState } from 'react'
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 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 styles from './Header.styles'
@ -76,9 +78,13 @@ const Header = memo(({ tree }) => {
</NavLink>
))}
</ul>
<Link color="action" onClick={() => setOpen(true)}>
Add Machine
</Link>
<ActionButton
color="secondary"
Icon={AddIcon}
InverseIcon={AddIconReverse}
onClick={() => setOpen(true)}>
Add machine
</ActionButton>
</nav>
</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}>
<P>
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>
(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 { makeStyles } from '@material-ui/core/styles'
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 = [
{
// Default article for non-maped statuses
@ -43,6 +53,24 @@ const supportArtices = [
// 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 }) =>
supportArtices.find(({ code: article }) => article === status)
@ -68,11 +96,37 @@ const Item = ({ children, ...props }) => (
</Grid>
)
const getState = machineEventsLazy =>
JSON.parse(machineEventsLazy.machine.latestEvent?.note ?? '{"state": null}')
.state
const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
const [action, setAction] = useState(null)
const [action, setAction] = useState({ command: null })
const [errorMessage, setErrorMessage] = useState(null)
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, {
onError: ({ message }) => {
const errorMessage = message ?? 'An error ocurred'
@ -80,11 +134,12 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
},
onCompleted: () => {
onActionSuccess && onActionSuccess()
setAction(null)
setAction({ command: null })
}
})
const confirmDialogOpen = Boolean(action)
const confirmDialogOpen = Boolean(action.command)
const disabled = !!(action?.command === 'restartServices' && loadingEvents)
return (
<>
@ -127,25 +182,26 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
className={classes.separator}
/>
<ConfirmDialog
disabled={disabled}
open={confirmDialogOpen}
title={`${action?.command} this machine?`}
title={`${action?.display} this machine?`}
errorMessage={errorMessage}
toBeConfirmed={machine.name}
message={action?.message}
confirmationMessage={action?.confirmationMessage}
saveButtonAlwaysEnabled={action?.command === 'Rename'}
saveButtonAlwaysEnabled={action?.command === 'rename'}
onConfirmed={value => {
setErrorMessage(null)
machineAction({
variables: {
deviceId: machine.deviceId,
action: `${action?.command}`.toLowerCase(),
...(action?.command === 'Rename' && { newName: value })
action: `${action?.command}`,
...(action?.command === 'rename' && { newName: value })
}
})
}}
onDissmised={() => {
setAction(null)
setAction({ command: null })
setErrorMessage(null)
}}
/>
@ -174,7 +230,8 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
InverseIcon={EditReversedIcon}
onClick={() =>
setAction({
command: 'Rename',
command: 'rename',
display: 'Rename',
confirmationMessage: 'Write the new name for this machine'
})
}>
@ -188,7 +245,8 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
disabled={loading}
onClick={() =>
setAction({
command: 'Unpair'
command: 'unpair',
display: 'Unpair'
})
}>
Unpair
@ -201,26 +259,43 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess }) => {
disabled={loading}
onClick={() =>
setAction({
command: 'Reboot'
command: 'reboot',
display: 'Reboot'
})
}>
Reboot
</ActionButton>
<ActionButton
className={classes.inlineChip}
className={classes.mr}
disabled={loading}
color="primary"
Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon}
onClick={() =>
setAction({
command: 'Shutdown',
command: 'shutdown',
display: 'Shutdown',
message:
'In order to bring it back online, the machine will need to be visited and its power reset.'
})
}>
Shutdown
</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>
</Item>
</Container>

View file

@ -4,7 +4,13 @@ import {
detailsRowStyles,
labelStyles
} 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 = {
...detailsRowStyles,
@ -58,6 +64,9 @@ const machineDetailsStyles = {
marginRight: 60,
marginLeft: 'auto',
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 { transformNumber } from '../helper'
const CASSETTE_1_KEY = 'cassette1'
const CASSETTE_2_KEY = 'cassette2'
const CASSETTE_1_KEY = 'fiatBalanceCassette1'
const CASSETTE_2_KEY = 'fiatBalanceCassette2'
const MACHINE_KEY = 'machine'
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({
requirement: Yup.string().required(),
suspensionDays: Yup.number()
suspensionDays: Yup.number().when('requirement', {
is: 'suspend',
then: Yup.number().required()
})
})
const Schema = Yup.object().shape({

View file

@ -10,6 +10,7 @@ import {
import { AppContext } from 'src/App'
import AuthRegister from 'src/pages/AuthRegister'
import Blacklist from 'src/pages/Blacklist'
import Cashout from 'src/pages/Cashout'
import Commissions from 'src/pages/Commissions'
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 MachineStatus from 'src/pages/Maintenance/MachineStatus'
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 Services from 'src/pages/Services/Services'
import TokenManagement from 'src/pages/TokenManagement/TokenManagement'
import Transactions from 'src/pages/Transactions/Transactions'
import Triggers from 'src/pages/Triggers'
import WalletSettings from 'src/pages/Wallet/Wallet'
@ -123,7 +128,36 @@ const tree = [
key: namespaces.OPERATOR_INFO,
label: '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',
component: Customers
},
{
key: 'blacklist',
label: 'Blacklist',
route: '/compliance/blacklist',
component: Blacklist
},
{
key: 'customer',
route: '/compliance/customer/:id',
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 leafRoutes = R.compose(R.flatten, map)(tree)
const parentRoutes = R.filter(R.has('children'))(tree)
const mappedRoutes = R.compose(R.flatten, map)(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 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 history = useHistory()
const location = useLocation()
@ -191,4 +271,4 @@ const Routes = () => {
</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",
"cookie-parser": "^1.4.3",
"cors": "^2.8.5",
"dataloader": "^2.0.0",
"ethereumjs-tx": "^1.3.3",
"ethereumjs-util": "^5.2.0",
"ethereumjs-wallet": "^0.6.3",
@ -67,13 +68,15 @@
"socket.io-client": "^2.0.3",
"talisman": "^0.20.0",
"twilio": "^3.6.1",
"ua-parser-js": "^0.7.22",
"uuid": "^3.1.0",
"web3": "^0.20.6",
"winston": "^2.4.2",
"winston-transport": "^4.3.0",
"ws": "^3.1.0",
"xml-stream": "^0.4.5",
"xmlrpc": "^1.3.2"
"xmlrpc": "^1.3.2",
"yup": "^0.31.1"
},
"repository": {
"type": "git",
@ -107,6 +110,7 @@
"scripts": {
"start": "node bin/lamassu-server",
"test": "mocha --recursive tests",
"jtest": "jest",
"build-admin": "npm run build-admin:css && npm run build-admin:main && npm run build-admin:lamassu",
"server": "nodemon bin/lamassu-server --mockSms",
"admin-server": "nodemon bin/lamassu-admin-server --dev",
@ -121,6 +125,7 @@
"devDependencies": {
"ava": "3.8.2",
"concurrently": "^5.3.0",
"jest": "^26.6.3",
"mocha": "^5.0.1",
"nodemon": "^2.0.6",
"rewire": "^4.0.1",