Merge branch 'dev' into fix/lam-235/lamassu-coins-install

This commit is contained in:
André Sá 2022-01-20 10:29:01 +00:00 committed by GitHub
commit 0e9f3e5863
66 changed files with 1544 additions and 638 deletions

View file

@ -2,6 +2,7 @@
const { asyncLocalStorage, defaultStore } = require('../lib/async-storage') const { asyncLocalStorage, defaultStore } = require('../lib/async-storage')
const userManagement = require('../lib/new-admin/graphql/modules/userManagement') const userManagement = require('../lib/new-admin/graphql/modules/userManagement')
const authErrors = require('../lib/new-admin/graphql/errors/authentication')
const options = require('../lib/options') const options = require('../lib/options')
const name = process.argv[2] const name = process.argv[2]
@ -14,29 +15,25 @@ if (!domain) {
} }
if (!name || !role) { if (!name || !role) {
console.log('Usage: lamassu-register <username> <role>') console.log('Usage: lamassu-register <email> <role>')
console.log('<role> must be \'user\' or \'superuser\'')
process.exit(2) process.exit(2)
} }
const emailRegex = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ const emailRegex = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
if (!emailRegex.test(name)) { if (!emailRegex.test(name)) {
console.log('Usage: <name> should be in an email format') console.log('Usage: <email> must be in an email format')
process.exit(2) process.exit(2)
} }
if (role !== 'user' && role !== 'superuser') { if (role !== 'user' && role !== 'superuser') {
console.log('Usage: <role> has two possible values: user | superuser') console.log('Usage: <role> must be \'user\' or \'superuser\'')
process.exit(2) process.exit(2)
} }
asyncLocalStorage.run(defaultStore(), () => { asyncLocalStorage.run(defaultStore(), () => {
userManagement.createRegisterToken(name, role).then(token => { userManagement.createRegisterToken(name, role).then(token => {
if (!token) {
console.log(`A user named ${name} already exists!`)
process.exit(2)
}
if (domain === 'localhost') { if (domain === 'localhost') {
console.log(`https://${domain}:3001/register?t=${token.token}`) console.log(`https://${domain}:3001/register?t=${token.token}`)
} else { } else {
@ -45,6 +42,12 @@ asyncLocalStorage.run(defaultStore(), () => {
process.exit(0) process.exit(0)
}).catch(err => { }).catch(err => {
if (err instanceof authErrors.UserAlreadyExistsError){
console.log(`A user with email ${name} already exists!`)
process.exit(2)
}
console.log('Error: %s', err) console.log('Error: %s', err)
process.exit(3) process.exit(3)
}) })

View file

@ -73,8 +73,9 @@ function unmergeCassettes(cassettes, output) {
} }
function makeChangeDuo(cassettes, amount) { function makeChangeDuo(cassettes, amount) {
const small = cassettes[0] // Initialize empty cassettes in case of undefined, due to same denomination across all cassettes results in a single merged cassette
const large = cassettes[1] const small = cassettes[0] ?? { denomination: 0, count: 0 }
const large = cassettes[1] ?? { denomination: 0, count: 0 }
const largeDenom = large.denomination const largeDenom = large.denomination
const smallDenom = small.denomination const smallDenom = small.denomination

View file

@ -23,18 +23,18 @@ module.exports = {
const BINARIES = { const BINARIES = {
BTC: { BTC: {
defaultUrl: 'https://bitcoincore.org/bin/bitcoin-core-0.20.0/bitcoin-0.20.0-x86_64-linux-gnu.tar.gz', defaultUrl: 'https://bitcoincore.org/bin/bitcoin-core-0.20.1/bitcoin-0.20.1-x86_64-linux-gnu.tar.gz',
defaultDir: 'bitcoin-0.20.0/bin', defaultDir: 'bitcoin-0.20.1/bin',
url: 'https://bitcoincore.org/bin/bitcoin-core-22.0/bitcoin-22.0-x86_64-linux-gnu.tar.gz', url: 'https://bitcoincore.org/bin/bitcoin-core-22.0/bitcoin-22.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-22.0/bin' dir: 'bitcoin-22.0/bin'
}, },
ETH: { ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.13-7a0c19f8.tar.gz', url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.15-8be800ff.tar.gz',
dir: 'geth-linux-amd64-1.10.13-7a0c19f8' dir: 'geth-linux-amd64-1.10.15-8be800ff'
}, },
ZEC: { ZEC: {
url: 'https://z.cash/downloads/zcash-4.5.1-1-linux64-debian-stretch.tar.gz', url: 'https://z.cash/downloads/zcash-4.6.0-1-linux64-debian-stretch.tar.gz',
dir: 'zcash-4.5.1-1/bin' dir: 'zcash-4.6.0-1/bin'
}, },
DASH: { DASH: {
url: 'https://github.com/dashpay/dash/releases/download/v0.17.0.3/dashcore-0.17.0.3-x86_64-linux-gnu.tar.gz', url: 'https://github.com/dashpay/dash/releases/download/v0.17.0.3/dashcore-0.17.0.3-x86_64-linux-gnu.tar.gz',
@ -50,8 +50,8 @@ const BINARIES = {
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']] files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
}, },
XMR: { XMR: {
url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.2.0.tar.bz2', url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.3.0.tar.bz2',
dir: 'monero-x86_64-linux-gnu-v0.17.2.0', dir: 'monero-x86_64-linux-gnu-v0.17.3.0',
files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']] files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']]
} }
} }

View file

@ -117,8 +117,9 @@ function run () {
_.filter(c => c.type !== 'erc-20'), _.filter(c => c.type !== 'erc-20'),
_.map(c => { _.map(c => {
const checked = isInstalledSoftware(c) && isInstalledVolume(c) const checked = isInstalledSoftware(c) && isInstalledVolume(c)
const name = c.code === 'ethereum' ? 'Ethereum and/or USDT' : c.display
return { return {
name: c.display, name,
value: c.code, value: c.code,
checked, checked,
disabled: checked && 'Installed' disabled: checked && 'Installed'

View file

@ -631,8 +631,8 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override, const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration, phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at, id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat, sanctions_override, total_txs, total_spent, LEAST(created, last_transaction) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer
FROM ( FROM (
SELECT c.id, c.authorized_override, SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended, greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
@ -640,13 +640,13 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
c.front_camera_path, c.front_camera_override, c.front_camera_path, c.front_camera_override,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration, c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions, c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, t.tx_class, t.fiat, t.fiat_code, t.created, cn.notes, c.sanctions_at, c.sanctions_override, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (partition by c.id order by t.created desc) AS rn, row_number() OVER (partition by c.id order by t.created desc) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs, sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,
coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN ( FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true UNION FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id
LEFT OUTER JOIN ( LEFT OUTER JOIN (
@ -686,8 +686,8 @@ function getCustomerById (id) {
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_at, front_camera_override, const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_at, front_camera_override,
phone, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration, phone, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_at, id_card_photo_override, us_ssn_at, us_ssn, us_ssn_override, sanctions, sanctions_at, id_card_photo_path, id_card_photo_at, id_card_photo_override, us_ssn_at, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat, sanctions_override, total_txs, total_spent, LEAST(created, last_transaction) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes, is_test_customer
FROM ( FROM (
SELECT c.id, c.authorized_override, SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended, greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended,
@ -695,13 +695,13 @@ function getCustomerById (id) {
c.front_camera_path, c.front_camera_override, c.front_camera_at, c.front_camera_path, c.front_camera_override, c.front_camera_at,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration, c.phone, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_at, c.id_card_photo_override, c.us_ssn, c.us_ssn_at, c.us_ssn_override, c.sanctions, c.id_card_photo_path, c.id_card_photo_at, c.id_card_photo_override, c.us_ssn, c.us_ssn_at, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created, cn.notes, c.sanctions_at, c.sanctions_override, c.subscriber_info, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (PARTITION BY c.id ORDER BY t.created DESC) AS rn, row_number() OVER (PARTITION BY c.id ORDER BY t.created DESC) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY c.id) AS total_txs, sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY c.id) AS total_txs,
sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (PARTITION BY c.id) AS total_spent, ccf.custom_fields sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (PARTITION BY c.id) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN ( FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true UNION FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) t ON c.id = t.customer_id FROM cash_out_txs WHERE confirmed_at IS NOT NULL) t ON c.id = t.customer_id
LEFT OUTER JOIN ( LEFT OUTER JOIN (
@ -993,6 +993,7 @@ function addCustomField (customerId, label, value) {
} }
}) })
) )
.then(res => !_.isNil(res))
} }
function saveCustomField (customerId, fieldId, newValue) { function saveCustomField (customerId, fieldId, newValue) {
@ -1022,6 +1023,16 @@ function getCustomInfoRequestsData (customer) {
return db.any(sql, [customer.id]).then(res => _.set('custom_info_request_data', res, customer)) return db.any(sql, [customer.id]).then(res => _.set('custom_info_request_data', res, customer))
} }
function enableTestCustomer (customerId) {
const sql = `UPDATE customers SET is_test_customer=true WHERE id=$1`
return db.none(sql, [customerId])
}
function disableTestCustomer (customerId) {
const sql = `UPDATE customers SET is_test_customer=false WHERE id=$1`
return db.none(sql, [customerId])
}
module.exports = { module.exports = {
add, add,
get, get,
@ -1040,5 +1051,7 @@ module.exports = {
edit, edit,
deleteEditedData, deleteEditedData,
updateEditedPhoto, updateEditedPhoto,
updateTxCustomerPhoto updateTxCustomerPhoto,
enableTestCustomer,
disableTestCustomer
} }

View file

@ -12,30 +12,35 @@ const configManager = require('./new-config-manager')
const settingsLoader = require('./new-settings-loader') const settingsLoader = require('./new-settings-loader')
const notifierUtils = require('./notifier/utils') const notifierUtils = require('./notifier/utils')
const notifierQueries = require('./notifier/queries') const notifierQueries = require('./notifier/queries')
const { ApolloError } = require('apollo-server-errors');
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' } const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' } const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
const stuckStatus = { label: 'Stuck', type: 'error' } const stuckStatus = { label: 'Stuck', type: 'error' }
function toMachineObject (r) {
return {
deviceId: r.device_id,
cashbox: r.cashbox,
cassette1: r.cassette1,
cassette2: r.cassette2,
cassette3: r.cassette3,
cassette4: r.cassette4,
numberOfCassettes: r.number_of_cassettes,
version: r.version,
model: r.model,
pairedAt: new Date(r.created),
lastPing: new Date(r.last_online),
name: r.name,
paired: r.paired
// TODO: we shall start using this JSON field at some point
// location: r.location,
}
}
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')
.then(rr => rr.map(r => ({ .then(rr => rr.map(toMachineObject))
deviceId: r.device_id,
cashbox: r.cashbox,
cassette1: r.cassette1,
cassette2: r.cassette2,
cassette3: r.cassette3,
cassette4: r.cassette4,
numberOfCassettes: r.number_of_cassettes,
version: r.version,
model: r.model,
pairedAt: new Date(r.created),
lastPing: new Date(r.last_online),
name: r.name,
// TODO: we shall start using this JSON field at some point
// location: r.location,
paired: r.paired
})))
} }
function getConfig (defaultConfig) { function getConfig (defaultConfig) {
@ -100,21 +105,10 @@ function getMachineName (machineId) {
function getMachine (machineId, config) { function getMachine (machineId, config) {
const sql = 'SELECT * FROM devices WHERE device_id=$1' const sql = 'SELECT * FROM devices WHERE device_id=$1'
const queryMachine = db.oneOrNone(sql, [machineId]).then(r => ({ const queryMachine = db.oneOrNone(sql, [machineId]).then(r => {
deviceId: r.device_id, if (r === null) throw new ApolloError('Resource doesn\'t exist', 'NOT_FOUND')
cashbox: r.cashbox, else return toMachineObject(r)
cassette1: r.cassette1, })
cassette2: r.cassette2,
cassette3: r.cassette3,
cassette4: r.cassette4,
numberOfCassettes: r.number_of_cassettes,
version: r.version,
model: r.model,
pairedAt: new Date(r.created),
lastPing: new Date(r.last_online),
name: r.name,
paired: r.paired
}))
return Promise.all([queryMachine, dbm.machineEvents(), config]) return Promise.all([queryMachine, dbm.machineEvents(), config])
.then(([machine, events, config]) => { .then(([machine, events, config]) => {

View file

@ -240,7 +240,7 @@ const reset2FA = (token, userID, code, context) => {
} }
const getToken = context => { const getToken = context => {
if (_.isNil(context.req.cookies.lid) || _.isNil(context.req.session.user.id)) if (_.isNil(context.req.cookies['lamassu_sid']) || _.isNil(context.req.session.user.id))
throw new authErrors.AuthenticationError('Authentication failed') throw new authErrors.AuthenticationError('Authentication failed')
return context.req.session.user.id return context.req.session.user.id

View file

@ -1,3 +1,4 @@
const authentication = require('../modules/userManagement')
const queries = require('../../services/customInfoRequests') const queries = require('../../services/customInfoRequests')
const DataLoader = require('dataloader') const DataLoader = require('dataloader')
@ -21,7 +22,10 @@ const resolvers = {
insertCustomInfoRequest: (...[, { customRequest }]) => queries.addCustomInfoRequest(customRequest), insertCustomInfoRequest: (...[, { customRequest }]) => queries.addCustomInfoRequest(customRequest),
removeCustomInfoRequest: (...[, { id }]) => queries.removeCustomInfoRequest(id), removeCustomInfoRequest: (...[, { id }]) => queries.removeCustomInfoRequest(id),
editCustomInfoRequest: (...[, { id, customRequest }]) => queries.editCustomInfoRequest(id, customRequest), editCustomInfoRequest: (...[, { id, customRequest }]) => queries.editCustomInfoRequest(id, customRequest),
setAuthorizedCustomRequest: (...[, { customerId, infoRequestId, isAuthorized }]) => queries.setAuthorizedCustomRequest(customerId, infoRequestId, isAuthorized), setAuthorizedCustomRequest: (...[, { customerId, infoRequestId, override }, context]) => {
const token = authentication.getToken(context)
return queries.setAuthorizedCustomRequest(customerId, infoRequestId, override, token)
},
setCustomerCustomInfoRequest: (...[, { customerId, infoRequestId, data }]) => queries.setCustomerData(customerId, infoRequestId, data) setCustomerCustomInfoRequest: (...[, { customerId, infoRequestId, data }]) => queries.setCustomerData(customerId, infoRequestId, data)
} }
} }

View file

@ -21,7 +21,7 @@ const resolvers = {
return customers.updateCustomer(customerId, customerInput, token) return customers.updateCustomer(customerId, customerInput, token)
}, },
addCustomField: (...[, { customerId, label, value }]) => customers.addCustomField(customerId, label, value), addCustomField: (...[, { customerId, label, value }]) => customers.addCustomField(customerId, label, value),
saveCustomField: (...[, { customerId, fieldId, newValue }]) => customers.saveCustomField(customerId, fieldId, newValue), saveCustomField: (...[, { customerId, fieldId, value }]) => customers.saveCustomField(customerId, fieldId, value),
removeCustomField: (...[, [ { customerId, fieldId } ]]) => customers.removeCustomField(customerId, fieldId), removeCustomField: (...[, [ { customerId, fieldId } ]]) => customers.removeCustomField(customerId, fieldId),
editCustomer: async (root, { customerId, customerEdit }, context) => { editCustomer: async (root, { customerId, customerEdit }, context) => {
const token = authentication.getToken(context) const token = authentication.getToken(context)
@ -49,7 +49,12 @@ const resolvers = {
}, },
deleteCustomerNote: (...[, { noteId }]) => { deleteCustomerNote: (...[, { noteId }]) => {
return customerNotes.deleteCustomerNote(noteId) return customerNotes.deleteCustomerNote(noteId)
} },
createCustomer: (...[, { phoneNumber }]) => customers.add({ phone: phoneNumber }),
enableTestCustomer: (...[, { customerId }]) =>
customers.enableTestCustomer(customerId),
disableTestCustomer: (...[, { customerId }]) =>
customers.disableTestCustomer(customerId)
} }
} }

View file

@ -30,10 +30,10 @@ const resolvers = {
isAnonymous: parent => (parent.customerId === anonymous.uuid) isAnonymous: parent => (parent.customerId === anonymous.uuid)
}, },
Query: { Query: {
transactions: (...[, { from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status }]) => transactions: (...[, { from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers }]) =>
transactions.batch(from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status), transactions.batch(from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers),
transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, timezone, simplified }]) => transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, timezone, excludeTestingCustomers, simplified }]) =>
transactions.batch(from, until, limit, offset, null, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, simplified) transactions.batch(from, until, limit, offset, null, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers, simplified)
.then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime']), { fields: txLogFields })), .then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime']), { fields: txLogFields })),
transactionCsv: (...[, { id, txClass, timezone }]) => transactionCsv: (...[, { id, txClass, timezone }]) =>
transactions.getTx(id, txClass).then(data => transactions.getTx(id, txClass).then(data =>

View file

@ -32,7 +32,9 @@ const typeDef = gql`
type CustomRequestData { type CustomRequestData {
customerId: ID customerId: ID
infoRequestId: ID infoRequestId: ID
approved: Boolean override: String
overrideAt: Date
overrideBy: ID
customerData: JSON customerData: JSON
customInfoRequest: CustomInfoRequest customInfoRequest: CustomInfoRequest
} }
@ -47,7 +49,7 @@ const typeDef = gql`
insertCustomInfoRequest(customRequest: CustomRequestInput!): CustomInfoRequest @auth insertCustomInfoRequest(customRequest: CustomRequestInput!): CustomInfoRequest @auth
removeCustomInfoRequest(id: ID!): CustomInfoRequest @auth removeCustomInfoRequest(id: ID!): CustomInfoRequest @auth
editCustomInfoRequest(id: ID!, customRequest: CustomRequestInput!): CustomInfoRequest @auth editCustomInfoRequest(id: ID!, customRequest: CustomRequestInput!): CustomInfoRequest @auth
setAuthorizedCustomRequest(customerId: ID!, infoRequestId: ID!, isAuthorized: Boolean!): Boolean @auth setAuthorizedCustomRequest(customerId: ID!, infoRequestId: ID!, override: String!): Boolean @auth
setCustomerCustomInfoRequest(customerId: ID!, infoRequestId: ID!, data: JSON!): Boolean @auth setCustomerCustomInfoRequest(customerId: ID!, infoRequestId: ID!, data: JSON!): Boolean @auth
} }
` `

View file

@ -1,12 +1,6 @@
const { gql } = require('apollo-server-express') const { gql } = require('apollo-server-express')
const typeDef = gql` const typeDef = gql`
type CustomerCustomField {
id: ID
label: String
value: String
}
type Customer { type Customer {
id: ID! id: ID!
authorizedOverride: String authorizedOverride: String
@ -42,6 +36,7 @@ const typeDef = gql`
customFields: [CustomerCustomField] customFields: [CustomerCustomField]
customInfoRequests: [CustomRequestData] customInfoRequests: [CustomRequestData]
notes: [CustomerNote] notes: [CustomerNote]
isTestCustomer: Boolean
} }
input CustomerInput { input CustomerInput {
@ -86,6 +81,12 @@ const typeDef = gql`
content: String content: String
} }
type CustomerCustomField {
id: ID
label: String
value: String
}
type Query { type Query {
customers(phone: String, name: String, address: String, id: String): [Customer] @auth customers(phone: String, name: String, address: String, id: String): [Customer] @auth
customer(customerId: ID!): Customer @auth customer(customerId: ID!): Customer @auth
@ -94,15 +95,18 @@ const typeDef = gql`
type Mutation { type Mutation {
setCustomer(customerId: ID!, customerInput: CustomerInput): Customer @auth setCustomer(customerId: ID!, customerInput: CustomerInput): Customer @auth
addCustomField(customerId: ID!, label: String!, value: String!): CustomerCustomField @auth addCustomField(customerId: ID!, label: String!, value: String!): Boolean @auth
saveCustomField(customerId: ID!, fieldId: ID!, value: String!): CustomerCustomField @auth saveCustomField(customerId: ID!, fieldId: ID!, value: String!): Boolean @auth
removeCustomField(customerId: ID!, fieldId: ID!): CustomerCustomField @auth removeCustomField(customerId: ID!, fieldId: ID!): Boolean @auth
editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth
createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth
editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth
deleteCustomerNote(noteId: ID!): Boolean @auth deleteCustomerNote(noteId: ID!): Boolean @auth
createCustomer(phoneNumber: String): Customer @auth
enableTestCustomer(customerId: ID!): Boolean @auth
disableTestCustomer(customerId: ID!): Boolean @auth
} }
` `

View file

@ -55,8 +55,8 @@ const typeDef = gql`
} }
type Query { type Query {
transactions(from: Date, until: Date, limit: Int, offset: Int, deviceId: ID, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String): [Transaction] @auth transactions(from: Date, until: Date, limit: Int, offset: Int, deviceId: ID, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, excludeTestingCustomers: Boolean): [Transaction] @auth
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, timezone: String, simplified: Boolean): String @auth transactionsCsv(from: Date, until: Date, limit: Int, offset: Int, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, timezone: String, excludeTestingCustomers: Boolean, simplified: Boolean): String @auth
transactionCsv(id: ID, txClass: String, timezone: String): String @auth transactionCsv(id: ID, txClass: String, timezone: String): String @auth
txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth
transactionFilters: [Filter] @auth transactionFilters: [Filter] @auth

View file

@ -35,8 +35,10 @@ const getAllCustomInfoRequestsForCustomer = (customerId) => {
return db.any(sql, [customerId]).then(res => res.map(item => ({ return db.any(sql, [customerId]).then(res => res.map(item => ({
customerId: item.customer_id, customerId: item.customer_id,
infoRequestId: item.info_request_id, infoRequestId: item.info_request_id,
approved: item.approved, customerData: item.customer_data,
customerData: item.customer_data override: item.override,
overrideAt: item.override_at,
overrideBy: item.override_by
}))) })))
} }
@ -46,8 +48,10 @@ const getCustomInfoRequestForCustomer = (customerId, infoRequestId) => {
return { return {
customerId: item.customer_id, customerId: item.customer_id,
infoRequestId: item.info_request_id, infoRequestId: item.info_request_id,
approved: item.approved, customerData: item.customer_data,
customerData: item.customer_data override: item.override,
overrideAt: item.override_at,
overrideBy: item.override_by
} }
}) })
} }
@ -61,8 +65,10 @@ const batchGetAllCustomInfoRequestsForCustomer = (customerIds) => {
return items.map(item => ({ return items.map(item => ({
customerId: item.customer_id, customerId: item.customer_id,
infoRequestId: item.info_request_id, infoRequestId: item.info_request_id,
approved: item.approved, customerData: item.customer_data,
customerData: item.customer_data override: item.override,
overrideAt: item.override_at,
overrideBy: item.override_by
})) }))
}) })
}) })
@ -93,9 +99,9 @@ const batchGetCustomInfoRequest = (infoRequestIds) => {
}) })
} }
const setAuthorizedCustomRequest = (customerId, infoRequestId, isAuthorized) => { const setAuthorizedCustomRequest = (customerId, infoRequestId, override, token) => {
const sql = `UPDATE customers_custom_info_requests SET approved = $1 WHERE customer_id = $2 AND info_request_id = $3` const sql = `UPDATE customers_custom_info_requests SET override = $1, override_by = $2, override_at = now() WHERE customer_id = $3 AND info_request_id = $4`
return db.none(sql, [isAuthorized, customerId, infoRequestId]).then(() => true) return db.none(sql, [override, token, customerId, infoRequestId]).then(() => true)
} }
const setCustomerData = (customerId, infoRequestId, data) => { const setCustomerData = (customerId, infoRequestId, data) => {
@ -103,7 +109,7 @@ const setCustomerData = (customerId, infoRequestId, data) => {
INSERT INTO customers_custom_info_requests (customer_id, info_request_id, customer_data) INSERT INTO customers_custom_info_requests (customer_id, info_request_id, customer_data)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (customer_id, info_request_id) ON CONFLICT (customer_id, info_request_id)
DO UPDATE SET customer_data = $3, approved = null` DO UPDATE SET customer_data = $3`
return db.none(sql, [customerId, infoRequestId, data]) return db.none(sql, [customerId, infoRequestId, data])
} }

View file

@ -39,6 +39,7 @@ function batch (
cryptoCode = null, cryptoCode = null,
toAddress = null, toAddress = null,
status = null, status = null,
excludeTestingCustomers = false,
simplified = false simplified = false
) { ) {
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames) const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
@ -67,6 +68,7 @@ function batch (
AND ($11 is null or txs.crypto_code = $11) AND ($11 is null or txs.crypto_code = $11)
AND ($12 is null or txs.to_address = $12) AND ($12 is null or txs.to_address = $12)
AND ($13 is null or txs.txStatus = $13) AND ($13 is null or txs.txStatus = $13)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
AND (fiat > 0) AND (fiat > 0)
ORDER BY created DESC limit $4 offset $5` ORDER BY created DESC limit $4 offset $5`
@ -98,6 +100,7 @@ function batch (
AND ($11 is null or txs.crypto_code = $11) AND ($11 is null or txs.crypto_code = $11)
AND ($12 is null or txs.to_address = $12) AND ($12 is null or txs.to_address = $12)
AND ($13 is null or txs.txStatus = $13) AND ($13 is null or txs.txStatus = $13)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
AND (fiat > 0) AND (fiat > 0)
ORDER BY created DESC limit $4 offset $5` ORDER BY created DESC limit $4 offset $5`

View file

@ -1,4 +1,5 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const { getCustomInfoRequests } = require('./new-admin/services/customInfoRequests')
const namespaces = { const namespaces = {
WALLETS: 'wallets', WALLETS: 'wallets',
@ -107,22 +108,29 @@ const getGlobalNotifications = config => getNotifications(null, null, config)
const getTriggers = _.get('triggers') const getTriggers = _.get('triggers')
const getTriggersAutomation = config => { const getTriggersAutomation = config => {
const defaultAutomation = _.get('triggersConfig_automation')(config) return getCustomInfoRequests(true)
const requirements = { .then(infoRequests => {
sanctions: defaultAutomation, const defaultAutomation = _.get('triggersConfig_automation')(config)
idCardPhoto: defaultAutomation, const requirements = {
idCardData: defaultAutomation, sanctions: defaultAutomation,
facephoto: defaultAutomation, idCardPhoto: defaultAutomation,
usSsn: defaultAutomation idCardData: defaultAutomation,
} facephoto: defaultAutomation,
usSsn: defaultAutomation
}
const overrides = _.get('triggersConfig_overrides')(config) _.forEach(it => {
requirements[it.id] = defaultAutomation
}, infoRequests)
const requirementsOverrides = _.reduce((acc, override) => { const overrides = _.get('triggersConfig_overrides')(config)
return _.assign(acc, { [override.requirement]: override.automation })
}, {}, overrides)
return _.assign(requirements, requirementsOverrides) const requirementsOverrides = _.reduce((acc, override) => {
return _.assign(acc, { [override.requirement]: override.automation })
}, {}, overrides)
return _.assign(requirements, requirementsOverrides)
})
} }
const splitGetFirst = _.compose(_.head, _.split('_')) const splitGetFirst = _.compose(_.head, _.split('_'))

View file

@ -20,8 +20,13 @@ const machineLoader = require('../machine-loader')
const { loadLatestConfig } = require('../new-settings-loader') const { loadLatestConfig } = require('../new-settings-loader')
const customInfoRequestQueries = require('../new-admin/services/customInfoRequests') const customInfoRequestQueries = require('../new-admin/services/customInfoRequests')
function updateCustomerCustomInfoRequest (customerId, dataToSave, req, res) { function updateCustomerCustomInfoRequest (customerId, patch, req, res) {
return customInfoRequestQueries.setCustomerData(customerId, dataToSave.info_request_id, dataToSave) if (_.isNil(patch.data)) {
return customers.getById(customerId)
.then(customer => respond(req, res, { customer }))
}
return customInfoRequestQueries.setCustomerData(customerId, patch.infoRequestId, patch)
.then(() => customers.getById(customerId)) .then(() => customers.getById(customerId))
.then(customer => respond(req, res, { customer })) .then(customer => respond(req, res, { customer }))
} }
@ -35,7 +40,7 @@ function updateCustomer (req, res, next) {
const compatTriggers = complianceTriggers.getBackwardsCompatibleTriggers(triggers) const compatTriggers = complianceTriggers.getBackwardsCompatibleTriggers(triggers)
if (patch.customRequestPatch) { if (patch.customRequestPatch) {
return updateCustomerCustomInfoRequest(id, patch.dataToSave, req, res).catch(next) return updateCustomerCustomInfoRequest(id, patch.customRequestPatch, req, res).catch(next)
} }
customers.getById(id) customers.getById(id)

View file

@ -41,8 +41,8 @@ const createTerms = terms => (terms.active && terms.text) ? ({
const buildTriggers = (allTriggers) => { const buildTriggers = (allTriggers) => {
const normalTriggers = [] const normalTriggers = []
const customTriggers = _.filter(o => { const customTriggers = _.filter(o => {
if (o.customInfoRequestId === '') normalTriggers.push(o) if (_.isEmpty(o.customInfoRequestId) || _.isNil(o.customInfoRequestId)) normalTriggers.push(o)
return o.customInfoRequestId !== '' return !_.isNil(o.customInfoRequestId) && !_.isEmpty(o.customInfoRequestId)
}, allTriggers) }, allTriggers)
return _.flow([_.map(_.get('customInfoRequestId')), customRequestQueries.batchGetCustomInfoRequest])(customTriggers) return _.flow([_.map(_.get('customInfoRequestId')), customRequestQueries.batchGetCustomInfoRequest])(customTriggers)
@ -73,7 +73,7 @@ function poll (req, res, next) {
const pi = plugins(settings, deviceId) const pi = plugins(settings, deviceId)
const hasLightning = checkHasLightning(settings) const hasLightning = checkHasLightning(settings)
const triggersAutomation = configManager.getTriggersAutomation(settings.config) const triggersAutomationPromise = configManager.getTriggersAutomation(settings.config)
const triggersPromise = buildTriggers(configManager.getTriggers(settings.config)) const triggersPromise = buildTriggers(configManager.getTriggers(settings.config))
const operatorInfo = configManager.getOperatorInfo(settings.config) const operatorInfo = configManager.getOperatorInfo(settings.config)
@ -84,8 +84,8 @@ function poll (req, res, next) {
state.pids[operatorId] = { [deviceId]: { pid, ts: Date.now() } } state.pids[operatorId] = { [deviceId]: { pid, ts: Date.now() } }
return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise]) return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise, triggersAutomationPromise])
.then(([results, triggers]) => { .then(([results, triggers, triggersAutomation]) => {
const cassettes = results.cassettes const cassettes = results.cassettes
const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid

View file

@ -0,0 +1,13 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE customers ADD COLUMN is_test_customer BOOLEAN NOT NULL DEFAULT false`,
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,16 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE customers_custom_info_requests DROP COLUMN approved`,
`ALTER TABLE customers_custom_info_requests ADD COLUMN override verification_type NOT NULL DEFAULT 'automatic'`,
`ALTER TABLE customers_custom_info_requests ADD COLUMN override_by UUID REFERENCES users(id)`,
`ALTER TABLE customers_custom_info_requests ADD COLUMN override_at TIMESTAMPTZ`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -13870,6 +13870,11 @@
"delegate": "^3.1.2" "delegate": "^3.1.2"
} }
}, },
"google-libphonenumber": {
"version": "3.2.22",
"resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.22.tgz",
"integrity": "sha512-lzEllxWc05n/HEv75SsDrA7zdEVvQzTZimItZm/TZ5XBs7cmx2NJmSlA5I0kZbdKNu8GFETBhSpo+SOhx0JslA=="
},
"graceful-fs": { "graceful-fs": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz",

View file

@ -26,6 +26,7 @@
"downshift": "3.3.4", "downshift": "3.3.4",
"file-saver": "2.0.2", "file-saver": "2.0.2",
"formik": "2.2.0", "formik": "2.2.0",
"google-libphonenumber": "^3.2.22",
"graphql": "^14.5.8", "graphql": "^14.5.8",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"jss-plugin-extend": "^10.0.0", "jss-plugin-extend": "^10.0.0",

View file

@ -38,6 +38,12 @@ export const Carousel = memo(({ photosData, slidePhoto }) => {
opacity: 1 opacity: 1
} }
}} }}
// navButtonsWrapperProps={{
// style: {
// background: 'linear-gradient(to right, black 10%, transparent 80%)',
// opacity: '0.4'
// }
// }}
autoPlay={false} autoPlay={false}
indicators={false} indicators={false}
navButtonsAlwaysVisible={true} navButtonsAlwaysVisible={true}

View file

@ -181,7 +181,8 @@ const LogsDownloaderPopover = ({
fetchLogs({ fetchLogs({
variables: { variables: {
...args, ...args,
simplified: selectedAdvancedRadio === SIMPLIFIED simplified: selectedAdvancedRadio === SIMPLIFIED,
excludeTestingCustomers: true
} }
}) })
} }
@ -196,7 +197,8 @@ const LogsDownloaderPopover = ({
...args, ...args,
from: range.from, from: range.from,
until: range.until, until: range.until,
simplified: selectedAdvancedRadio === SIMPLIFIED simplified: selectedAdvancedRadio === SIMPLIFIED,
excludeTestingCustomers: true
} }
}) })
} }

View file

@ -64,6 +64,9 @@ const styles = {
position: 'relative', position: 'relative',
marginBottom: spacer / 2, marginBottom: spacer / 2,
paddingTop: spacer * 1.5, paddingTop: spacer * 1.5,
'& > *:first-child': {
marginRight: 24
},
'& > *': { '& > *': {
marginRight: 10 marginRight: 10
}, },
@ -74,7 +77,8 @@ const styles = {
notificationContent: { notificationContent: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center' justifyContent: 'center',
width: 300
}, },
unread: { unread: {
backgroundColor: spring3 backgroundColor: spring3
@ -89,8 +93,7 @@ const styles = {
flexGrow: 1 flexGrow: 1
}, },
unreadIcon: { unreadIcon: {
marginLeft: spacer, marginTop: 2,
marginTop: 5,
width: '12px', width: '12px',
height: '12px', height: '12px',
backgroundColor: secondaryColor, backgroundColor: secondaryColor,

View file

@ -13,12 +13,27 @@ import styles from './NotificationCenter.styles'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const types = { const types = {
transaction: { display: 'Transactions', icon: <Transaction /> }, transaction: {
highValueTransaction: { display: 'Transactions', icon: <Transaction /> }, display: 'Transactions',
fiatBalance: { display: 'Maintenance', icon: <Wrench /> }, icon: <Transaction height={16} width={16} />
cryptoBalance: { display: 'Maintenance', icon: <Wrench /> }, },
compliance: { display: 'Compliance', icon: <WarningIcon /> }, highValueTransaction: {
error: { display: 'Error', icon: <WarningIcon /> } display: 'Transactions',
icon: <Transaction height={16} width={16} />
},
fiatBalance: {
display: 'Maintenance',
icon: <Wrench height={16} width={16} />
},
cryptoBalance: {
display: 'Maintenance',
icon: <Wrench height={16} width={16} />
},
compliance: {
display: 'Compliance',
icon: <WarningIcon height={16} width={16} />
},
error: { display: 'Error', icon: <WarningIcon height={16} width={16} /> }
} }
const NotificationRow = ({ const NotificationRow = ({
@ -35,7 +50,9 @@ const NotificationRow = ({
const classes = useStyles() const classes = useStyles()
const typeDisplay = R.path([type, 'display'])(types) ?? null const typeDisplay = R.path([type, 'display'])(types) ?? null
const icon = R.path([type, 'icon'])(types) ?? <Wrench /> const icon = R.path([type, 'icon'])(types) ?? (
<Wrench height={16} width={16} />
)
const age = prettyMs(new Date().getTime() - new Date(created).getTime(), { const age = prettyMs(new Date().getTime() - new Date(created).getTime(), {
compact: true, compact: true,
verbose: true verbose: true
@ -57,7 +74,9 @@ const NotificationRow = ({
classes.notificationRow, classes.notificationRow,
!read && valid ? classes.unread : '' !read && valid ? classes.unread : ''
)}> )}>
<div className={classes.notificationRowIcon}>{icon}</div> <div className={classes.notificationRowIcon}>
<div>{icon}</div>
</div>
<div className={classes.notificationContent}> <div className={classes.notificationContent}>
<Label2 className={classes.notificationTitle}> <Label2 className={classes.notificationTitle}>
{notificationTitle} {notificationTitle}

View file

@ -132,7 +132,7 @@ const Header = memo(({ tree, user }) => {
return ( return (
<NavLink <NavLink
key={idx} key={idx}
to={!R.isNil(it.children) ? it.children[0].route : it.route} to={it.route || it.children[0].route}
isActive={match => { isActive={match => {
if (!match) return false if (!match) return false
setActive(it) setActive(it)

View file

@ -19,14 +19,14 @@ const TitleSection = ({
buttons = [], buttons = [],
children, children,
appendix, appendix,
appendixClassName appendixRight
}) => { }) => {
const classes = useStyles() const classes = useStyles()
return ( return (
<div className={classnames(classes.titleWrapper, className)}> <div className={classnames(classes.titleWrapper, className)}>
<div className={classes.titleAndButtonsContainer}> <div className={classes.titleAndButtonsContainer}>
<Title>{title}</Title> <Title>{title}</Title>
{appendix && <div className={appendixClassName}>{appendix}</div>} {!!appendix && appendix}
{error && ( {error && (
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage> <ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
)} )}
@ -46,13 +46,14 @@ const TitleSection = ({
</> </>
)} )}
</div> </div>
<Box display="flex" flexDirection="row"> <Box display="flex" flexDirection="row" alignItems="center">
{(labels ?? []).map(({ icon, label }, idx) => ( {(labels ?? []).map(({ icon, label }, idx) => (
<Box key={idx} display="flex" alignItems="center"> <Box key={idx} display="flex" alignItems="center">
<div className={classes.icon}>{icon}</div> <div className={classes.icon}>{icon}</div>
<Label1 className={classes.label}>{label}</Label1> <Label1 className={classes.label}>{label}</Label1>
</Box> </Box>
))} ))}
{appendixRight}
</Box> </Box>
{children} {children}
</div> </div>

View file

@ -2,6 +2,8 @@ import { useQuery } from '@apollo/react-hooks'
import { Box } from '@material-ui/core' import { Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames' import classnames from 'classnames'
import { endOfToday } from 'date-fns'
import { subDays } from 'date-fns/fp'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
@ -42,8 +44,16 @@ const TIME_OPTIONS = {
} }
const GET_TRANSACTIONS = gql` const GET_TRANSACTIONS = gql`
query transactions($limit: Int, $from: Date, $until: Date) { query transactions(
transactions(limit: $limit, from: $from, until: $until) { $from: Date
$until: Date
$excludeTestingCustomers: Boolean
) {
transactions(
from: $from
until: $until
excludeTestingCustomers: $excludeTestingCustomers
) {
id id
txClass txClass
txHash txHash
@ -116,7 +126,13 @@ const OverviewEntry = ({ label, value, oldValue, currency }) => {
const Analytics = () => { const Analytics = () => {
const classes = useStyles() const classes = useStyles()
const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS) const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS, {
variables: {
from: subDays(65, endOfToday()),
until: endOfToday(),
excludeTestingCustomers: true
}
})
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA) const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0]) const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0])

View file

@ -46,23 +46,24 @@ const GET_USER_DATA = gql`
` `
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
client: Yup.string() email: Yup.string()
.required('Client field is required!') .label('Email')
.email('Username field should be in an email format!'), .required()
.email(),
password: Yup.string().required('Password field is required'), password: Yup.string().required('Password field is required'),
rememberMe: Yup.boolean() rememberMe: Yup.boolean()
}) })
const initialValues = { const initialValues = {
client: '', email: '',
password: '', password: '',
rememberMe: false rememberMe: false
} }
const getErrorMsg = (formikErrors, formikTouched, mutationError) => { const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Invalid login/password combination' if (mutationError) return 'Invalid email/password combination'
if (formikErrors.client && formikTouched.client) return formikErrors.client if (formikErrors.email && formikTouched.email) return formikErrors.email
if (formikErrors.password && formikTouched.password) if (formikErrors.password && formikTouched.password)
return formikErrors.password return formikErrors.password
return null return null
@ -142,13 +143,13 @@ const LoginState = ({ state, dispatch, strategy }) => {
validationSchema={validationSchema} validationSchema={validationSchema}
initialValues={initialValues} initialValues={initialValues}
onSubmit={values => onSubmit={values =>
submitLogin(values.client, values.password, values.rememberMe) submitLogin(values.email, values.password, values.rememberMe)
}> }>
{({ errors, touched }) => ( {({ errors, touched }) => (
<Form id="login-form"> <Form id="login-form">
<Field <Field
name="client" name="email"
label="Client" label="Email"
size="lg" size="lg"
component={TextInput} component={TextInput}
fullWidth fullWidth

View file

@ -210,6 +210,10 @@ const Register = () => {
{!loading && state.result === 'failure' && ( {!loading && state.result === 'failure' && (
<> <>
<Label3>Link has expired</Label3> <Label3>Link has expired</Label3>
<Label3>
To obtain a new link, run the command{' '}
<strong>lamassu-register</strong> in your servers terminal.
</Label3>
</> </>
)} )}
</div> </div>

View file

@ -140,14 +140,13 @@ const Setup2FAState = ({ state, dispatch }) => {
<> <>
<div className={classes.infoWrapper}> <div className={classes.infoWrapper}>
<Label3 className={classes.info2}> <Label3 className={classes.info2}>
We detected that this account does not have its two-factor This account does not yet have two-factor authentication enabled. To
authentication enabled. In order to protect the resources in the secure the admin, two-factor authentication is required.
system, a two-factor authentication is enforced.
</Label3> </Label3>
<Label3 className={classes.info2}> <Label3 className={classes.info2}>
To finish this process, please scan the following QR code or insert To complete the registration process, scan the following QR code or
the secret further below on an authentication app of your choice, insert the secret below on a 2FA app, such as Google Authenticator
such as Google Authenticator or Authy. or AndOTP.
</Label3> </Label3>
</div> </div>
<div className={classes.qrCodeWrapper}> <div className={classes.qrCodeWrapper}>

View file

@ -1,6 +1,6 @@
import Grid from '@material-ui/core/Grid' import Grid from '@material-ui/core/Grid'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { parse, format, isValid } from 'date-fns/fp' import { parse, format } from 'date-fns/fp'
import _ from 'lodash/fp' import _ from 'lodash/fp'
import * as R from 'ramda' import * as R from 'ramda'
import { useState, React } from 'react' import { useState, React } from 'react'
@ -26,6 +26,11 @@ import { URI } from 'src/utils/apollo'
import styles from './CustomerData.styles.js' import styles from './CustomerData.styles.js'
import { EditableCard } from './components' import { EditableCard } from './components'
import {
customerDataElements,
customerDataSchemas,
formatDates
} from './helper.js'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
@ -63,7 +68,8 @@ const CustomerData = ({
editCustomer, editCustomer,
deleteEditedData, deleteEditedData,
updateCustomRequest, updateCustomRequest,
authorizeCustomRequest authorizeCustomRequest,
updateCustomEntry
}) => { }) => {
const classes = useStyles() const classes = useStyles()
const [listView, setListView] = useState(false) const [listView, setListView] = useState(false)
@ -84,8 +90,8 @@ const CustomerData = ({
R.compose(R.toLower, R.path(['customInfoRequest', 'customRequest', 'name'])) R.compose(R.toLower, R.path(['customInfoRequest', 'customRequest', 'name']))
) )
const customEntries = null // get customer custom entries const customFields = []
const customRequirements = [] // get customer custom requirements const customRequirements = []
const customInfoRequests = sortByName( const customInfoRequests = sortByName(
R.path(['customInfoRequests'])(customer) ?? [] R.path(['customInfoRequests'])(customer) ?? []
) )
@ -94,87 +100,8 @@ const CustomerData = ({
const getVisibleCards = _.filter(elem => elem.isAvailable) const getVisibleCards = _.filter(elem => elem.isAvailable)
const schemas = {
idScan: Yup.object().shape({
firstName: Yup.string().required(),
lastName: Yup.string().required(),
documentNumber: Yup.string().required(),
dateOfBirth: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required(),
gender: Yup.string().required(),
country: Yup.string().required(),
expirationDate: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required()
}),
usSsn: Yup.object().shape({
usSsn: Yup.string().required()
}),
idCardPhoto: Yup.object().shape({
idCardPhoto: Yup.mixed().required()
}),
frontCamera: Yup.object().shape({
frontCamera: Yup.mixed().required()
})
}
const idScanElements = [
{
name: 'firstName',
label: 'First name',
component: TextInput
},
{
name: 'documentNumber',
label: 'ID number',
component: TextInput
},
{
name: 'dateOfBirth',
label: 'Birthdate',
component: TextInput
},
{
name: 'gender',
label: 'Gender',
component: TextInput
},
{
name: 'lastName',
label: 'Last name',
component: TextInput
},
{
name: 'expirationDate',
label: 'Expiration Date',
component: TextInput
},
{
name: 'country',
label: 'Country',
component: TextInput
}
]
const usSsnElements = [
{
name: 'usSsn',
label: 'US SSN',
component: TextInput,
size: 190
}
]
const idCardPhotoElements = [{ name: 'idCardPhoto' }]
const frontCameraElements = [{ name: 'frontCamera' }]
const initialValues = { const initialValues = {
idScan: { idCardData: {
firstName: R.path(['firstName'])(idData) ?? '', firstName: R.path(['firstName'])(idData) ?? '',
lastName: R.path(['lastName'])(idData) ?? '', lastName: R.path(['lastName'])(idData) ?? '',
documentNumber: R.path(['documentNumber'])(idData) ?? '', documentNumber: R.path(['documentNumber'])(idData) ?? '',
@ -202,19 +129,9 @@ const CustomerData = ({
} }
} }
const formatDates = values => {
_.map(
elem =>
(values[elem] = format('yyyyMMdd')(
parse(new Date(), 'yyyy-MM-dd', values[elem])
))
)(['dateOfBirth', 'expirationDate'])
return values
}
const cards = [ const cards = [
{ {
fields: idScanElements, fields: customerDataElements.idCardData,
title: 'ID Scan', title: 'ID Scan',
titleIcon: <CardIcon className={classes.cardIcon} />, titleIcon: <CardIcon className={classes.cardIcon} />,
state: R.path(['idCardDataOverride'])(customer), state: R.path(['idCardDataOverride'])(customer),
@ -226,8 +143,8 @@ const CustomerData = ({
editCustomer({ editCustomer({
idCardData: _.merge(idData, formatDates(values)) idCardData: _.merge(idData, formatDates(values))
}), }),
validationSchema: schemas.idScan, validationSchema: customerDataSchemas.idCardData,
initialValues: initialValues.idScan, initialValues: initialValues.idCardData,
isAvailable: !_.isNil(idData) isAvailable: !_.isNil(idData)
}, },
{ {
@ -257,7 +174,7 @@ const CustomerData = ({
isAvailable: !_.isNil(sanctions) isAvailable: !_.isNil(sanctions)
}, },
{ {
fields: frontCameraElements, fields: customerDataElements.frontCamera,
title: 'Front facing camera', title: 'Front facing camera',
titleIcon: <EditIcon className={classes.editIcon} />, titleIcon: <EditIcon className={classes.editIcon} />,
state: R.path(['frontCameraOverride'])(customer), state: R.path(['frontCameraOverride'])(customer),
@ -279,12 +196,12 @@ const CustomerData = ({
/> />
) : null, ) : null,
hasImage: true, hasImage: true,
validationSchema: schemas.frontCamera, validationSchema: customerDataSchemas.frontCamera,
initialValues: initialValues.frontCamera, initialValues: initialValues.frontCamera,
isAvailable: !_.isNil(customer.frontCameraPath) isAvailable: !_.isNil(customer.frontCameraPath)
}, },
{ {
fields: idCardPhotoElements, fields: customerDataElements.idCardPhoto,
title: 'ID card image', title: 'ID card image',
titleIcon: <EditIcon className={classes.editIcon} />, titleIcon: <EditIcon className={classes.editIcon} />,
state: R.path(['idCardPhotoOverride'])(customer), state: R.path(['idCardPhotoOverride'])(customer),
@ -304,27 +221,26 @@ const CustomerData = ({
/> />
) : null, ) : null,
hasImage: true, hasImage: true,
validationSchema: schemas.idCardPhoto, validationSchema: customerDataSchemas.idCardPhoto,
initialValues: initialValues.idCardPhoto, initialValues: initialValues.idCardPhoto,
isAvailable: !_.isNil(customer.idCardPhotoPath) isAvailable: !_.isNil(customer.idCardPhotoPath)
}, },
{ {
fields: usSsnElements, fields: customerDataElements.usSsn,
title: 'US SSN', title: 'US SSN',
titleIcon: <CardIcon className={classes.cardIcon} />, titleIcon: <CardIcon className={classes.cardIcon} />,
state: R.path(['usSsnOverride'])(customer), state: R.path(['usSsnOverride'])(customer),
authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }), authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }), reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }),
save: values => editCustomer({ usSsn: values.usSsn }), save: values => editCustomer(values),
deleteEditedData: () => deleteEditedData({ usSsn: null }), deleteEditedData: () => deleteEditedData({ usSsn: null }),
validationSchema: schemas.usSsn, validationSchema: customerDataSchemas.usSsn,
initialValues: initialValues.usSsn, initialValues: initialValues.usSsn,
isAvailable: !_.isNil(customer.usSsn) isAvailable: !_.isNil(customer.usSsn)
} }
] ]
R.forEach(it => { R.forEach(it => {
console.log('it', it)
customRequirements.push({ customRequirements.push({
fields: [ fields: [
{ {
@ -336,12 +252,13 @@ const CustomerData = ({
], ],
title: it.customInfoRequest.customRequest.name, title: it.customInfoRequest.customRequest.name,
titleIcon: <CardIcon className={classes.cardIcon} />, titleIcon: <CardIcon className={classes.cardIcon} />,
state: R.path(['override'])(it),
authorize: () => authorize: () =>
authorizeCustomRequest({ authorizeCustomRequest({
variables: { variables: {
customerId: it.customerId, customerId: it.customerId,
infoRequestId: it.customInfoRequest.id, infoRequestId: it.customInfoRequest.id,
isAuthorized: true override: OVERRIDE_AUTHORIZED
} }
}), }),
reject: () => reject: () =>
@ -349,7 +266,7 @@ const CustomerData = ({
variables: { variables: {
customerId: it.customerId, customerId: it.customerId,
infoRequestId: it.customInfoRequest.id, infoRequestId: it.customInfoRequest.id,
isAuthorized: false override: OVERRIDE_REJECTED
} }
}), }),
save: values => { save: values => {
@ -374,6 +291,34 @@ const CustomerData = ({
}) })
}, customInfoRequests) }, customInfoRequests)
R.forEach(it => {
customFields.push({
fields: [
{
name: it.label,
label: it.label,
value: it.value ?? '',
component: TextInput
}
],
title: it.label,
titleIcon: <EditIcon className={classes.editIcon} />,
save: values => {
updateCustomEntry({
fieldId: it.id,
value: values[it.label]
})
},
deleteEditedData: () => {},
validationSchema: Yup.object().shape({
[it.label]: Yup.string()
}),
initialValues: {
[it.label]: it.value ?? ''
}
})
}, R.path(['customFields'])(customer) ?? [])
const editableCard = ( const editableCard = (
{ {
title, title,
@ -415,19 +360,24 @@ const CustomerData = ({
<div> <div>
<div className={classes.header}> <div className={classes.header}>
<H3 className={classes.title}>{'Customer data'}</H3> <H3 className={classes.title}>{'Customer data'}</H3>
<FeatureButton {// TODO: Remove false condition for next release
active={!listView} false && (
className={classes.viewIcons} <>
Icon={OverviewIcon} <FeatureButton
InverseIcon={OverviewReversedIcon} active={!listView}
onClick={() => setListView(false)} className={classes.viewIcons}
/> Icon={OverviewIcon}
<FeatureButton InverseIcon={OverviewReversedIcon}
active={listView} onClick={() => setListView(false)}
className={classes.viewIcons} />
Icon={CustomerListViewIcon} <FeatureButton
InverseIcon={CustomerListViewReversedIcon} active={listView}
onClick={() => setListView(true)}></FeatureButton> className={classes.viewIcons}
Icon={CustomerListViewIcon}
InverseIcon={CustomerListViewReversedIcon}
onClick={() => setListView(true)}></FeatureButton>
</>
)}
</div> </div>
<div> <div>
{!listView && customer && ( {!listView && customer && (
@ -444,9 +394,21 @@ const CustomerData = ({
</Grid> </Grid>
</Grid> </Grid>
)} )}
{customEntries && ( {!_.isEmpty(customFields) && (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<span className={classes.separator}>Custom data entry</span> <span className={classes.separator}>Custom data entry</span>
<Grid container>
<Grid container direction="column" item xs={6}>
{customFields.map((elem, idx) => {
return isEven(idx) ? editableCard(elem, idx) : null
})}
</Grid>
<Grid container direction="column" item xs={6}>
{customFields.map((elem, idx) => {
return !isEven(idx) ? editableCard(elem, idx) : null
})}
</Grid>
</Grid>
</div> </div>
)} )}
{!R.isEmpty(customRequirements) && ( {!R.isEmpty(customRequirements) && (

View file

@ -7,6 +7,7 @@ import React, { memo, useState } from 'react'
import { useHistory, useParams } from 'react-router-dom' import { useHistory, useParams } from 'react-router-dom'
import { ActionButton } from 'src/components/buttons' import { ActionButton } from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import { Label1, Label2 } from 'src/components/typography' import { Label1, Label2 } from 'src/components/typography'
import { import {
OVERRIDE_AUTHORIZED, OVERRIDE_AUTHORIZED,
@ -18,8 +19,9 @@ import { ReactComponent as BlockReversedIcon } from 'src/styling/icons/button/bl
import { ReactComponent as BlockIcon } from 'src/styling/icons/button/block/zodiac.svg' import { ReactComponent as BlockIcon } from 'src/styling/icons/button/block/zodiac.svg'
import { ReactComponent as DataReversedIcon } from 'src/styling/icons/button/data/white.svg' import { ReactComponent as DataReversedIcon } from 'src/styling/icons/button/data/white.svg'
import { ReactComponent as DataIcon } from 'src/styling/icons/button/data/zodiac.svg' import { ReactComponent as DataIcon } from 'src/styling/icons/button/data/zodiac.svg'
import { ReactComponent as DiscountReversedIcon } from 'src/styling/icons/button/discount/white.svg' // TODO: Enable for next release
import { ReactComponent as Discount } from 'src/styling/icons/button/discount/zodiac.svg' // import { ReactComponent as DiscountReversedIcon } from 'src/styling/icons/button/discount/white.svg'
// import { ReactComponent as Discount } from 'src/styling/icons/button/discount/zodiac.svg'
import { fromNamespace, namespaces } from 'src/utils/config' import { fromNamespace, namespaces } from 'src/utils/config'
import CustomerData from './CustomerData' import CustomerData from './CustomerData'
@ -66,6 +68,7 @@ const GET_CUSTOMER = gql`
lastTxClass lastTxClass
daysSuspended daysSuspended
isSuspended isSuspended
isTestCustomer
customFields { customFields {
id id
label label
@ -95,7 +98,9 @@ const GET_CUSTOMER = gql`
} }
customInfoRequests { customInfoRequests {
customerId customerId
approved override
overrideBy
overrideAt
customerData customerData
customInfoRequest { customInfoRequest {
id id
@ -180,12 +185,12 @@ const SET_AUTHORIZED_REQUEST = gql`
mutation setAuthorizedCustomRequest( mutation setAuthorizedCustomRequest(
$customerId: ID! $customerId: ID!
$infoRequestId: ID! $infoRequestId: ID!
$isAuthorized: Boolean! $override: String!
) { ) {
setAuthorizedCustomRequest( setAuthorizedCustomRequest(
customerId: $customerId customerId: $customerId
infoRequestId: $infoRequestId infoRequestId: $infoRequestId
isAuthorized: $isAuthorized override: $override
) )
} }
` `
@ -230,12 +235,45 @@ const EDIT_NOTE = gql`
} }
` `
const ENABLE_TEST_CUSTOMER = gql`
mutation enableTestCustomer($customerId: ID!) {
enableTestCustomer(customerId: $customerId)
}
`
const DISABLE_TEST_CUSTOMER = gql`
mutation disableTestCustomer($customerId: ID!) {
disableTestCustomer(customerId: $customerId)
}
`
const GET_DATA = gql` const GET_DATA = gql`
query getData { query getData {
config config
} }
` `
const SET_CUSTOM_ENTRY = gql`
mutation addCustomField($customerId: ID!, $label: String!, $value: String!) {
addCustomField(customerId: $customerId, label: $label, value: $value)
}
`
const EDIT_CUSTOM_ENTRY = gql`
mutation saveCustomField($customerId: ID!, $fieldId: ID!, $value: String!) {
saveCustomField(customerId: $customerId, fieldId: $fieldId, value: $value)
}
`
const GET_ACTIVE_CUSTOM_REQUESTS = gql`
query customInfoRequests($onlyEnabled: Boolean) {
customInfoRequests(onlyEnabled: $onlyEnabled) {
id
customRequest
}
}
`
const CustomerProfile = memo(() => { const CustomerProfile = memo(() => {
const history = useHistory() const history = useHistory()
@ -255,6 +293,20 @@ const CustomerProfile = memo(() => {
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA) const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const { data: activeCustomRequests } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, {
variables: {
onlyEnabled: true
}
})
const [setCustomEntry] = useMutation(SET_CUSTOM_ENTRY, {
onCompleted: () => getCustomer()
})
const [editCustomEntry] = useMutation(EDIT_CUSTOM_ENTRY, {
onCompleted: () => getCustomer()
})
const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, { const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, {
onCompleted: () => getCustomer() onCompleted: () => getCustomer()
}) })
@ -294,6 +346,37 @@ const CustomerProfile = memo(() => {
onCompleted: () => getCustomer() onCompleted: () => getCustomer()
}) })
const saveCustomEntry = it => {
setCustomEntry({
variables: {
customerId,
label: it.title,
value: it.data
}
})
setWizard(null)
}
const updateCustomEntry = it => {
editCustomEntry({
variables: {
customerId,
fieldId: it.fieldId,
value: it.value
}
})
}
const [enableTestCustomer] = useMutation(ENABLE_TEST_CUSTOMER, {
variables: { customerId },
onCompleted: () => getCustomer()
})
const [disableTestCustomer] = useMutation(DISABLE_TEST_CUSTOMER, {
variables: { customerId },
onCompleted: () => getCustomer()
})
const updateCustomer = it => const updateCustomer = it =>
setCustomer({ setCustomer({
variables: { variables: {
@ -302,7 +385,7 @@ const CustomerProfile = memo(() => {
} }
}) })
const replacePhoto = it => const replacePhoto = it => {
replaceCustomerPhoto({ replaceCustomerPhoto({
variables: { variables: {
customerId, customerId,
@ -310,14 +393,18 @@ const CustomerProfile = memo(() => {
photoType: it.photoType photoType: it.photoType
} }
}) })
setWizard(null)
}
const editCustomer = it => const editCustomer = it => {
editCustomerData({ editCustomerData({
variables: { variables: {
customerId, customerId,
customerEdit: it customerEdit: it
} }
}) })
setWizard(null)
}
const deleteEditedData = it => const deleteEditedData = it =>
deleteCustomerEditedData({ deleteCustomerEditedData({
@ -385,6 +472,12 @@ const CustomerProfile = memo(() => {
const timezone = R.path(['config', 'locale_timezone'], configResponse) const timezone = R.path(['config', 'locale_timezone'], configResponse)
const customInfoRequirementOptions =
activeCustomRequests?.customInfoRequests?.map(it => ({
value: it.id,
display: it.customRequest.name
})) ?? []
const classes = useStyles() const classes = useStyles()
return ( return (
@ -411,82 +504,101 @@ const CustomerProfile = memo(() => {
<div className={classes.panels}> <div className={classes.panels}>
<div className={classes.leftSidePanel}> <div className={classes.leftSidePanel}>
{!loading && !customerData.isAnonymous && ( {!loading && !customerData.isAnonymous && (
<div> <>
<CustomerSidebar
isSelected={code => code === clickedItem}
onClick={onClickSidebarItem}
/>
<div> <div>
<CustomerSidebar <Label1 className={classes.actionLabel}>Actions</Label1>
isSelected={code => code === clickedItem} <div className={classes.actionBar}>
onClick={onClickSidebarItem}
/>
</div>
<Label1 className={classes.actionLabel}>Actions</Label1>
<div className={classes.actionBar}>
<ActionButton
className={classes.actionButton}
color="primary"
Icon={DataIcon}
InverseIcon={DataReversedIcon}
onClick={() => setWizard(true)}>
{`Manual data entry`}
</ActionButton>
<ActionButton
className={classes.actionButton}
color="primary"
Icon={Discount}
InverseIcon={DiscountReversedIcon}
onClick={() => {}}>
{`Add individual discount`}
</ActionButton>
{isSuspended && (
<ActionButton <ActionButton
className={classes.actionButton} className={classes.actionButton}
color="primary" color="primary"
Icon={AuthorizeIcon} Icon={DataIcon}
InverseIcon={AuthorizeReversedIcon} InverseIcon={DataReversedIcon}
onClick={() => setWizard(true)}>
{`Manual data entry`}
</ActionButton>
{/* <ActionButton
className={classes.actionButton}
color="primary"
Icon={Discount}
InverseIcon={DiscountReversedIcon}
onClick={() => {}}>
{`Add individual discount`}
</ActionButton> */}
{isSuspended && (
<ActionButton
className={classes.actionButton}
color="primary"
Icon={AuthorizeIcon}
InverseIcon={AuthorizeReversedIcon}
onClick={() =>
updateCustomer({
suspendedUntil: null
})
}>
{`Unsuspend customer`}
</ActionButton>
)}
<ActionButton
color="primary"
className={classes.actionButton}
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
}
onClick={() => onClick={() =>
updateCustomer({ updateCustomer({
suspendedUntil: null authorizedOverride: blocked
? OVERRIDE_AUTHORIZED
: OVERRIDE_REJECTED
}) })
}> }>
{`Unsuspend customer`} {`${blocked ? 'Authorize' : 'Block'} customer`}
</ActionButton> </ActionButton>
)} <ActionButton
<ActionButton color="primary"
color="primary" className={classes.actionButton}
className={classes.actionButton} Icon={blocked ? AuthorizeIcon : BlockIcon}
Icon={blocked ? AuthorizeIcon : BlockIcon} InverseIcon={
InverseIcon={ blocked ? AuthorizeReversedIcon : BlockReversedIcon
blocked ? AuthorizeReversedIcon : BlockReversedIcon }
} onClick={() =>
onClick={() => setCustomer({
updateCustomer({ variables: {
authorizedOverride: blocked customerId,
? OVERRIDE_AUTHORIZED customerInput: {
: OVERRIDE_REJECTED subscriberInfo: true
}) }
}>
{`${blocked ? 'Authorize' : 'Block'} customer`}
</ActionButton>
<ActionButton
color="primary"
className={classes.actionButton}
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
}
onClick={() =>
setCustomer({
variables: {
customerId,
customerInput: {
subscriberInfo: true
} }
} })
}) }>
}> {`Retrieve information`}
{`Retrieve information`} </ActionButton>
</ActionButton> </div>
</div> </div>
</div> <div>
<Label1 className={classes.actionLabel}>
{`Special user status`}
</Label1>
<div className={classes.actionBar}>
<div className={classes.userStatusAction}>
<Switch
checked={!!R.path(['isTestCustomer'])(customerData)}
value={!!R.path(['isTestCustomer'])(customerData)}
onChange={() =>
R.path(['isTestCustomer'])(customerData)
? disableTestCustomer()
: enableTestCustomer()
}
/>
{`Test user`}
</div>
</div>
</div>
</>
)} )}
</div> </div>
<div className={classes.rightSidePanel}> <div className={classes.rightSidePanel}>
@ -522,7 +634,8 @@ const CustomerProfile = memo(() => {
editCustomer={editCustomer} editCustomer={editCustomer}
deleteEditedData={deleteEditedData} deleteEditedData={deleteEditedData}
updateCustomRequest={setCustomerCustomInfoRequest} updateCustomRequest={setCustomerCustomInfoRequest}
authorizeCustomRequest={authorizeCustomRequest}></CustomerData> authorizeCustomRequest={authorizeCustomRequest}
updateCustomEntry={updateCustomEntry}></CustomerData>
</div> </div>
)} )}
{isNotes && ( {isNotes && (
@ -544,8 +657,11 @@ const CustomerProfile = memo(() => {
{wizard && ( {wizard && (
<Wizard <Wizard
error={error?.message} error={error?.message}
save={() => {}} save={saveCustomEntry}
addPhoto={replacePhoto}
addCustomerData={editCustomer}
onClose={() => setWizard(null)} onClose={() => setWizard(null)}
customInfoRequirementOptions={customInfoRequirementOptions}
/> />
)} )}
</div> </div>

View file

@ -1,4 +1,4 @@
import { comet } from 'src/styling/variables' import { comet, subheaderColor } from 'src/styling/variables'
export default { export default {
labelLink: { labelLink: {
@ -34,6 +34,23 @@ export default {
width: 1100 width: 1100
}, },
leftSidePanel: { leftSidePanel: {
width: 300 width: 300,
'& > *': {
marginBottom: 25
},
'& > *:last-child': {
marginBottom: 0
},
'& > *:first-child': {
marginBottom: 50
}
},
userStatusAction: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: subheaderColor,
borderRadius: 8,
padding: [[0, 5]]
} }
} }

View file

@ -1,5 +1,5 @@
import { useQuery } from '@apollo/react-hooks' import { useQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core/styles' import { Box, makeStyles } from '@material-ui/core'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom'
import SearchBox from 'src/components/SearchBox' import SearchBox from 'src/components/SearchBox'
import SearchFilter from 'src/components/SearchFilter' import SearchFilter from 'src/components/SearchFilter'
import { Link } from 'src/components/buttons'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import baseStyles from 'src/pages/Logs.styles' import baseStyles from 'src/pages/Logs.styles'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg' import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
@ -14,6 +15,7 @@ import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-ou
import { fromNamespace, namespaces } from 'src/utils/config' import { fromNamespace, namespaces } from 'src/utils/config'
import CustomersList from './CustomersList' import CustomersList from './CustomersList'
import CreateCustomerModal from './components/CreateCustomerModal'
const GET_CUSTOMER_FILTERS = gql` const GET_CUSTOMER_FILTERS = gql`
query filters { query filters {
@ -43,14 +45,35 @@ const GET_CUSTOMERS = gql`
lastTxFiatCode lastTxFiatCode
lastTxClass lastTxClass
authorizedOverride authorizedOverride
frontCameraPath
frontCameraOverride
idCardPhotoPath
idCardPhotoOverride
idCardData
idCardDataOverride
usSsn
usSsnOverride
sanctions
sanctionsOverride
daysSuspended daysSuspended
isSuspended isSuspended
} }
} }
` `
const CREATE_CUSTOMER = gql`
mutation createCustomer($phoneNumber: String) {
createCustomer(phoneNumber: $phoneNumber) {
phone
}
}
`
const useBaseStyles = makeStyles(baseStyles) const useBaseStyles = makeStyles(baseStyles)
const getFiltersObj = filters =>
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
const Customers = () => { const Customers = () => {
const baseStyles = useBaseStyles() const baseStyles = useBaseStyles()
const history = useHistory() const history = useHistory()
@ -61,6 +84,7 @@ const Customers = () => {
const [filteredCustomers, setFilteredCustomers] = useState([]) const [filteredCustomers, setFilteredCustomers] = useState([])
const [variables, setVariables] = useState({}) const [variables, setVariables] = useState({})
const [filters, setFilters] = useState([]) const [filters, setFilters] = useState([])
const [showCreationModal, setShowCreationModal] = useState(false)
const { const {
data: customersResponse, data: customersResponse,
@ -75,19 +99,25 @@ const Customers = () => {
GET_CUSTOMER_FILTERS GET_CUSTOMER_FILTERS
) )
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
onCompleted: () => setShowCreationModal(false),
refetchQueries: () => [
{
query: GET_CUSTOMERS,
variables
}
]
})
const configData = R.path(['config'])(customersResponse) ?? [] const configData = R.path(['config'])(customersResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData) const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const customersData = R.sortWith([R.descend(R.prop('lastActive'))])( const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData)
filteredCustomers ?? [] const customersData = R.sortWith([
) R.descend(it => new Date(R.prop('lastActive', it) ?? '0'))
])(filteredCustomers ?? [])
const onFilterChange = filters => { const onFilterChange = filters => {
const filtersObject = R.compose( const filtersObject = getFiltersObj(filters)
R.mergeAll,
R.map(f => ({
[f.type]: f.value
}))
)(filters)
setFilters(filters) setFilters(filters)
@ -101,10 +131,38 @@ const Customers = () => {
refetch && refetch() refetch && refetch()
} }
const onFilterDelete = filter => const onFilterDelete = filter => {
setFilters( const newFilters = R.filter(
R.filter(f => !R.whereEq(R.pick(['type', 'value'], f), filter))(filters) f => !R.whereEq(R.pick(['type', 'value'], f), filter)
) )(filters)
setFilters(newFilters)
const filtersObject = getFiltersObj(newFilters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
address: filtersObject.address,
id: filtersObject.id
})
refetch && refetch()
}
const deleteAllFilters = () => {
setFilters([])
const filtersObject = getFiltersObj([])
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
address: filtersObject.address,
id: filtersObject.id
})
refetch && refetch()
}
const filterOptions = R.path(['customerFilters'])(filtersResponse) const filterOptions = R.path(['customerFilters'])(filtersResponse)
@ -113,7 +171,7 @@ const Customers = () => {
<TitleSection <TitleSection
title="Customers" title="Customers"
appendix={ appendix={
<div> <div className={baseStyles.buttonsWrapper}>
<SearchBox <SearchBox
loading={loadingFilters} loading={loadingFilters}
filters={filters} filters={filters}
@ -123,7 +181,13 @@ const Customers = () => {
/> />
</div> </div>
} }
appendixClassName={baseStyles.buttonsWrapper} appendixRight={
<Box display="flex">
<Link color="primary" onClick={() => setShowCreationModal(true)}>
Add new user
</Link>
</Box>
}
labels={[ labels={[
{ label: 'Cash-in', icon: <TxInIcon /> }, { label: 'Cash-in', icon: <TxInIcon /> },
{ label: 'Cash-out', icon: <TxOutIcon /> } { label: 'Cash-out', icon: <TxOutIcon /> }
@ -131,9 +195,10 @@ const Customers = () => {
/> />
{filters.length > 0 && ( {filters.length > 0 && (
<SearchFilter <SearchFilter
entries={customersData.length}
filters={filters} filters={filters}
onFilterDelete={onFilterDelete} onFilterDelete={onFilterDelete}
setFilters={setFilters} deleteAllFilters={deleteAllFilters}
/> />
)} )}
<CustomersList <CustomersList
@ -141,6 +206,13 @@ const Customers = () => {
locale={locale} locale={locale}
onClick={handleCustomerClicked} onClick={handleCustomerClicked}
loading={customerLoading} loading={customerLoading}
triggers={triggers}
/>
<CreateCustomerModal
showModal={showCreationModal}
handleClose={() => setShowCreationModal(false)}
locale={locale}
onSubmit={createNewCustomer}
/> />
</> </>
) )

View file

@ -13,42 +13,42 @@ import { getAuthorizedStatus, getFormattedPhone, getName } from './helper'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const CustomersList = ({ data, locale, onClick, loading }) => { const CustomersList = ({ data, locale, onClick, loading, triggers }) => {
const classes = useStyles() const classes = useStyles()
const elements = [ const elements = [
{ {
header: 'Phone', header: 'Phone',
width: 175, width: 199,
view: it => getFormattedPhone(it.phone, locale.country) view: it => getFormattedPhone(it.phone, locale.country)
}, },
{ {
header: 'Name', header: 'Name',
width: 247, width: 241,
view: getName view: getName
}, },
{ {
header: 'Total TXs', header: 'Total TXs',
width: 130, width: 126,
textAlign: 'right', textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}` view: it => `${Number.parseInt(it.totalTxs)}`
}, },
{ {
header: 'Total spent', header: 'Total spent',
width: 155, width: 152,
textAlign: 'right', textAlign: 'right',
view: it => view: it =>
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}` `${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`
}, },
{ {
header: 'Last active', header: 'Last active',
width: 137, width: 133,
view: it => view: it =>
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? '' (it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? ''
}, },
{ {
header: 'Last transaction', header: 'Last transaction',
width: 165, width: 161,
textAlign: 'right', textAlign: 'right',
view: it => { view: it => {
const hasLastTx = !R.isNil(it.lastTxFiatCode) const hasLastTx = !R.isNil(it.lastTxFiatCode)
@ -66,7 +66,7 @@ const CustomersList = ({ data, locale, onClick, loading }) => {
{ {
header: 'Status', header: 'Status',
width: 191, width: 191,
view: it => <MainStatus statuses={[getAuthorizedStatus(it)]} /> view: it => <MainStatus statuses={[getAuthorizedStatus(it, triggers)]} />
} }
] ]

View file

@ -9,7 +9,14 @@ import Stepper from 'src/components/Stepper'
import { Button } from 'src/components/buttons' import { Button } from 'src/components/buttons'
import { comet } from 'src/styling/variables' import { comet } from 'src/styling/variables'
import { entryType, customElements } from './helper' import {
entryType,
customElements,
requirementElements,
formatDates,
REQUIREMENT,
ID_CARD_DATA
} from './helper'
const LAST_STEP = 2 const LAST_STEP = 2
@ -41,23 +48,40 @@ const styles = {
margin: [[0, 4, 0, 2]], margin: [[0, 4, 0, 2]],
borderBottom: `1px solid ${comet}`, borderBottom: `1px solid ${comet}`,
display: 'inline-block' display: 'inline-block'
},
dropdownField: {
marginTop: 16,
minWidth: 155
} }
} }
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const getStep = (step, selectedValues) => { const getStep = (step, selectedValues) => {
const elements =
selectedValues?.entryType === REQUIREMENT &&
!R.isNil(selectedValues?.requirement)
? requirementElements[selectedValues?.requirement]
: customElements[selectedValues?.dataType]
switch (step) { switch (step) {
case 1: case 1:
return entryType return entryType
case 2: case 2:
return customElements[selectedValues?.dataType] return elements
default: default:
return Fragment return Fragment
} }
} }
const Wizard = ({ onClose, save, error }) => { const Wizard = ({
onClose,
save,
error,
customInfoRequirementOptions,
addCustomerData,
addPhoto
}) => {
const classes = useStyles() const classes = useStyles()
const [selectedValues, setSelectedValues] = useState(null) const [selectedValues, setSelectedValues] = useState(null)
@ -66,6 +90,10 @@ const Wizard = ({ onClose, save, error }) => {
step: 1 step: 1
}) })
const isIdCardData = values => values?.requirement === ID_CARD_DATA
const formatCustomerData = (it, newConfig) =>
isIdCardData(newConfig) ? { [newConfig.requirement]: formatDates(it) } : it
const isLastStep = step === LAST_STEP const isLastStep = step === LAST_STEP
const stepOptions = getStep(step, selectedValues) const stepOptions = getStep(step, selectedValues)
@ -74,7 +102,23 @@ const Wizard = ({ onClose, save, error }) => {
setSelectedValues(newConfig) setSelectedValues(newConfig)
if (isLastStep) { if (isLastStep) {
return save(newConfig) switch (stepOptions.saveType) {
case 'customerData':
return addCustomerData(formatCustomerData(it, newConfig))
case 'customerDataUpload':
return addPhoto({
newPhoto: R.head(R.values(it)),
photoType: R.head(R.keys(it))
})
case 'customEntry':
return save(newConfig)
case 'customInfoRequirement':
return
// case 'customerEntryUpload':
// break
default:
break
}
} }
setState({ setState({
@ -106,6 +150,7 @@ const Wizard = ({ onClose, save, error }) => {
<Form className={classes.form}> <Form className={classes.form}>
<stepOptions.Component <stepOptions.Component
selectedValues={selectedValues} selectedValues={selectedValues}
customInfoRequirementOptions={customInfoRequirementOptions}
{...stepOptions.props} {...stepOptions.props}
/> />
<div className={classes.submit}> <div className={classes.submit}>

View file

@ -0,0 +1,139 @@
import { makeStyles } from '@material-ui/core/styles'
import { Field, Form, Formik } from 'formik'
import { PhoneNumberFormat, PhoneNumberUtil } from 'google-libphonenumber'
import * as R from 'ramda'
import React from 'react'
import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { H1 } from 'src/components/typography'
import { spacer, primaryColor, fontPrimary } from 'src/styling/variables'
const styles = {
modalTitle: {
marginTop: -5,
color: primaryColor,
fontFamily: fontPrimary
},
footer: {
display: 'flex',
flexDirection: 'row',
margin: [['auto', 0, spacer * 3, 0]]
},
form: {
display: 'flex',
flexDirection: 'column',
height: '100%'
},
submit: {
margin: [['auto', 0, 0, 'auto']]
}
}
const pnUtilInstance = PhoneNumberUtil.getInstance()
const getValidationSchema = countryCodes =>
Yup.object().shape({
phoneNumber: Yup.string()
.required('A phone number is required')
.test('is-valid-number', 'That is not a valid phone number', value => {
try {
const validMap = R.map(it => {
const number = pnUtilInstance.parseAndKeepRawInput(value, it)
return pnUtilInstance.isValidNumber(number)
}, countryCodes)
return R.any(it => it === true, validMap)
} catch (e) {}
})
.trim()
})
const formatPhoneNumber = (countryCodes, numberStr) => {
const matchedCountry = R.find(it => {
const number = pnUtilInstance.parseAndKeepRawInput(numberStr, it)
return pnUtilInstance.isValidNumber(number)
}, countryCodes)
const matchedNumber = pnUtilInstance.parseAndKeepRawInput(
numberStr,
matchedCountry
)
return pnUtilInstance.format(matchedNumber, PhoneNumberFormat.E164)
}
const initialValues = {
phoneNumber: ''
}
const useStyles = makeStyles(styles)
const getErrorMsg = (formikErrors, formikTouched) => {
if (!formikErrors || !formikTouched) return null
if (formikErrors.phoneNumber && formikTouched.phoneNumber)
return formikErrors.phoneNumber
return null
}
const CreateCustomerModal = ({ showModal, handleClose, onSubmit, locale }) => {
const classes = useStyles()
const possibleCountries = R.append(
locale?.country,
R.map(it => it.country, locale?.overrides ?? [])
)
return (
<Modal
closeOnBackdropClick={true}
width={600}
height={300}
handleClose={handleClose}
open={showModal}>
<Formik
validationSchema={getValidationSchema(possibleCountries)}
initialValues={initialValues}
validateOnChange={false}
onSubmit={values => {
onSubmit({
variables: {
phoneNumber: formatPhoneNumber(
possibleCountries,
values.phoneNumber
)
}
})
}}>
{({ errors, touched }) => (
<Form id="customer-registration-form" className={classes.form}>
<H1 className={classes.modalTitle}>Create new customer</H1>
<Field
component={TextInput}
name="phoneNumber"
width={338}
autoFocus
label="Phone number"
/>
<div className={classes.footer}>
{getErrorMsg(errors, touched) && (
<ErrorMessage>{getErrorMsg(errors, touched)}</ErrorMessage>
)}
<Button
type="submit"
form="customer-registration-form"
className={classes.submit}>
Finish
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)
}
export default CreateCustomerModal

View file

@ -53,12 +53,12 @@ const SET_AUTHORIZED_REQUEST = gql`
mutation setAuthorizedCustomRequest( mutation setAuthorizedCustomRequest(
$customerId: ID! $customerId: ID!
$infoRequestId: ID! $infoRequestId: ID!
$isAuthorized: Boolean! $override: String!
) { ) {
setAuthorizedCustomRequest( setAuthorizedCustomRequest(
customerId: $customerId customerId: $customerId
infoRequestId: $infoRequestId infoRequestId: $infoRequestId
isAuthorized: $isAuthorized override: $override
) )
} }
` `

View file

@ -11,8 +11,7 @@ export default {
backgroundColor: sidebarColor, backgroundColor: sidebarColor,
width: 219, width: 219,
flexDirection: 'column', flexDirection: 'column',
borderRadius: 5, borderRadius: 5
marginBottom: 50
}, },
link: { link: {
alignItems: 'center', alignItems: 'center',

View file

@ -150,7 +150,7 @@ const EditableCard = ({
<H3 className={classes.cardTitle}>{title}</H3> <H3 className={classes.cardTitle}>{title}</H3>
<Tooltip width={304}></Tooltip> <Tooltip width={304}></Tooltip>
</div> </div>
{state && ( {state && authorize && (
<div className={classnames(label1ClassNames)}> <div className={classnames(label1ClassNames)}>
<MainStatus statuses={[authorized]} /> <MainStatus statuses={[authorized]} />
</div> </div>
@ -207,17 +207,19 @@ const EditableCard = ({
<div className={classes.edit}> <div className={classes.edit}>
{!editing && ( {!editing && (
<div className={classes.editButton}> <div className={classes.editButton}>
<div className={classes.deleteButton}> {// TODO: Remove false condition for next release
<ActionButton false && (
color="primary" <div className={classes.deleteButton}>
type="button" <ActionButton
Icon={DeleteIcon} color="primary"
InverseIcon={DeleteReversedIcon} type="button"
onClick={() => deleteEditedData()}> Icon={DeleteIcon}
{`Delete`} InverseIcon={DeleteReversedIcon}
</ActionButton> onClick={() => deleteEditedData()}>
</div> {`Delete`}
</ActionButton>
</div>
)}
<ActionButton <ActionButton
color="primary" color="primary"
Icon={EditIcon} Icon={EditIcon}
@ -279,7 +281,7 @@ const EditableCard = ({
Cancel Cancel
</ActionButton> </ActionButton>
</div> </div>
{authorized.label !== 'Accepted' && ( {authorize && authorized.label !== 'Accepted' && (
<div className={classes.button}> <div className={classes.button}>
<ActionButton <ActionButton
color="spring" color="spring"
@ -291,7 +293,7 @@ const EditableCard = ({
</ActionButton> </ActionButton>
</div> </div>
)} )}
{authorized.label !== 'Rejected' && ( {authorize && authorized.label !== 'Rejected' && (
<ActionButton <ActionButton
color="tomato" color="tomato"
type="button" type="button"

View file

@ -12,7 +12,6 @@ import { offColor, subheaderColor } from 'src/styling/variables'
const useStyles = makeStyles({ const useStyles = makeStyles({
box: { box: {
boxSizing: 'border-box', boxSizing: 'border-box',
marginTop: 40,
width: 450, width: 450,
height: 120, height: 120,
borderStyle: 'dashed', borderStyle: 'dashed',
@ -32,6 +31,7 @@ const useStyles = makeStyles({
display: 'flex' display: 'flex'
}, },
board: { board: {
marginTop: 40,
width: 450, width: 450,
height: 120 height: 120
}, },
@ -48,12 +48,15 @@ const Upload = ({ type }) => {
const { setFieldValue } = useFormikContext() const { setFieldValue } = useFormikContext()
const IMAGE = 'image' const IMAGE = 'image'
const isImage = type === IMAGE const ID_CARD_PHOTO = 'idCardPhoto'
const FRONT_CAMERA = 'frontCamera'
const isImage =
type === IMAGE || type === FRONT_CAMERA || type === ID_CARD_PHOTO
const onDrop = useCallback( const onDrop = useCallback(
acceptedData => { acceptedData => {
// TODO: attach the uploaded data to the form as well setFieldValue(type, R.head(acceptedData))
setFieldValue(type, R.head(acceptedData).name)
setData({ setData({
preview: isImage preview: isImage
@ -84,12 +87,12 @@ const Upload = ({ type }) => {
</div> </div>
</div> </div>
)} )}
{!R.isEmpty(data) && type === IMAGE && ( {!R.isEmpty(data) && isImage && (
<div key={data.name}> <div key={data.name}>
<img src={data.preview} className={classes.box} alt=""></img> <img src={data.preview} className={classes.box} alt=""></img>
</div> </div>
)} )}
{!R.isEmpty(data) && type !== IMAGE && ( {!R.isEmpty(data) && !isImage && (
<div className={classes.box}> <div className={classes.box}>
<H3 className={classes.uploadContent}>{data.preview}</H3> <H3 className={classes.uploadContent}>{data.preview}</H3>
</div> </div>

View file

@ -1,13 +1,19 @@
import { makeStyles, Box } from '@material-ui/core' import { makeStyles, Box } from '@material-ui/core'
import classnames from 'classnames' import classnames from 'classnames'
import { parse, isValid, format } from 'date-fns/fp'
import { Field, useFormikContext } from 'formik' import { Field, useFormikContext } from 'formik'
import { parsePhoneNumberFromString } from 'libphonenumber-js' import { parsePhoneNumberFromString } from 'libphonenumber-js'
import * as R from 'ramda' import * as R from 'ramda'
import * as Yup from 'yup' import * as Yup from 'yup'
import { RadioGroup, TextInput } from 'src/components/inputs/formik' import {
RadioGroup,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { H4 } from 'src/components/typography' import { H4 } from 'src/components/typography'
import { errorColor } from 'src/styling/variables' import { errorColor } from 'src/styling/variables'
import { MANUAL } from 'src/utils/constants'
import { Upload } from './components' import { Upload } from './components'
@ -34,19 +40,63 @@ const useStyles = makeStyles({
specialGrid: { specialGrid: {
display: 'grid', display: 'grid',
gridTemplateColumns: [[182, 162, 141]] gridTemplateColumns: [[182, 162, 141]]
},
picker: {
width: 150
},
field: {
'& > *:last-child': {
marginBottom: 24
}
} }
}) })
const CUSTOMER_BLOCKED = 'blocked' const CUSTOMER_BLOCKED = 'blocked'
const CUSTOM = 'custom'
const REQUIREMENT = 'requirement'
const ID_CARD_DATA = 'idCardData'
const getAuthorizedStatus = it => const getAuthorizedStatus = (it, triggers) => {
it.authorizedOverride === CUSTOMER_BLOCKED const fields = [
? { label: 'Blocked', type: 'error' } 'frontCameraPath',
: it.isSuspended 'idCardData',
? it.daysSuspended > 0 'idCardPhotoPath',
'usSsn',
'sanctions'
]
const isManualField = fieldName => {
const manualOverrides = R.filter(
ite => R.equals(R.toLower(ite.automation), MANUAL),
triggers?.overrides ?? []
)
return (
!!R.find(ite => R.equals(ite.requirement, fieldName), manualOverrides) ||
R.equals(triggers.automation, MANUAL)
)
}
const pendingFieldStatus = R.map(
ite =>
!R.isNil(it[`${ite}`])
? isManualField(ite)
? R.equals(it[`${ite}Override`], 'automatic')
: false
: false,
fields
)
if (it.authorizedOverride === CUSTOMER_BLOCKED)
return { label: 'Blocked', type: 'error' }
if (it.isSuspended)
return it.daysSuspended > 0
? { label: `${it.daysSuspended} day suspension`, type: 'warning' } ? { label: `${it.daysSuspended} day suspension`, type: 'warning' }
: { label: `< 1 day suspension`, type: 'warning' } : { label: `< 1 day suspension`, type: 'warning' }
: { label: 'Authorized', type: 'success' } if (R.any(ite => ite === true, pendingFieldStatus))
return { label: 'Pending', type: 'warning' }
return { label: 'Authorized', type: 'success' }
}
const getFormattedPhone = (phone, country) => { const getFormattedPhone = (phone, country) => {
const phoneNumber = const phoneNumber =
@ -63,34 +113,46 @@ const getName = it => {
) ?? ''}`.trim() ) ?? ''}`.trim()
} }
// Manual Entry Wizard
const entryOptions = [ const entryOptions = [
{ display: 'Custom entry', code: 'custom' }, { display: 'Custom entry', code: 'custom' },
{ display: 'Populate existing requirement', code: 'requirement' } { display: 'Populate existing requirement', code: 'requirement' }
] ]
const dataOptions = [ const dataOptions = [
{ display: 'Text', code: 'text' }, { display: 'Text', code: 'text' }
{ display: 'File', code: 'file' }, // TODO: Requires backend modifications to support File and Image
{ display: 'Image', code: 'image' } // { display: 'File', code: 'file' },
// { display: 'Image', code: 'image' }
] ]
const requirementOptions = [ const requirementOptions = [
{ display: 'Birthdate', code: 'birthdate' },
{ display: 'ID card image', code: 'idCardPhoto' }, { display: 'ID card image', code: 'idCardPhoto' },
{ display: 'ID data', code: 'idCardData' }, { display: 'ID data', code: 'idCardData' },
{ display: 'Customer camera', code: 'facephoto' }, { display: 'US SSN', code: 'usSsn' },
{ display: 'US SSN', code: 'usSsn' } { display: 'Customer camera', code: 'frontCamera' }
] ]
const customTextOptions = [ const customTextOptions = [
{ display: 'Data entry title', code: 'title' }, { label: 'Data entry title', name: 'title' },
{ display: 'Data entry', code: 'data' } { label: 'Data entry', name: 'data' }
] ]
const customUploadOptions = [{ display: 'Data entry title', code: 'title' }] const customUploadOptions = [{ label: 'Data entry title', name: 'title' }]
const entryTypeSchema = Yup.object().shape({ const entryTypeSchema = Yup.lazy(values => {
entryType: Yup.string().required() if (values.entryType === 'custom') {
return Yup.object().shape({
entryType: Yup.string().required(),
dataType: Yup.string().required()
})
} else if (values.entryType === 'requirement') {
return Yup.object().shape({
entryType: Yup.string().required(),
requirement: Yup.string().required()
})
}
}) })
const customFileSchema = Yup.object().shape({ const customFileSchema = Yup.object().shape({
@ -108,13 +170,18 @@ const customTextSchema = Yup.object().shape({
data: Yup.string().required() data: Yup.string().required()
}) })
const EntryType = () => { const updateRequirementOptions = it => [
{
display: 'Custom information requirement',
code: 'custom'
},
...it
]
const EntryType = ({ customInfoRequirementOptions }) => {
const classes = useStyles() const classes = useStyles()
const { values } = useFormikContext() const { values } = useFormikContext()
const CUSTOM = 'custom'
const REQUIREMENT = 'requirement'
const displayCustomOptions = values.entryType === CUSTOM const displayCustomOptions = values.entryType === CUSTOM
const displayRequirementOptions = values.entryType === REQUIREMENT const displayRequirementOptions = values.entryType === REQUIREMENT
@ -154,7 +221,13 @@ const EntryType = () => {
<Field <Field
component={RadioGroup} component={RadioGroup}
name="requirement" name="requirement"
options={requirementOptions} options={
requirementOptions
// TODO: Enable once custom info requirement manual entry is finished
// !R.isEmpty(customInfoRequirementOptions)
// ? updateRequirementOptions(requirementOptions)
// : requirementOptions
}
labelClassName={classes.label} labelClassName={classes.label}
radioClassName={classes.radio} radioClassName={classes.radio}
className={classnames(classes.radioGroup, classes.specialGrid)} className={classnames(classes.radioGroup, classes.specialGrid)}
@ -165,18 +238,73 @@ const EntryType = () => {
) )
} }
const CustomData = ({ selectedValues }) => { const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => {
const classes = useStyles()
const typeOfEntrySelected = selectedValues?.entryType
const dataTypeSelected = selectedValues?.dataType const dataTypeSelected = selectedValues?.dataType
const upload = dataTypeSelected === 'file' || dataTypeSelected === 'image' const requirementSelected = selectedValues?.requirement
const displayRequirements = typeOfEntrySelected === 'requirement'
const isCustomInfoRequirement = requirementSelected === CUSTOM
const updatedRequirementOptions = !R.isEmpty(customInfoRequirementOptions)
? updateRequirementOptions(requirementOptions)
: requirementOptions
const requirementName = displayRequirements
? R.find(R.propEq('code', requirementSelected))(updatedRequirementOptions)
.display
: ''
const title = displayRequirements
? `Requirement ${requirementName}`
: `Custom ${dataTypeSelected} entry`
const elements = displayRequirements
? requirementElements[requirementSelected]
: customElements[dataTypeSelected]
const upload = displayRequirements
? requirementSelected === 'idCardPhoto' ||
requirementSelected === 'frontCamera'
: dataTypeSelected === 'file' || dataTypeSelected === 'image'
return ( return (
<> <>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<H4>{`Custom ${dataTypeSelected} entry`}</H4> <H4>{title}</H4>
</Box> </Box>
{customElements[dataTypeSelected].options.map(({ display, code }) => ( {isCustomInfoRequirement && (
<Field name={code} label={display} component={TextInput} width={390} /> <Autocomplete
))} fullWidth
{upload && <Upload type={dataTypeSelected}></Upload>} label={`Available requests`}
className={classes.picker}
getOptionSelected={R.eqProps('code')}
labelProp={'display'}
options={customInfoRequirementOptions}
onChange={(evt, it) => {}}
/>
)}
<div className={classes.field}>
{!upload &&
!isCustomInfoRequirement &&
elements.options.map(({ label, name }) => (
<Field
name={name}
label={label}
component={TextInput}
width={390}
/>
))}
</div>
{upload && (
<Upload
type={
displayRequirements ? requirementSelected : dataTypeSelected
}></Upload>
)}
</> </>
) )
} }
@ -185,20 +313,23 @@ const customElements = {
text: { text: {
schema: customTextSchema, schema: customTextSchema,
options: customTextOptions, options: customTextOptions,
Component: CustomData, Component: ManualDataEntry,
initialValues: { data: '', title: '' } initialValues: { data: '', title: '' },
saveType: 'customEntry'
}, },
file: { file: {
schema: customFileSchema, schema: customFileSchema,
options: customUploadOptions, options: customUploadOptions,
Component: CustomData, Component: ManualDataEntry,
initialValues: { file: '', title: '' } initialValues: { file: null, title: '' },
saveType: 'customEntryUpload'
}, },
image: { image: {
schema: customImageSchema, schema: customImageSchema,
options: customUploadOptions, options: customUploadOptions,
Component: CustomData, Component: ManualDataEntry,
initialValues: { image: '', title: '' } initialValues: { image: null, title: '' },
saveType: 'customEntryUpload'
} }
} }
@ -209,6 +340,142 @@ const entryType = {
initialValues: { entryType: '' } initialValues: { entryType: '' }
} }
// Customer data
const customerDataElements = {
idCardData: [
{
name: 'firstName',
label: 'First name',
component: TextInput
},
{
name: 'documentNumber',
label: 'ID number',
component: TextInput
},
{
name: 'dateOfBirth',
label: 'Birthdate',
component: TextInput
},
{
name: 'gender',
label: 'Gender',
component: TextInput
},
{
name: 'lastName',
label: 'Last name',
component: TextInput
},
{
name: 'expirationDate',
label: 'Expiration Date',
component: TextInput
},
{
name: 'country',
label: 'Country',
component: TextInput
}
],
usSsn: [
{
name: 'usSsn',
label: 'US SSN',
component: TextInput,
size: 190
}
],
idCardPhoto: [{ name: 'idCardPhoto' }],
frontCamera: [{ name: 'frontCamera' }]
}
const customerDataSchemas = {
idCardData: Yup.object().shape({
firstName: Yup.string().required(),
lastName: Yup.string().required(),
documentNumber: Yup.string().required(),
dateOfBirth: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required(),
gender: Yup.string().required(),
country: Yup.string().required(),
expirationDate: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required()
}),
usSsn: Yup.object().shape({
usSsn: Yup.string().required()
}),
idCardPhoto: Yup.object().shape({
idCardPhoto: Yup.mixed().required()
}),
frontCamera: Yup.object().shape({
frontCamera: Yup.mixed().required()
})
}
const requirementElements = {
idCardData: {
schema: customerDataSchemas.idCardData,
options: customerDataElements.idCardData,
Component: ManualDataEntry,
initialValues: {
firstName: '',
lastName: '',
documentNumber: '',
dateOfBirth: '',
gender: '',
country: '',
expirationDate: ''
},
saveType: 'customerData'
},
usSsn: {
schema: customerDataSchemas.usSsn,
options: customerDataElements.usSsn,
Component: ManualDataEntry,
initialValues: { usSsn: '' },
saveType: 'customerData'
},
idCardPhoto: {
schema: customerDataSchemas.idCardPhoto,
options: customerDataElements.idCardPhoto,
Component: ManualDataEntry,
initialValues: { idCardPhoto: null },
saveType: 'customerDataUpload'
},
frontCamera: {
schema: customerDataSchemas.frontCamera,
options: customerDataElements.frontCamera,
Component: ManualDataEntry,
initialValues: { frontCamera: null },
saveType: 'customerDataUpload'
},
custom: {
// schema: customerDataSchemas.customInfoRequirement,
Component: ManualDataEntry,
initialValues: { customInfoRequirement: null },
saveType: 'customInfoRequirement'
}
}
const formatDates = values => {
R.map(
elem =>
(values[elem] = format('yyyyMMdd')(
parse(new Date(), 'yyyy-MM-dd', values[elem])
))
)(['dateOfBirth', 'expirationDate'])
return values
}
const mapKeys = pair => { const mapKeys = pair => {
const [key, value] = pair const [key, value] = pair
if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') { if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') {
@ -245,5 +512,12 @@ export {
getName, getName,
entryType, entryType,
customElements, customElements,
formatPhotosData requirementElements,
formatPhotosData,
customerDataElements,
customerDataSchemas,
formatDates,
REQUIREMENT,
CUSTOM,
ID_CARD_DATA
} }

View file

@ -62,7 +62,7 @@ const Graph = ({ data, timeFrame, timezone }) => {
[] []
) )
const filterDay = useMemo( const filterDay = useCallback(
x => (timeFrame === 'day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1), x => (timeFrame === 'day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1),
[timeFrame] [timeFrame]
) )

View file

@ -52,8 +52,8 @@ const ranges = {
} }
const GET_DATA = gql` const GET_DATA = gql`
query getData { query getData($excludeTestingCustomers: Boolean) {
transactions { transactions(excludeTestingCustomers: $excludeTestingCustomers) {
fiatCode fiatCode
fiat fiat
cashInFee cashInFee
@ -78,7 +78,9 @@ const reducer = (acc, it) =>
const SystemPerformance = () => { const SystemPerformance = () => {
const classes = useStyles() const classes = useStyles()
const [selectedRange, setSelectedRange] = useState('Day') const [selectedRange, setSelectedRange] = useState('Day')
const { data, loading } = useQuery(GET_DATA) const { data, loading } = useQuery(GET_DATA, {
variables: { excludeTestingCustomers: true }
})
const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency
const timezone = fromNamespace('locale')(data?.config).timezone const timezone = fromNamespace('locale')(data?.config).timezone

View file

@ -128,17 +128,17 @@ const MachinesTable = ({ machines = [], numToRender }) => {
onClick={() => redirect(machine)} onClick={() => redirect(machine)}
className={classnames(classes.row)} className={classnames(classes.row)}
key={machine.deviceId + idx}> key={machine.deviceId + idx}>
<StyledCell <StyledCell align="left">
align="left" <div className={classes.machineNameWrapper}>
className={classes.machineNameWrapper}> <TL2>{machine.name}</TL2>
<TL2>{machine.name}</TL2> <MachineLinkIcon
<MachineLinkIcon className={classnames(
className={classnames( classes.machineRedirectIcon,
classes.machineRedirectIcon, classes.clickableRow
classes.clickableRow )}
)} onClick={() => redirect(machine)}
onClick={() => redirect(machine)} />
/> </div>
</StyledCell> </StyledCell>
<StyledCell> <StyledCell>
<Status status={machine.statuses[0]} /> <Status status={machine.statuses[0]} />

View file

@ -145,7 +145,7 @@ const Transactions = ({ id }) => {
width: 140 width: 140
}, },
{ {
header: 'Date (UTC)', header: 'Date',
view: it => formatDate(it.created, timezone, 'yyyy-MM-dd'), view: it => formatDate(it.created, timezone, 'yyyy-MM-dd'),
textAlign: 'left', textAlign: 'left',
size: 'sm', size: 'sm',

View file

@ -6,8 +6,8 @@ import NavigateNextIcon from '@material-ui/icons/NavigateNext'
import classnames from 'classnames' import classnames from 'classnames'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React from 'react' import React, { useState } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation, useHistory } from 'react-router-dom'
import { TL1, TL2, Label3 } from 'src/components/typography' import { TL1, TL2, Label3 } from 'src/components/typography'
@ -58,18 +58,42 @@ const GET_INFO = gql`
const getMachineID = path => path.slice(path.lastIndexOf('/') + 1) const getMachineID = path => path.slice(path.lastIndexOf('/') + 1)
const Machines = () => { const MachineRoute = () => {
const location = useLocation() const location = useLocation()
const { data, loading, refetch } = useQuery(GET_INFO, { const history = useHistory()
const id = getMachineID(location.pathname)
const [loading, setLoading] = useState(true)
const { data, refetch } = useQuery(GET_INFO, {
onCompleted: data => {
if (data.machine === null)
return history.push('/maintenance/machine-status')
setLoading(false)
},
variables: { variables: {
deviceId: getMachineID(location.pathname), deviceId: id
billFilters: { },
deviceId: getMachineID(location.pathname), billFilters: {
batch: 'none' deviceId: id,
} batch: 'none'
} }
}) })
const reload = () => {
return history.push(location.pathname)
}
return (
!loading && (
<Machines data={data} refetch={refetch} reload={reload}></Machines>
)
)
}
const Machines = ({ data, refetch, reload }) => {
const classes = useStyles() const classes = useStyles()
const timezone = R.path(['config', 'locale_timezone'], data) ?? {} const timezone = R.path(['config', 'locale_timezone'], data) ?? {}
@ -82,54 +106,52 @@ const Machines = () => {
const machineID = R.path(['deviceId'])(machine) ?? null const machineID = R.path(['deviceId'])(machine) ?? null
return ( return (
!loading && ( <Grid container className={classes.grid}>
<Grid container className={classes.grid}> <Grid item xs={3}>
<Grid item xs={3}> <Grid item xs={12}>
<Grid item xs={12}> <div className={classes.breadcrumbsContainer}>
<div className={classes.breadcrumbsContainer}> <Breadcrumbs separator={<NavigateNextIcon fontSize="small" />}>
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />}> <Link to="/dashboard" className={classes.breadcrumbLink}>
<Link to="/dashboard" className={classes.breadcrumbLink}> <Label3 noMargin className={classes.subtitle}>
<Label3 noMargin className={classes.subtitle}> Dashboard
Dashboard </Label3>
</Label3> </Link>
</Link> <TL2 noMargin className={classes.subtitle}>
<TL2 noMargin className={classes.subtitle}> {machineName}
{machineName} </TL2>
</TL2> </Breadcrumbs>
</Breadcrumbs> <Overview data={machine} onActionSuccess={reload} />
<Overview data={machine} onActionSuccess={refetch} />
</div>
</Grid>
</Grid>
<Grid item xs={9}>
<div className={classes.content}>
<div
className={classnames(classes.detailItem, classes.detailsMargin)}>
<TL1 className={classes.subtitle}>{'Details'}</TL1>
<Details data={machine} timezone={timezone} />
</div>
<div className={classes.detailItem}>
<TL1 className={classes.subtitle}>{'Cash box & cassettes'}</TL1>
<Cassettes
refetchData={refetch}
machine={machine}
config={config ?? false}
bills={bills}
/>
</div>
<div className={classes.transactionsItem}>
<TL1 className={classes.subtitle}>{'Latest transactions'}</TL1>
<Transactions id={machineID} />
</div>
<div className={classes.detailItem}>
<TL1 className={classes.subtitle}>{'Commissions'}</TL1>
<Commissions name={'commissions'} id={machineID} />
</div>
</div> </div>
</Grid> </Grid>
</Grid> </Grid>
) <Grid item xs={9}>
<div className={classes.content}>
<div
className={classnames(classes.detailItem, classes.detailsMargin)}>
<TL1 className={classes.subtitle}>{'Details'}</TL1>
<Details data={machine} timezone={timezone} />
</div>
<div className={classes.detailItem}>
<TL1 className={classes.subtitle}>{'Cash box & cassettes'}</TL1>
<Cassettes
refetchData={refetch}
machine={machine}
config={config ?? false}
bills={bills}
/>
</div>
<div className={classes.transactionsItem}>
<TL1 className={classes.subtitle}>{'Latest transactions'}</TL1>
<Transactions id={machineID} />
</div>
<div className={classes.detailItem}>
<TL1 className={classes.subtitle}>{'Commissions'}</TL1>
<Commissions name={'commissions'} id={machineID} />
</div>
</div>
</Grid>
</Grid>
) )
} }
export default Machines export default MachineRoute

View file

@ -28,6 +28,30 @@ import Wizard from './Wizard/Wizard'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const widthsByNumberOfCassettes = {
2: {
machine: 250,
cashbox: 260,
cassette: 300,
cassetteGraph: 80,
editWidth: 90
},
3: {
machine: 220,
cashbox: 215,
cassette: 225,
cassetteGraph: 60,
editWidth: 90
},
4: {
machine: 190,
cashbox: 180,
cassette: 185,
cassetteGraph: 50,
editWidth: 90
}
}
const ValidationSchema = Yup.object().shape({ const ValidationSchema = Yup.object().shape({
name: Yup.string().required(), name: Yup.string().required(),
cashbox: Yup.number() cashbox: Yup.number()
@ -201,14 +225,14 @@ const CashCassettes = () => {
{ {
name: 'name', name: 'name',
header: 'Machine', header: 'Machine',
width: 184, width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine,
view: name => <>{name}</>, view: name => <>{name}</>,
input: ({ field: { value: name } }) => <>{name}</> input: ({ field: { value: name } }) => <>{name}</>
}, },
{ {
name: 'cashbox', name: 'cashbox',
header: 'Cash box', header: 'Cash box',
width: maxNumberOfCassettes > 2 ? 140 : 280, width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cashbox,
view: (value, { id }) => ( view: (value, { id }) => (
<CashIn <CashIn
currency={{ code: fiatCurrency }} currency={{ code: fiatCurrency }}
@ -229,7 +253,7 @@ const CashCassettes = () => {
elements.push({ elements.push({
name: `cassette${it}`, name: `cassette${it}`,
header: `Cassette ${it}`, header: `Cassette ${it}`,
width: (maxNumberOfCassettes > 2 ? 560 : 650) / maxNumberOfCassettes, width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassette,
stripe: true, stripe: true,
doubleHeader: 'Cash-out', doubleHeader: 'Cash-out',
view: (value, { id }) => ( view: (value, { id }) => (
@ -238,7 +262,9 @@ const CashCassettes = () => {
denomination={getCashoutSettings(id)?.[`cassette${it}`]} denomination={getCashoutSettings(id)?.[`cassette${it}`]}
currency={{ code: fiatCurrency }} currency={{ code: fiatCurrency }}
notes={value} notes={value}
width={50} width={
widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph
}
threshold={ threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`] fillingPercentageSettings[`fillingPercentageCassette${it}`]
} }
@ -248,7 +274,7 @@ const CashCassettes = () => {
input: CashCassetteInput, input: CashCassetteInput,
inputProps: { inputProps: {
decimalPlaces: 0, decimalPlaces: 0,
width: 50, width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph,
inputClassName: classes.cashbox inputClassName: classes.cashbox
} }
}) })
@ -260,7 +286,8 @@ const CashCassettes = () => {
elements.push({ elements.push({
name: 'edit', name: 'edit',
header: 'Edit', header: 'Edit',
width: 87, width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.editWidth,
textAlign: 'center',
view: (value, { id }) => { view: (value, { id }) => {
return ( return (
<IconButton <IconButton
@ -279,12 +306,14 @@ const CashCassettes = () => {
<> <>
<TitleSection <TitleSection
title="Cash Boxes & Cassettes" title="Cash Boxes & Cassettes"
button={{ buttons={[
text: 'Cash box history', {
icon: HistoryIcon, text: 'Cash box history',
inverseIcon: ReverseHistoryIcon, icon: HistoryIcon,
toggle: setShowHistory inverseIcon: ReverseHistoryIcon,
}} toggle: setShowHistory
}
]}
iconClassName={classes.listViewButton} iconClassName={classes.listViewButton}
className={classes.tableWidth}> className={classes.tableWidth}>
{!showHistory && ( {!showHistory && (

View file

@ -28,7 +28,6 @@ const GET_BATCHES = gql`
fiat fiat
deviceId deviceId
created created
cashbox
} }
} }
} }

View file

@ -274,6 +274,7 @@ const WizardStep = ({
placeholder={originalCassetteCount.toString()} placeholder={originalCassetteCount.toString()}
name={cassetteField} name={cassetteField}
className={classes.cashboxBills} className={classes.cashboxBills}
autoFocus
/> />
<P> <P>
{cassetteDenomination} {fiatCurrency} bills loaded {cassetteDenomination} {fiatCurrency} bills loaded

View file

@ -79,7 +79,7 @@ const SessionManagement = () => {
} }
}, },
{ {
header: 'Expiration date (UTC)', header: 'Expiration date',
width: 290, width: 290,
textAlign: 'right', textAlign: 'right',
size: 'sm', size: 'sm',

View file

@ -1,7 +1,7 @@
import { useLazyQuery, useMutation } from '@apollo/react-hooks' import { useLazyQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Box } from '@material-ui/core' import { makeStyles, Box } from '@material-ui/core'
import BigNumber from 'bignumber.js' import BigNumber from 'bignumber.js'
import { add, differenceInYears, format, sub } from 'date-fns/fp' import { add, differenceInYears, format, sub, parse } from 'date-fns/fp'
import FileSaver from 'file-saver' import FileSaver from 'file-saver'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import JSZip from 'jszip' import JSZip from 'jszip'
@ -116,17 +116,27 @@ const DetailsRow = ({ it: tx, timezone }) => {
const exchangeRate = BigNumber(fiat / crypto).toFormat(2) const exchangeRate = BigNumber(fiat / crypto).toFormat(2)
const displayExRate = `1 ${tx.cryptoCode} = ${exchangeRate} ${tx.fiatCode}` const displayExRate = `1 ${tx.cryptoCode} = ${exchangeRate} ${tx.fiatCode}`
const parseDateString = parse(new Date(), 'yyyyMMdd')
const customer = tx.customerIdCardData && { const customer = tx.customerIdCardData && {
name: `${onlyFirstToUpper( name: `${onlyFirstToUpper(
tx.customerIdCardData.firstName tx.customerIdCardData.firstName
)} ${onlyFirstToUpper(tx.customerIdCardData.lastName)}`, )} ${onlyFirstToUpper(tx.customerIdCardData.lastName)}`,
age: differenceInYears(tx.customerIdCardData.dateOfBirth, new Date()), age:
(tx.customerIdCardData.dateOfBirth &&
differenceInYears(
parseDateString(tx.customerIdCardData.dateOfBirth),
new Date()
)) ??
'',
country: tx.customerIdCardData.country, country: tx.customerIdCardData.country,
idCardNumber: tx.customerIdCardData.documentNumber, idCardNumber: tx.customerIdCardData.documentNumber,
idCardExpirationDate: format( idCardExpirationDate:
'dd-MM-yyyy', (tx.customerIdCardData.expirationDate &&
tx.customerIdCardData.expirationDate format('yyyy-MM-dd')(
) parseDateString(tx.customerIdCardData.expirationDate)
)) ??
''
} }
const from = sub({ minutes: MINUTES_OFFSET }, tx.created) const from = sub({ minutes: MINUTES_OFFSET }, tx.created)

View file

@ -40,6 +40,7 @@ const GET_TRANSACTIONS_CSV = gql`
$from: Date $from: Date
$until: Date $until: Date
$timezone: String $timezone: String
$excludeTestingCustomers: Boolean
) { ) {
transactionsCsv( transactionsCsv(
simplified: $simplified simplified: $simplified
@ -47,6 +48,7 @@ const GET_TRANSACTIONS_CSV = gql`
from: $from from: $from
until: $until until: $until
timezone: $timezone timezone: $timezone
excludeTestingCustomers: $excludeTestingCustomers
) )
} }
` `
@ -222,7 +224,7 @@ const Transactions = () => {
width: 140 width: 140
}, },
{ {
header: 'Date (UTC)', header: 'Date',
view: it => view: it =>
timezone && formatDate(it.created, timezone, 'yyyy-MM-dd HH:mm:ss'), timezone && formatDate(it.created, timezone, 'yyyy-MM-dd HH:mm:ss'),
textAlign: 'right', textAlign: 'right',

View file

@ -75,6 +75,7 @@ const TriggerView = ({
error={error?.message} error={error?.message}
save={add} save={add}
onClose={toggleWizard} onClose={toggleWizard}
customInfoRequests={customInfoRequests}
/> />
)} )}
{R.isEmpty(triggers) && ( {R.isEmpty(triggers) && (

View file

@ -48,8 +48,10 @@ const GET_CUSTOM_REQUESTS = gql`
const Triggers = () => { const Triggers = () => {
const classes = useStyles() const classes = useStyles()
const [wizardType, setWizard] = useState(false) const [wizardType, setWizard] = useState(false)
const { data, loading } = useQuery(GET_CONFIG) const { data, loading: configLoading } = useQuery(GET_CONFIG)
const { data: customInfoReqData } = useQuery(GET_CUSTOM_REQUESTS) const { data: customInfoReqData, loading: customInfoLoading } = useQuery(
GET_CUSTOM_REQUESTS
)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [subMenu, setSubMenu] = useState(false) const [subMenu, setSubMenu] = useState(false)
@ -94,6 +96,8 @@ const Triggers = () => {
return setWizard(wizardName) return setWizard(wizardName)
} }
const loading = configLoading || customInfoLoading
return ( return (
<> <>
<TitleSection <TitleSection
@ -178,7 +182,7 @@ const Triggers = () => {
showWizard={wizardType === 'newTrigger'} showWizard={wizardType === 'newTrigger'}
config={data?.config ?? {}} config={data?.config ?? {}}
toggleWizard={toggleWizard('newTrigger')} toggleWizard={toggleWizard('newTrigger')}
customInfoRequests={customInfoRequests} customInfoRequests={enabledCustomInfoRequests}
/> />
)} )}
{!loading && subMenu === 'advancedSettings' && ( {!loading && subMenu === 'advancedSettings' && (

View file

@ -48,14 +48,14 @@ const styles = {
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const getStep = (step, currency) => { const getStep = (step, currency, customInfoRequests) => {
switch (step) { switch (step) {
// case 1: // case 1:
// return txDirection // return txDirection
case 1: case 1:
return type(currency) return type(currency)
case 2: case 2:
return requirements return requirements(customInfoRequests)
default: default:
return Fragment return Fragment
} }
@ -202,7 +202,7 @@ const GetValues = ({ setValues }) => {
return null return null
} }
const Wizard = ({ onClose, save, error, currency }) => { const Wizard = ({ onClose, save, error, currency, customInfoRequests }) => {
const classes = useStyles() const classes = useStyles()
const [liveValues, setLiveValues] = useState({}) const [liveValues, setLiveValues] = useState({})
@ -211,7 +211,7 @@ const Wizard = ({ onClose, save, error, currency }) => {
}) })
const isLastStep = step === LAST_STEP const isLastStep = step === LAST_STEP
const stepOptions = getStep(step, currency) const stepOptions = getStep(step, currency, customInfoRequests)
const onContinue = async it => { const onContinue = async it => {
const newConfig = R.merge(config, stepOptions.schema.cast(it)) const newConfig = R.merge(config, stepOptions.schema.cast(it))
@ -230,12 +230,18 @@ const Wizard = ({ onClose, save, error, currency }) => {
const triggerType = values?.triggerType const triggerType = values?.triggerType
const containsType = R.contains(triggerType) const containsType = R.contains(triggerType)
const isSuspend = values?.requirement?.requirement === 'suspend' const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom'
const hasRequirementError = const hasRequirementError = requirements().hasRequirementError(
!!errors.requirement && errors,
!!touched.requirement?.suspensionDays && touched,
(!values.requirement?.suspensionDays || values
values.requirement?.suspensionDays < 0) )
const hasCustomRequirementError = requirements().hasCustomRequirementError(
errors,
touched,
values
)
const hasAmountError = const hasAmountError =
!!errors.threshold && !!errors.threshold &&
@ -258,7 +264,11 @@ const Wizard = ({ onClose, save, error, currency }) => {
) )
return errors.threshold return errors.threshold
if (isSuspend && hasRequirementError) return errors.requirement if (
(isSuspend && hasRequirementError) ||
(isCustom && hasCustomRequirementError)
)
return errors.requirement
} }
return ( return (

View file

@ -28,13 +28,34 @@ const GET_INFO = gql`
} }
` `
const GET_CUSTOM_REQUESTS = gql`
query customInfoRequests {
customInfoRequests {
id
customRequest
enabled
}
}
`
const AdvancedTriggersSettings = memo(() => { const AdvancedTriggersSettings = memo(() => {
const SCREEN_KEY = namespaces.TRIGGERS const SCREEN_KEY = namespaces.TRIGGERS
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [isEditingDefault, setEditingDefault] = useState(false) const [isEditingDefault, setEditingDefault] = useState(false)
const [isEditingOverrides, setEditingOverrides] = useState(false) const [isEditingOverrides, setEditingOverrides] = useState(false)
const { data } = useQuery(GET_INFO) const { data, loading: configLoading } = useQuery(GET_INFO)
const { data: customInfoReqData, loading: customInfoLoading } = useQuery(
GET_CUSTOM_REQUESTS
)
const customInfoRequests =
R.path(['customInfoRequests'])(customInfoReqData) ?? []
const enabledCustomInfoRequests = R.filter(R.propEq('enabled', true))(
customInfoRequests
)
const loading = configLoading || customInfoLoading
const [saveConfig] = useMutation(SAVE_CONFIG, { const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData'], refetchQueries: () => ['getData'],
@ -67,42 +88,47 @@ const AdvancedTriggersSettings = memo(() => {
const onEditingOverrides = (it, editing) => setEditingOverrides(editing) const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
return ( return (
<> !loading && (
<Section> <>
<EditableTable <Section>
title="Default requirement settings" <EditableTable
error={error?.message} title="Default requirement settings"
titleLg error={error?.message}
name="triggersConfig" titleLg
enableEdit name="triggersConfig"
initialValues={requirementsDefaults} enableEdit
save={saveDefaults} initialValues={requirementsDefaults}
validationSchema={defaultSchema} save={saveDefaults}
data={R.of(requirementsDefaults)} validationSchema={defaultSchema}
elements={getDefaultSettings()} data={R.of(requirementsDefaults)}
setEditing={onEditingDefault} elements={getDefaultSettings()}
forceDisable={isEditingOverrides} setEditing={onEditingDefault}
/> forceDisable={isEditingOverrides}
</Section> />
<Section> </Section>
<EditableTable <Section>
error={error?.message} <EditableTable
title="Overrides" error={error?.message}
titleLg title="Overrides"
name="overrides" titleLg
enableDelete name="overrides"
enableEdit enableDelete
enableCreate enableEdit
initialValues={overridesDefaults} enableCreate
save={saveOverrides} initialValues={overridesDefaults}
validationSchema={getOverridesSchema(requirementsOverrides)} save={saveOverrides}
data={requirementsOverrides} validationSchema={getOverridesSchema(
elements={getOverrides()} requirementsOverrides,
setEditing={onEditingOverrides} enabledCustomInfoRequests
forceDisable={isEditingDefault} )}
/> data={requirementsOverrides}
</Section> elements={getOverrides(enabledCustomInfoRequests)}
</> setEditing={onEditingOverrides}
forceDisable={isEditingDefault}
/>
</Section>
</>
)
) )
}) })

View file

@ -4,18 +4,29 @@ import * as Yup from 'yup'
import Autocomplete from 'src/components/inputs/formik/Autocomplete.js' import Autocomplete from 'src/components/inputs/formik/Autocomplete.js'
import { getView } from 'src/pages/Triggers/helper' import { getView } from 'src/pages/Triggers/helper'
const advancedRequirementOptions = [ const buildAdvancedRequirementOptions = customInfoRequests => {
{ display: 'Sanctions', code: 'sanctions' }, const base = [
{ display: 'ID card image', code: 'idCardPhoto' }, { display: 'Sanctions', code: 'sanctions' },
{ display: 'ID data', code: 'idCardData' }, { display: 'ID card image', code: 'idCardPhoto' },
{ display: 'Customer camera', code: 'facephoto' }, { display: 'ID data', code: 'idCardData' },
{ display: 'US SSN', code: 'usSsn' } { display: 'Customer camera', code: 'facephoto' },
] { display: 'US SSN', code: 'usSsn' }
]
const displayRequirement = code => { const custom = R.map(it => ({
display: it.customRequest.name,
code: it.id
}))(customInfoRequests)
return R.concat(base, custom)
}
const displayRequirement = (code, customInfoRequests) => {
return R.prop( return R.prop(
'display', 'display',
R.find(R.propEq('code', code))(advancedRequirementOptions) R.find(R.propEq('code', code))(
buildAdvancedRequirementOptions(customInfoRequests)
)
) )
} }
@ -29,7 +40,7 @@ const defaultSchema = Yup.object().shape({
.required() .required()
}) })
const getOverridesSchema = values => { const getOverridesSchema = (values, customInfoRequests) => {
return Yup.object().shape({ return Yup.object().shape({
id: Yup.string() id: Yup.string()
.label('Requirement') .label('Requirement')
@ -40,7 +51,8 @@ const getOverridesSchema = values => {
if (R.find(R.propEq('requirement', requirement))(values)) { if (R.find(R.propEq('requirement', requirement))(values)) {
return this.createError({ return this.createError({
message: `Requirement ${displayRequirement( message: `Requirement ${displayRequirement(
requirement requirement,
customInfoRequests
)} already overriden` )} already overriden`
}) })
} }
@ -84,17 +96,20 @@ const getDefaultSettings = () => {
] ]
} }
const getOverrides = () => { const getOverrides = customInfoRequests => {
return [ return [
{ {
name: 'requirement', name: 'requirement',
header: 'Requirement', header: 'Requirement',
width: 196, width: 196,
size: 'sm', size: 'sm',
view: getView(advancedRequirementOptions, 'display'), view: getView(
buildAdvancedRequirementOptions(customInfoRequests),
'display'
),
input: Autocomplete, input: Autocomplete,
inputProps: { inputProps: {
options: advancedRequirementOptions, options: buildAdvancedRequirementOptions(customInfoRequests),
labelProp: 'display', labelProp: 'display',
valueProp: 'code' valueProp: 'code'
} }

View file

@ -1,8 +1,6 @@
import { useQuery } from '@apollo/react-hooks'
import { makeStyles, Box } from '@material-ui/core' import { makeStyles, Box } from '@material-ui/core'
import classnames from 'classnames' import classnames from 'classnames'
import { Field, useFormikContext } from 'formik' import { Field, useFormikContext } from 'formik'
import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { memo } from 'react' import React, { memo } from 'react'
import * as Yup from 'yup' import * as Yup from 'yup'
@ -479,21 +477,43 @@ const requirementSchema = Yup.object()
otherwise: Yup.number() otherwise: Yup.number()
.nullable() .nullable()
.transform(() => null) .transform(() => null)
}),
customInfoRequestId: Yup.string().when('requirement', {
is: value => value === 'custom',
then: Yup.string(),
otherwise: Yup.string()
.nullable()
.transform(() => '')
}) })
}).required() }).required()
}) })
.test(({ requirement }, context) => { .test(({ requirement }, context) => {
const requirementValidator = requirement => const requirementValidator = (requirement, type) => {
requirement.requirement === 'suspend' switch (type) {
? requirement.suspensionDays > 0 case 'suspend':
: true return requirement.requirement === type
? requirement.suspensionDays > 0
: true
case 'custom':
return requirement.requirement === type
? !R.isNil(requirement.customInfoRequestId)
: true
default:
return true
}
}
if (requirement && requirementValidator(requirement)) return if (requirement && !requirementValidator(requirement, 'suspend'))
return context.createError({
path: 'requirement',
message: 'Suspension days must be greater than 0'
})
return context.createError({ if (requirement && !requirementValidator(requirement, 'custom'))
path: 'requirement', return context.createError({
message: 'Suspension days must be greater than 0' path: 'requirement',
}) message: 'You must select an item'
})
}) })
const requirementOptions = [ const requirementOptions = [
@ -508,16 +528,19 @@ const requirementOptions = [
{ display: 'Block', code: 'block' } { display: 'Block', code: 'block' }
] ]
const GET_ACTIVE_CUSTOM_REQUESTS = gql` const hasRequirementError = (errors, touched, values) =>
query customInfoRequests($onlyEnabled: Boolean) { !!errors.requirement &&
customInfoRequests(onlyEnabled: $onlyEnabled) { !!touched.requirement?.suspensionDays &&
id (!values.requirement?.suspensionDays ||
customRequest values.requirement?.suspensionDays < 0)
}
}
`
const Requirement = () => { const hasCustomRequirementError = (errors, touched, values) =>
!!errors.requirement &&
!!touched.requirement?.customInfoRequestId &&
(!values.requirement?.customInfoRequestId ||
!R.isNil(values.requirement?.customInfoRequestId))
const Requirement = ({ customInfoRequests }) => {
const classes = useStyles() const classes = useStyles()
const { const {
touched, touched,
@ -526,11 +549,6 @@ const Requirement = () => {
handleChange, handleChange,
setTouched setTouched
} = useFormikContext() } = useFormikContext()
const { data } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, {
variables: {
onlyEnabled: true
}
})
const isSuspend = values?.requirement?.requirement === 'suspend' const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom' const isCustom = values?.requirement?.requirement === 'custom'
@ -540,24 +558,19 @@ const Requirement = () => {
display: it.customRequest.name display: it.customRequest.name
})) }))
const hasRequirementError = const enableCustomRequirement = customInfoRequests?.length > 0
!!errors.requirement &&
!!touched.requirement?.suspensionDays &&
(!values.requirement?.suspensionDays ||
values.requirement?.suspensionDays < 0)
const customInfoRequests = R.path(['customInfoRequests'])(data) ?? []
const enableCustomRequirement = customInfoRequests.length > 0
const customInfoOption = { const customInfoOption = {
display: 'Custom information requirement', display: 'Custom information requirement',
code: 'custom' code: 'custom'
} }
const options = enableCustomRequirement const options = enableCustomRequirement
? [...requirementOptions, customInfoOption] ? [...requirementOptions, customInfoOption]
: [...requirementOptions, { ...customInfoOption, disabled: true }] : [...requirementOptions]
const titleClass = { const titleClass = {
[classes.error]: [classes.error]:
(!!errors.requirement && !isSuspend) || (isSuspend && hasRequirementError) (!!errors.requirement && !isSuspend && !isCustom) ||
(isSuspend && hasRequirementError(errors, touched, values)) ||
(isCustom && hasCustomRequirementError(errors, touched, values))
} }
return ( return (
@ -586,7 +599,7 @@ const Requirement = () => {
label="Days" label="Days"
size="lg" size="lg"
name="requirement.suspensionDays" name="requirement.suspensionDays"
error={hasRequirementError} error={hasRequirementError(errors, touched, values)}
/> />
)} )}
{isCustom && ( {isCustom && (
@ -604,10 +617,13 @@ const Requirement = () => {
) )
} }
const requirements = { const requirements = customInfoRequests => ({
schema: requirementSchema, schema: requirementSchema,
options: requirementOptions, options: requirementOptions,
Component: Requirement, Component: Requirement,
props: { customInfoRequests },
hasRequirementError: hasRequirementError,
hasCustomRequirementError: hasCustomRequirementError,
initialValues: { initialValues: {
requirement: { requirement: {
requirement: '', requirement: '',
@ -615,7 +631,7 @@ const requirements = {
customInfoRequestId: '' customInfoRequestId: ''
} }
} }
} })
const getView = (data, code, compare) => it => { const getView = (data, code, compare) => it => {
if (!data) return '' if (!data) return ''

View file

@ -140,12 +140,14 @@ const Wallet = ({ name: SCREEN_KEY }) => {
<div className={classes.header}> <div className={classes.header}>
<TitleSection <TitleSection
title="Wallet Settings" title="Wallet Settings"
button={{ buttons={[
text: 'Advanced settings', {
icon: SettingsIcon, text: 'Advanced settings',
inverseIcon: ReverseSettingsIcon, icon: SettingsIcon,
toggle: setAdvancedSettings inverseIcon: ReverseSettingsIcon,
}} toggle: setAdvancedSettings
}
]}
/> />
<Box alignItems="center" justifyContent="end"> <Box alignItems="center" justifyContent="end">
<Label1 className={classes.feeDiscountLabel}>Fee discount</Label1> <Label1 className={classes.feeDiscountLabel}>Fee discount</Label1>

View file

@ -130,7 +130,7 @@ const getLamassuRoutes = () => [
}, },
{ {
key: 'services', key: 'services',
label: '3rd party services', label: '3rd Party Services',
route: '/settings/3rd-party-services', route: '/settings/3rd-party-services',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Services component: Services

View file

@ -132,7 +132,7 @@ const getPazuzRoutes = () => [
}, },
{ {
key: 'services', key: 'services',
label: '3rd party services', label: '3rd Party Services',
route: '/settings/3rd-party-services', route: '/settings/3rd-party-services',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Services component: Services

View file

@ -28,7 +28,10 @@ export default {
pointerEvents: 'none' pointerEvents: 'none'
}, },
html: { html: {
height: fill height: fill,
'@media screen and (max-height: 900px)': {
scrollbarGutter: 'stable'
}
}, },
body: { body: {
width: mainWidth, width: mainWidth,

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="32px" height="32px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="32px" height="32px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="↳-notification-center" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"> <g id="↳-notification-center" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="notification-center_v01a#2-(open)" transform="translate(-1023.000000, -459.000000)" stroke="#1B2559"> <g id="notification-center_v01a#2-(open)" transform="translate(-1023.000000, -459.000000)" stroke="#1B2559">
<g id="Group-5" transform="translate(1000.000000, 0.000000)"> <g id="Group-5" transform="translate(1000.000000, 0.000000)">

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After