diff --git a/bin/lamassu-register b/bin/lamassu-register index c24a59b6..7d5284bc 100755 --- a/bin/lamassu-register +++ b/bin/lamassu-register @@ -2,6 +2,7 @@ const { asyncLocalStorage, defaultStore } = require('../lib/async-storage') const userManagement = require('../lib/new-admin/graphql/modules/userManagement') +const authErrors = require('../lib/new-admin/graphql/errors/authentication') const options = require('../lib/options') const name = process.argv[2] @@ -14,29 +15,25 @@ if (!domain) { } if (!name || !role) { - console.log('Usage: lamassu-register ') + console.log('Usage: lamassu-register ') + console.log(' must be \'user\' or \'superuser\'') 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,}))$/ if (!emailRegex.test(name)) { - console.log('Usage: should be in an email format') + console.log('Usage: must be in an email format') process.exit(2) } if (role !== 'user' && role !== 'superuser') { - console.log('Usage: has two possible values: user | superuser') + console.log('Usage: must be \'user\' or \'superuser\'') process.exit(2) } asyncLocalStorage.run(defaultStore(), () => { userManagement.createRegisterToken(name, role).then(token => { - if (!token) { - console.log(`A user named ${name} already exists!`) - process.exit(2) - } - if (domain === 'localhost') { console.log(`https://${domain}:3001/register?t=${token.token}`) } else { @@ -45,6 +42,12 @@ asyncLocalStorage.run(defaultStore(), () => { process.exit(0) }).catch(err => { + + if (err instanceof authErrors.UserAlreadyExistsError){ + console.log(`A user with email ${name} already exists!`) + process.exit(2) + } + console.log('Error: %s', err) process.exit(3) }) diff --git a/lib/bill-math.js b/lib/bill-math.js index d4801c0f..fbb3ee99 100644 --- a/lib/bill-math.js +++ b/lib/bill-math.js @@ -73,8 +73,9 @@ function unmergeCassettes(cassettes, output) { } function makeChangeDuo(cassettes, amount) { - const small = cassettes[0] - const large = cassettes[1] + // Initialize empty cassettes in case of undefined, due to same denomination across all cassettes results in a single merged cassette + const small = cassettes[0] ?? { denomination: 0, count: 0 } + const large = cassettes[1] ?? { denomination: 0, count: 0 } const largeDenom = large.denomination const smallDenom = small.denomination diff --git a/lib/blockchain/common.js b/lib/blockchain/common.js index 273515e2..4bcd7a93 100644 --- a/lib/blockchain/common.js +++ b/lib/blockchain/common.js @@ -23,18 +23,18 @@ module.exports = { const BINARIES = { BTC: { - defaultUrl: 'https://bitcoincore.org/bin/bitcoin-core-0.20.0/bitcoin-0.20.0-x86_64-linux-gnu.tar.gz', - defaultDir: 'bitcoin-0.20.0/bin', + defaultUrl: 'https://bitcoincore.org/bin/bitcoin-core-0.20.1/bitcoin-0.20.1-x86_64-linux-gnu.tar.gz', + defaultDir: 'bitcoin-0.20.1/bin', url: 'https://bitcoincore.org/bin/bitcoin-core-22.0/bitcoin-22.0-x86_64-linux-gnu.tar.gz', dir: 'bitcoin-22.0/bin' }, ETH: { - url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.13-7a0c19f8.tar.gz', - dir: 'geth-linux-amd64-1.10.13-7a0c19f8' + url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.15-8be800ff.tar.gz', + dir: 'geth-linux-amd64-1.10.15-8be800ff' }, ZEC: { - url: 'https://z.cash/downloads/zcash-4.5.1-1-linux64-debian-stretch.tar.gz', - dir: 'zcash-4.5.1-1/bin' + url: 'https://z.cash/downloads/zcash-4.6.0-1-linux64-debian-stretch.tar.gz', + dir: 'zcash-4.6.0-1/bin' }, DASH: { 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']] }, XMR: { - url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.2.0.tar.bz2', - dir: 'monero-x86_64-linux-gnu-v0.17.2.0', + url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.3.0.tar.bz2', + dir: 'monero-x86_64-linux-gnu-v0.17.3.0', files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']] } } diff --git a/lib/blockchain/install.js b/lib/blockchain/install.js index efc48107..717c9945 100644 --- a/lib/blockchain/install.js +++ b/lib/blockchain/install.js @@ -117,8 +117,9 @@ function run () { _.filter(c => c.type !== 'erc-20'), _.map(c => { const checked = isInstalledSoftware(c) && isInstalledVolume(c) + const name = c.code === 'ethereum' ? 'Ethereum and/or USDT' : c.display return { - name: c.display, + name, value: c.code, checked, disabled: checked && 'Installed' diff --git a/lib/customers.js b/lib/customers.js index e96155f9..cf6f30fb 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -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, 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, - sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat, - fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes + 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, is_test_customer FROM ( SELECT c.id, c.authorized_override, 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.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.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, 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 FROM customers c LEFT OUTER JOIN ( 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 FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id 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, 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, - sanctions_override, total_txs, total_spent, created 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 + sanctions_override, total_txs, total_spent, LEAST(created, last_transaction) AS last_active, fiat AS last_tx_fiat, + fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes, is_test_customer FROM ( SELECT c.id, c.authorized_override, 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.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.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, 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 FROM customers c LEFT OUTER JOIN ( 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 FROM cash_out_txs WHERE confirmed_at IS NOT NULL) t ON c.id = t.customer_id LEFT OUTER JOIN ( @@ -993,6 +993,7 @@ function addCustomField (customerId, label, value) { } }) ) + .then(res => !_.isNil(res)) } 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)) } +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 = { add, get, @@ -1040,5 +1051,7 @@ module.exports = { edit, deleteEditedData, updateEditedPhoto, - updateTxCustomerPhoto + updateTxCustomerPhoto, + enableTestCustomer, + disableTestCustomer } diff --git a/lib/machine-loader.js b/lib/machine-loader.js index 8af4cd35..6818676f 100644 --- a/lib/machine-loader.js +++ b/lib/machine-loader.js @@ -12,30 +12,35 @@ const configManager = require('./new-config-manager') const settingsLoader = require('./new-settings-loader') const notifierUtils = require('./notifier/utils') const notifierQueries = require('./notifier/queries') +const { ApolloError } = require('apollo-server-errors'); const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' } const unresponsiveStatus = { label: 'Unresponsive', 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 () { return db.any('SELECT * FROM devices WHERE display=TRUE ORDER BY created') - .then(rr => rr.map(r => ({ - 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 - }))) + .then(rr => rr.map(toMachineObject)) } function getConfig (defaultConfig) { @@ -100,21 +105,10 @@ function getMachineName (machineId) { function getMachine (machineId, config) { const sql = 'SELECT * FROM devices WHERE device_id=$1' - const queryMachine = db.oneOrNone(sql, [machineId]).then(r => ({ - 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 - })) + const queryMachine = db.oneOrNone(sql, [machineId]).then(r => { + if (r === null) throw new ApolloError('Resource doesn\'t exist', 'NOT_FOUND') + else return toMachineObject(r) + }) return Promise.all([queryMachine, dbm.machineEvents(), config]) .then(([machine, events, config]) => { diff --git a/lib/new-admin/graphql/modules/userManagement.js b/lib/new-admin/graphql/modules/userManagement.js index 13c42e70..362e150f 100644 --- a/lib/new-admin/graphql/modules/userManagement.js +++ b/lib/new-admin/graphql/modules/userManagement.js @@ -240,7 +240,7 @@ const reset2FA = (token, userID, code, 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') return context.req.session.user.id diff --git a/lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js b/lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js index 897b415c..fefdcf6b 100644 --- a/lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js +++ b/lib/new-admin/graphql/resolvers/customInfoRequests.resolver.js @@ -1,3 +1,4 @@ +const authentication = require('../modules/userManagement') const queries = require('../../services/customInfoRequests') const DataLoader = require('dataloader') @@ -21,7 +22,10 @@ const resolvers = { insertCustomInfoRequest: (...[, { customRequest }]) => queries.addCustomInfoRequest(customRequest), removeCustomInfoRequest: (...[, { id }]) => queries.removeCustomInfoRequest(id), 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) } } diff --git a/lib/new-admin/graphql/resolvers/customer.resolver.js b/lib/new-admin/graphql/resolvers/customer.resolver.js index d3563669..19b54f79 100644 --- a/lib/new-admin/graphql/resolvers/customer.resolver.js +++ b/lib/new-admin/graphql/resolvers/customer.resolver.js @@ -21,7 +21,7 @@ const resolvers = { return customers.updateCustomer(customerId, customerInput, token) }, 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), editCustomer: async (root, { customerId, customerEdit }, context) => { const token = authentication.getToken(context) @@ -49,7 +49,12 @@ const resolvers = { }, deleteCustomerNote: (...[, { noteId }]) => { return customerNotes.deleteCustomerNote(noteId) - } + }, + createCustomer: (...[, { phoneNumber }]) => customers.add({ phone: phoneNumber }), + enableTestCustomer: (...[, { customerId }]) => + customers.enableTestCustomer(customerId), + disableTestCustomer: (...[, { customerId }]) => + customers.disableTestCustomer(customerId) } } diff --git a/lib/new-admin/graphql/resolvers/transaction.resolver.js b/lib/new-admin/graphql/resolvers/transaction.resolver.js index 3dc5a399..96bb406e 100644 --- a/lib/new-admin/graphql/resolvers/transaction.resolver.js +++ b/lib/new-admin/graphql/resolvers/transaction.resolver.js @@ -30,10 +30,10 @@ const resolvers = { isAnonymous: parent => (parent.customerId === anonymous.uuid) }, Query: { - transactions: (...[, { 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), - transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, timezone, simplified }]) => - transactions.batch(from, until, limit, offset, null, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, simplified) + 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, excludeTestingCustomers), + 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, excludeTestingCustomers, simplified) .then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime']), { fields: txLogFields })), transactionCsv: (...[, { id, txClass, timezone }]) => transactions.getTx(id, txClass).then(data => diff --git a/lib/new-admin/graphql/types/customInfoRequests.type.js b/lib/new-admin/graphql/types/customInfoRequests.type.js index 5a9ed909..917c9f6f 100644 --- a/lib/new-admin/graphql/types/customInfoRequests.type.js +++ b/lib/new-admin/graphql/types/customInfoRequests.type.js @@ -32,7 +32,9 @@ const typeDef = gql` type CustomRequestData { customerId: ID infoRequestId: ID - approved: Boolean + override: String + overrideAt: Date + overrideBy: ID customerData: JSON customInfoRequest: CustomInfoRequest } @@ -47,7 +49,7 @@ const typeDef = gql` insertCustomInfoRequest(customRequest: CustomRequestInput!): CustomInfoRequest @auth removeCustomInfoRequest(id: ID!): 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 } ` diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index bdbf3a94..bf099647 100644 --- a/lib/new-admin/graphql/types/customer.type.js +++ b/lib/new-admin/graphql/types/customer.type.js @@ -1,12 +1,6 @@ const { gql } = require('apollo-server-express') const typeDef = gql` - type CustomerCustomField { - id: ID - label: String - value: String - } - type Customer { id: ID! authorizedOverride: String @@ -42,6 +36,7 @@ const typeDef = gql` customFields: [CustomerCustomField] customInfoRequests: [CustomRequestData] notes: [CustomerNote] + isTestCustomer: Boolean } input CustomerInput { @@ -86,6 +81,12 @@ const typeDef = gql` content: String } + type CustomerCustomField { + id: ID + label: String + value: String + } + type Query { customers(phone: String, name: String, address: String, id: String): [Customer] @auth customer(customerId: ID!): Customer @auth @@ -94,15 +95,18 @@ const typeDef = gql` type Mutation { setCustomer(customerId: ID!, customerInput: CustomerInput): Customer @auth - addCustomField(customerId: ID!, label: String!, value: String!): CustomerCustomField @auth - saveCustomField(customerId: ID!, fieldId: ID!, value: String!): CustomerCustomField @auth - removeCustomField(customerId: ID!, fieldId: ID!): CustomerCustomField @auth + addCustomField(customerId: ID!, label: String!, value: String!): Boolean @auth + saveCustomField(customerId: ID!, fieldId: ID!, value: String!): Boolean @auth + removeCustomField(customerId: ID!, fieldId: ID!): Boolean @auth editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth deleteCustomerNote(noteId: ID!): Boolean @auth + createCustomer(phoneNumber: String): Customer @auth + enableTestCustomer(customerId: ID!): Boolean @auth + disableTestCustomer(customerId: ID!): Boolean @auth } ` diff --git a/lib/new-admin/graphql/types/transaction.type.js b/lib/new-admin/graphql/types/transaction.type.js index c121d0bd..a0212e91 100644 --- a/lib/new-admin/graphql/types/transaction.type.js +++ b/lib/new-admin/graphql/types/transaction.type.js @@ -55,8 +55,8 @@ const typeDef = gql` } 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 - 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 + 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, excludeTestingCustomers: Boolean, simplified: Boolean): String @auth transactionCsv(id: ID, txClass: String, timezone: String): String @auth txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth transactionFilters: [Filter] @auth diff --git a/lib/new-admin/services/customInfoRequests.js b/lib/new-admin/services/customInfoRequests.js index c19c112a..7c703443 100644 --- a/lib/new-admin/services/customInfoRequests.js +++ b/lib/new-admin/services/customInfoRequests.js @@ -35,8 +35,10 @@ const getAllCustomInfoRequestsForCustomer = (customerId) => { return db.any(sql, [customerId]).then(res => res.map(item => ({ customerId: item.customer_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 { customerId: item.customer_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 => ({ customerId: item.customer_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 sql = `UPDATE customers_custom_info_requests SET approved = $1 WHERE customer_id = $2 AND info_request_id = $3` - return db.none(sql, [isAuthorized, customerId, infoRequestId]).then(() => true) +const setAuthorizedCustomRequest = (customerId, infoRequestId, override, token) => { + 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, [override, token, customerId, infoRequestId]).then(() => true) } 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) VALUES ($1, $2, $3) 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]) } diff --git a/lib/new-admin/services/transactions.js b/lib/new-admin/services/transactions.js index 494630f9..e420355a 100644 --- a/lib/new-admin/services/transactions.js +++ b/lib/new-admin/services/transactions.js @@ -39,6 +39,7 @@ function batch ( cryptoCode = null, toAddress = null, status = null, + excludeTestingCustomers = false, simplified = false ) { 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 ($12 is null or txs.to_address = $12) AND ($13 is null or txs.txStatus = $13) + ${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``} AND (fiat > 0) ORDER BY created DESC limit $4 offset $5` @@ -98,6 +100,7 @@ function batch ( AND ($11 is null or txs.crypto_code = $11) AND ($12 is null or txs.to_address = $12) AND ($13 is null or txs.txStatus = $13) + ${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``} AND (fiat > 0) ORDER BY created DESC limit $4 offset $5` diff --git a/lib/new-config-manager.js b/lib/new-config-manager.js index 531186ea..0e721736 100644 --- a/lib/new-config-manager.js +++ b/lib/new-config-manager.js @@ -1,4 +1,5 @@ const _ = require('lodash/fp') +const { getCustomInfoRequests } = require('./new-admin/services/customInfoRequests') const namespaces = { WALLETS: 'wallets', @@ -107,22 +108,29 @@ const getGlobalNotifications = config => getNotifications(null, null, config) const getTriggers = _.get('triggers') const getTriggersAutomation = config => { - const defaultAutomation = _.get('triggersConfig_automation')(config) - const requirements = { - sanctions: defaultAutomation, - idCardPhoto: defaultAutomation, - idCardData: defaultAutomation, - facephoto: defaultAutomation, - usSsn: defaultAutomation - } + return getCustomInfoRequests(true) + .then(infoRequests => { + const defaultAutomation = _.get('triggersConfig_automation')(config) + const requirements = { + sanctions: defaultAutomation, + idCardPhoto: 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) => { - return _.assign(acc, { [override.requirement]: override.automation }) - }, {}, overrides) + const overrides = _.get('triggersConfig_overrides')(config) - 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('_')) diff --git a/lib/routes/customerRoutes.js b/lib/routes/customerRoutes.js index de4e356d..8b0e51d6 100644 --- a/lib/routes/customerRoutes.js +++ b/lib/routes/customerRoutes.js @@ -20,8 +20,13 @@ const machineLoader = require('../machine-loader') const { loadLatestConfig } = require('../new-settings-loader') const customInfoRequestQueries = require('../new-admin/services/customInfoRequests') -function updateCustomerCustomInfoRequest (customerId, dataToSave, req, res) { - return customInfoRequestQueries.setCustomerData(customerId, dataToSave.info_request_id, dataToSave) +function updateCustomerCustomInfoRequest (customerId, patch, req, res) { + 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(customer => respond(req, res, { customer })) } @@ -35,7 +40,7 @@ function updateCustomer (req, res, next) { const compatTriggers = complianceTriggers.getBackwardsCompatibleTriggers(triggers) if (patch.customRequestPatch) { - return updateCustomerCustomInfoRequest(id, patch.dataToSave, req, res).catch(next) + return updateCustomerCustomInfoRequest(id, patch.customRequestPatch, req, res).catch(next) } customers.getById(id) diff --git a/lib/routes/pollingRoutes.js b/lib/routes/pollingRoutes.js index ad7d0118..c4568233 100644 --- a/lib/routes/pollingRoutes.js +++ b/lib/routes/pollingRoutes.js @@ -41,8 +41,8 @@ const createTerms = terms => (terms.active && terms.text) ? ({ const buildTriggers = (allTriggers) => { const normalTriggers = [] const customTriggers = _.filter(o => { - if (o.customInfoRequestId === '') normalTriggers.push(o) - return o.customInfoRequestId !== '' + if (_.isEmpty(o.customInfoRequestId) || _.isNil(o.customInfoRequestId)) normalTriggers.push(o) + return !_.isNil(o.customInfoRequestId) && !_.isEmpty(o.customInfoRequestId) }, allTriggers) return _.flow([_.map(_.get('customInfoRequestId')), customRequestQueries.batchGetCustomInfoRequest])(customTriggers) @@ -73,7 +73,7 @@ function poll (req, res, next) { const pi = plugins(settings, deviceId) const hasLightning = checkHasLightning(settings) - const triggersAutomation = configManager.getTriggersAutomation(settings.config) + const triggersAutomationPromise = configManager.getTriggersAutomation(settings.config) const triggersPromise = buildTriggers(configManager.getTriggers(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() } } - return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise]) - .then(([results, triggers]) => { + return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise, triggersAutomationPromise]) + .then(([results, triggers, triggersAutomation]) => { const cassettes = results.cassettes const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid diff --git a/migrations/1641394367865-testing-customer-toggle.js b/migrations/1641394367865-testing-customer-toggle.js new file mode 100644 index 00000000..a8b8d522 --- /dev/null +++ b/migrations/1641394367865-testing-customer-toggle.js @@ -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() +} diff --git a/migrations/1642518884925-manual-custom-info-requests.js b/migrations/1642518884925-manual-custom-info-requests.js new file mode 100644 index 00000000..9a912c6d --- /dev/null +++ b/migrations/1642518884925-manual-custom-info-requests.js @@ -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() +} diff --git a/new-lamassu-admin/package-lock.json b/new-lamassu-admin/package-lock.json index e64cc12a..b54297ef 100644 --- a/new-lamassu-admin/package-lock.json +++ b/new-lamassu-admin/package-lock.json @@ -13870,6 +13870,11 @@ "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": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz", diff --git a/new-lamassu-admin/package.json b/new-lamassu-admin/package.json index bd97a32c..ab5f4f1c 100644 --- a/new-lamassu-admin/package.json +++ b/new-lamassu-admin/package.json @@ -26,6 +26,7 @@ "downshift": "3.3.4", "file-saver": "2.0.2", "formik": "2.2.0", + "google-libphonenumber": "^3.2.22", "graphql": "^14.5.8", "graphql-tag": "^2.10.3", "jss-plugin-extend": "^10.0.0", diff --git a/new-lamassu-admin/src/components/Carousel.js b/new-lamassu-admin/src/components/Carousel.js index f751cd1f..35f4ef20 100644 --- a/new-lamassu-admin/src/components/Carousel.js +++ b/new-lamassu-admin/src/components/Carousel.js @@ -38,6 +38,12 @@ export const Carousel = memo(({ photosData, slidePhoto }) => { opacity: 1 } }} + // navButtonsWrapperProps={{ + // style: { + // background: 'linear-gradient(to right, black 10%, transparent 80%)', + // opacity: '0.4' + // } + // }} autoPlay={false} indicators={false} navButtonsAlwaysVisible={true} diff --git a/new-lamassu-admin/src/components/LogsDownloaderPopper.js b/new-lamassu-admin/src/components/LogsDownloaderPopper.js index 9c02760a..f41dde88 100644 --- a/new-lamassu-admin/src/components/LogsDownloaderPopper.js +++ b/new-lamassu-admin/src/components/LogsDownloaderPopper.js @@ -181,7 +181,8 @@ const LogsDownloaderPopover = ({ fetchLogs({ variables: { ...args, - simplified: selectedAdvancedRadio === SIMPLIFIED + simplified: selectedAdvancedRadio === SIMPLIFIED, + excludeTestingCustomers: true } }) } @@ -196,7 +197,8 @@ const LogsDownloaderPopover = ({ ...args, from: range.from, until: range.until, - simplified: selectedAdvancedRadio === SIMPLIFIED + simplified: selectedAdvancedRadio === SIMPLIFIED, + excludeTestingCustomers: true } }) } diff --git a/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js b/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js index 49f0456f..91d1b720 100644 --- a/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js +++ b/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js @@ -64,6 +64,9 @@ const styles = { position: 'relative', marginBottom: spacer / 2, paddingTop: spacer * 1.5, + '& > *:first-child': { + marginRight: 24 + }, '& > *': { marginRight: 10 }, @@ -74,7 +77,8 @@ const styles = { notificationContent: { display: 'flex', flexDirection: 'column', - justifyContent: 'center' + justifyContent: 'center', + width: 300 }, unread: { backgroundColor: spring3 @@ -89,8 +93,7 @@ const styles = { flexGrow: 1 }, unreadIcon: { - marginLeft: spacer, - marginTop: 5, + marginTop: 2, width: '12px', height: '12px', backgroundColor: secondaryColor, diff --git a/new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js b/new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js index e318f0c2..f5cdea17 100644 --- a/new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js +++ b/new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js @@ -13,12 +13,27 @@ import styles from './NotificationCenter.styles' const useStyles = makeStyles(styles) const types = { - transaction: { display: 'Transactions', icon: }, - highValueTransaction: { display: 'Transactions', icon: }, - fiatBalance: { display: 'Maintenance', icon: }, - cryptoBalance: { display: 'Maintenance', icon: }, - compliance: { display: 'Compliance', icon: }, - error: { display: 'Error', icon: } + transaction: { + display: 'Transactions', + icon: + }, + highValueTransaction: { + display: 'Transactions', + icon: + }, + fiatBalance: { + display: 'Maintenance', + icon: + }, + cryptoBalance: { + display: 'Maintenance', + icon: + }, + compliance: { + display: 'Compliance', + icon: + }, + error: { display: 'Error', icon: } } const NotificationRow = ({ @@ -35,7 +50,9 @@ const NotificationRow = ({ const classes = useStyles() const typeDisplay = R.path([type, 'display'])(types) ?? null - const icon = R.path([type, 'icon'])(types) ?? + const icon = R.path([type, 'icon'])(types) ?? ( + + ) const age = prettyMs(new Date().getTime() - new Date(created).getTime(), { compact: true, verbose: true @@ -57,7 +74,9 @@ const NotificationRow = ({ classes.notificationRow, !read && valid ? classes.unread : '' )}> -
{icon}
+
+
{icon}
+
{notificationTitle} diff --git a/new-lamassu-admin/src/components/layout/Header.js b/new-lamassu-admin/src/components/layout/Header.js index 12e57ae5..0c62549a 100644 --- a/new-lamassu-admin/src/components/layout/Header.js +++ b/new-lamassu-admin/src/components/layout/Header.js @@ -132,7 +132,7 @@ const Header = memo(({ tree, user }) => { return ( { if (!match) return false setActive(it) diff --git a/new-lamassu-admin/src/components/layout/TitleSection.js b/new-lamassu-admin/src/components/layout/TitleSection.js index 6858ed5f..f0777103 100644 --- a/new-lamassu-admin/src/components/layout/TitleSection.js +++ b/new-lamassu-admin/src/components/layout/TitleSection.js @@ -19,14 +19,14 @@ const TitleSection = ({ buttons = [], children, appendix, - appendixClassName + appendixRight }) => { const classes = useStyles() return (
{title} - {appendix &&
{appendix}
} + {!!appendix && appendix} {error && ( Failed to save )} @@ -46,13 +46,14 @@ const TitleSection = ({ )}
- + {(labels ?? []).map(({ icon, label }, idx) => (
{icon}
{label}
))} + {appendixRight}
{children}
diff --git a/new-lamassu-admin/src/pages/Analytics/Analytics.js b/new-lamassu-admin/src/pages/Analytics/Analytics.js index 8b00c2fc..936db2ba 100644 --- a/new-lamassu-admin/src/pages/Analytics/Analytics.js +++ b/new-lamassu-admin/src/pages/Analytics/Analytics.js @@ -2,6 +2,8 @@ import { useQuery } from '@apollo/react-hooks' import { Box } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import classnames from 'classnames' +import { endOfToday } from 'date-fns' +import { subDays } from 'date-fns/fp' import gql from 'graphql-tag' import * as R from 'ramda' import React, { useState } from 'react' @@ -42,8 +44,16 @@ const TIME_OPTIONS = { } const GET_TRANSACTIONS = gql` - query transactions($limit: Int, $from: Date, $until: Date) { - transactions(limit: $limit, from: $from, until: $until) { + query transactions( + $from: Date + $until: Date + $excludeTestingCustomers: Boolean + ) { + transactions( + from: $from + until: $until + excludeTestingCustomers: $excludeTestingCustomers + ) { id txClass txHash @@ -116,7 +126,13 @@ const OverviewEntry = ({ label, value, oldValue, currency }) => { const Analytics = () => { 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 [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0]) diff --git a/new-lamassu-admin/src/pages/Authentication/LoginState.js b/new-lamassu-admin/src/pages/Authentication/LoginState.js index aaa91fe9..4cc4444b 100644 --- a/new-lamassu-admin/src/pages/Authentication/LoginState.js +++ b/new-lamassu-admin/src/pages/Authentication/LoginState.js @@ -46,23 +46,24 @@ const GET_USER_DATA = gql` ` const validationSchema = Yup.object().shape({ - client: Yup.string() - .required('Client field is required!') - .email('Username field should be in an email format!'), + email: Yup.string() + .label('Email') + .required() + .email(), password: Yup.string().required('Password field is required'), rememberMe: Yup.boolean() }) const initialValues = { - client: '', + email: '', password: '', rememberMe: false } const getErrorMsg = (formikErrors, formikTouched, mutationError) => { if (!formikErrors || !formikTouched) return null - if (mutationError) return 'Invalid login/password combination' - if (formikErrors.client && formikTouched.client) return formikErrors.client + if (mutationError) return 'Invalid email/password combination' + if (formikErrors.email && formikTouched.email) return formikErrors.email if (formikErrors.password && formikTouched.password) return formikErrors.password return null @@ -142,13 +143,13 @@ const LoginState = ({ state, dispatch, strategy }) => { validationSchema={validationSchema} initialValues={initialValues} onSubmit={values => - submitLogin(values.client, values.password, values.rememberMe) + submitLogin(values.email, values.password, values.rememberMe) }> {({ errors, touched }) => (
{ {!loading && state.result === 'failure' && ( <> Link has expired + + To obtain a new link, run the command{' '} + lamassu-register in your server’s terminal. + )}
diff --git a/new-lamassu-admin/src/pages/Authentication/Setup2FAState.js b/new-lamassu-admin/src/pages/Authentication/Setup2FAState.js index 410b49e4..618ed89e 100644 --- a/new-lamassu-admin/src/pages/Authentication/Setup2FAState.js +++ b/new-lamassu-admin/src/pages/Authentication/Setup2FAState.js @@ -140,14 +140,13 @@ const Setup2FAState = ({ state, dispatch }) => { <>
- We detected that this account does not have its two-factor - authentication enabled. In order to protect the resources in the - system, a two-factor authentication is enforced. + This account does not yet have two-factor authentication enabled. To + secure the admin, two-factor authentication is required. - To finish this process, please scan the following QR code or insert - the secret further below on an authentication app of your choice, - such as Google Authenticator or Authy. + To complete the registration process, scan the following QR code or + insert the secret below on a 2FA app, such as Google Authenticator + or AndOTP.
diff --git a/new-lamassu-admin/src/pages/Customers/CustomerData.js b/new-lamassu-admin/src/pages/Customers/CustomerData.js index 18a53cac..3e1ba8fa 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerData.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerData.js @@ -1,6 +1,6 @@ import Grid from '@material-ui/core/Grid' 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 * as R from 'ramda' import { useState, React } from 'react' @@ -26,6 +26,11 @@ import { URI } from 'src/utils/apollo' import styles from './CustomerData.styles.js' import { EditableCard } from './components' +import { + customerDataElements, + customerDataSchemas, + formatDates +} from './helper.js' const useStyles = makeStyles(styles) @@ -63,7 +68,8 @@ const CustomerData = ({ editCustomer, deleteEditedData, updateCustomRequest, - authorizeCustomRequest + authorizeCustomRequest, + updateCustomEntry }) => { const classes = useStyles() const [listView, setListView] = useState(false) @@ -84,8 +90,8 @@ const CustomerData = ({ R.compose(R.toLower, R.path(['customInfoRequest', 'customRequest', 'name'])) ) - const customEntries = null // get customer custom entries - const customRequirements = [] // get customer custom requirements + const customFields = [] + const customRequirements = [] const customInfoRequests = sortByName( R.path(['customInfoRequests'])(customer) ?? [] ) @@ -94,87 +100,8 @@ const CustomerData = ({ 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 = { - idScan: { + idCardData: { firstName: R.path(['firstName'])(idData) ?? '', lastName: R.path(['lastName'])(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 = [ { - fields: idScanElements, + fields: customerDataElements.idCardData, title: 'ID Scan', titleIcon: , state: R.path(['idCardDataOverride'])(customer), @@ -226,8 +143,8 @@ const CustomerData = ({ editCustomer({ idCardData: _.merge(idData, formatDates(values)) }), - validationSchema: schemas.idScan, - initialValues: initialValues.idScan, + validationSchema: customerDataSchemas.idCardData, + initialValues: initialValues.idCardData, isAvailable: !_.isNil(idData) }, { @@ -257,7 +174,7 @@ const CustomerData = ({ isAvailable: !_.isNil(sanctions) }, { - fields: frontCameraElements, + fields: customerDataElements.frontCamera, title: 'Front facing camera', titleIcon: , state: R.path(['frontCameraOverride'])(customer), @@ -279,12 +196,12 @@ const CustomerData = ({ /> ) : null, hasImage: true, - validationSchema: schemas.frontCamera, + validationSchema: customerDataSchemas.frontCamera, initialValues: initialValues.frontCamera, isAvailable: !_.isNil(customer.frontCameraPath) }, { - fields: idCardPhotoElements, + fields: customerDataElements.idCardPhoto, title: 'ID card image', titleIcon: , state: R.path(['idCardPhotoOverride'])(customer), @@ -304,27 +221,26 @@ const CustomerData = ({ /> ) : null, hasImage: true, - validationSchema: schemas.idCardPhoto, + validationSchema: customerDataSchemas.idCardPhoto, initialValues: initialValues.idCardPhoto, isAvailable: !_.isNil(customer.idCardPhotoPath) }, { - fields: usSsnElements, + fields: customerDataElements.usSsn, title: 'US SSN', titleIcon: , state: R.path(['usSsnOverride'])(customer), authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }), reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }), - save: values => editCustomer({ usSsn: values.usSsn }), + save: values => editCustomer(values), deleteEditedData: () => deleteEditedData({ usSsn: null }), - validationSchema: schemas.usSsn, + validationSchema: customerDataSchemas.usSsn, initialValues: initialValues.usSsn, isAvailable: !_.isNil(customer.usSsn) } ] R.forEach(it => { - console.log('it', it) customRequirements.push({ fields: [ { @@ -336,12 +252,13 @@ const CustomerData = ({ ], title: it.customInfoRequest.customRequest.name, titleIcon: , + state: R.path(['override'])(it), authorize: () => authorizeCustomRequest({ variables: { customerId: it.customerId, infoRequestId: it.customInfoRequest.id, - isAuthorized: true + override: OVERRIDE_AUTHORIZED } }), reject: () => @@ -349,7 +266,7 @@ const CustomerData = ({ variables: { customerId: it.customerId, infoRequestId: it.customInfoRequest.id, - isAuthorized: false + override: OVERRIDE_REJECTED } }), save: values => { @@ -374,6 +291,34 @@ const CustomerData = ({ }) }, customInfoRequests) + R.forEach(it => { + customFields.push({ + fields: [ + { + name: it.label, + label: it.label, + value: it.value ?? '', + component: TextInput + } + ], + title: it.label, + titleIcon: , + 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 = ( { title, @@ -415,19 +360,24 @@ const CustomerData = ({

{'Customer data'}

- setListView(false)} - /> - setListView(true)}> + {// TODO: Remove false condition for next release + false && ( + <> + setListView(false)} + /> + setListView(true)}> + + )}
{!listView && customer && ( @@ -444,9 +394,21 @@ const CustomerData = ({ )} - {customEntries && ( + {!_.isEmpty(customFields) && (
Custom data entry + + + {customFields.map((elem, idx) => { + return isEven(idx) ? editableCard(elem, idx) : null + })} + + + {customFields.map((elem, idx) => { + return !isEven(idx) ? editableCard(elem, idx) : null + })} + +
)} {!R.isEmpty(customRequirements) && ( diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index 91b076ee..5a0d2314 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -7,6 +7,7 @@ import React, { memo, useState } from 'react' import { useHistory, useParams } from 'react-router-dom' import { ActionButton } from 'src/components/buttons' +import { Switch } from 'src/components/inputs' import { Label1, Label2 } from 'src/components/typography' import { 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 DataReversedIcon } from 'src/styling/icons/button/data/white.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' -import { ReactComponent as Discount } from 'src/styling/icons/button/discount/zodiac.svg' +// TODO: Enable for next release +// 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 CustomerData from './CustomerData' @@ -66,6 +68,7 @@ const GET_CUSTOMER = gql` lastTxClass daysSuspended isSuspended + isTestCustomer customFields { id label @@ -95,7 +98,9 @@ const GET_CUSTOMER = gql` } customInfoRequests { customerId - approved + override + overrideBy + overrideAt customerData customInfoRequest { id @@ -180,12 +185,12 @@ const SET_AUTHORIZED_REQUEST = gql` mutation setAuthorizedCustomRequest( $customerId: ID! $infoRequestId: ID! - $isAuthorized: Boolean! + $override: String! ) { setAuthorizedCustomRequest( customerId: $customerId 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` query getData { 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 history = useHistory() @@ -255,6 +293,20 @@ const CustomerProfile = memo(() => { 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, { onCompleted: () => getCustomer() }) @@ -294,6 +346,37 @@ const CustomerProfile = memo(() => { 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 => setCustomer({ variables: { @@ -302,7 +385,7 @@ const CustomerProfile = memo(() => { } }) - const replacePhoto = it => + const replacePhoto = it => { replaceCustomerPhoto({ variables: { customerId, @@ -310,14 +393,18 @@ const CustomerProfile = memo(() => { photoType: it.photoType } }) + setWizard(null) + } - const editCustomer = it => + const editCustomer = it => { editCustomerData({ variables: { customerId, customerEdit: it } }) + setWizard(null) + } const deleteEditedData = it => deleteCustomerEditedData({ @@ -385,6 +472,12 @@ const CustomerProfile = memo(() => { const timezone = R.path(['config', 'locale_timezone'], configResponse) + const customInfoRequirementOptions = + activeCustomRequests?.customInfoRequests?.map(it => ({ + value: it.id, + display: it.customRequest.name + })) ?? [] + const classes = useStyles() return ( @@ -411,82 +504,101 @@ const CustomerProfile = memo(() => {
{!loading && !customerData.isAnonymous && ( -
+ <> + code === clickedItem} + onClick={onClickSidebarItem} + />
- code === clickedItem} - onClick={onClickSidebarItem} - /> -
- Actions -
- setWizard(true)}> - {`Manual data entry`} - - {}}> - {`Add individual discount`} - - {isSuspended && ( + Actions +
setWizard(true)}> + {`Manual data entry`} + + {/* {}}> + {`Add individual discount`} + */} + {isSuspended && ( + + updateCustomer({ + suspendedUntil: null + }) + }> + {`Unsuspend customer`} + + )} + updateCustomer({ - suspendedUntil: null + authorizedOverride: blocked + ? OVERRIDE_AUTHORIZED + : OVERRIDE_REJECTED }) }> - {`Unsuspend customer`} + {`${blocked ? 'Authorize' : 'Block'} customer`} - )} - - updateCustomer({ - authorizedOverride: blocked - ? OVERRIDE_AUTHORIZED - : OVERRIDE_REJECTED - }) - }> - {`${blocked ? 'Authorize' : 'Block'} customer`} - - - setCustomer({ - variables: { - customerId, - customerInput: { - subscriberInfo: true + + setCustomer({ + variables: { + customerId, + customerInput: { + subscriberInfo: true + } } - } - }) - }> - {`Retrieve information`} - + }) + }> + {`Retrieve information`} + +
-
+
+ + {`Special user status`} + +
+
+ + R.path(['isTestCustomer'])(customerData) + ? disableTestCustomer() + : enableTestCustomer() + } + /> + {`Test user`} +
+
+
+ )}
@@ -522,7 +634,8 @@ const CustomerProfile = memo(() => { editCustomer={editCustomer} deleteEditedData={deleteEditedData} updateCustomRequest={setCustomerCustomInfoRequest} - authorizeCustomRequest={authorizeCustomRequest}> + authorizeCustomRequest={authorizeCustomRequest} + updateCustomEntry={updateCustomEntry}>
)} {isNotes && ( @@ -544,8 +657,11 @@ const CustomerProfile = memo(() => { {wizard && ( {}} + save={saveCustomEntry} + addPhoto={replacePhoto} + addCustomerData={editCustomer} onClose={() => setWizard(null)} + customInfoRequirementOptions={customInfoRequirementOptions} /> )}
diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.styles.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.styles.js index ee9a7e82..a486abee 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.styles.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.styles.js @@ -1,4 +1,4 @@ -import { comet } from 'src/styling/variables' +import { comet, subheaderColor } from 'src/styling/variables' export default { labelLink: { @@ -34,6 +34,23 @@ export default { width: 1100 }, 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]] } } diff --git a/new-lamassu-admin/src/pages/Customers/Customers.js b/new-lamassu-admin/src/pages/Customers/Customers.js index f263de86..16cefc5d 100644 --- a/new-lamassu-admin/src/pages/Customers/Customers.js +++ b/new-lamassu-admin/src/pages/Customers/Customers.js @@ -1,5 +1,5 @@ -import { useQuery } from '@apollo/react-hooks' -import { makeStyles } from '@material-ui/core/styles' +import { useQuery, useMutation } from '@apollo/react-hooks' +import { Box, makeStyles } from '@material-ui/core' import gql from 'graphql-tag' import * as R from 'ramda' import React, { useState } from 'react' @@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom' import SearchBox from 'src/components/SearchBox' import SearchFilter from 'src/components/SearchFilter' +import { Link } from 'src/components/buttons' import TitleSection from 'src/components/layout/TitleSection' import baseStyles from 'src/pages/Logs.styles' 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 CustomersList from './CustomersList' +import CreateCustomerModal from './components/CreateCustomerModal' const GET_CUSTOMER_FILTERS = gql` query filters { @@ -43,14 +45,35 @@ const GET_CUSTOMERS = gql` lastTxFiatCode lastTxClass authorizedOverride + frontCameraPath + frontCameraOverride + idCardPhotoPath + idCardPhotoOverride + idCardData + idCardDataOverride + usSsn + usSsnOverride + sanctions + sanctionsOverride daysSuspended isSuspended } } ` +const CREATE_CUSTOMER = gql` + mutation createCustomer($phoneNumber: String) { + createCustomer(phoneNumber: $phoneNumber) { + phone + } + } +` + const useBaseStyles = makeStyles(baseStyles) +const getFiltersObj = filters => + R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters) + const Customers = () => { const baseStyles = useBaseStyles() const history = useHistory() @@ -61,6 +84,7 @@ const Customers = () => { const [filteredCustomers, setFilteredCustomers] = useState([]) const [variables, setVariables] = useState({}) const [filters, setFilters] = useState([]) + const [showCreationModal, setShowCreationModal] = useState(false) const { data: customersResponse, @@ -75,19 +99,25 @@ const Customers = () => { GET_CUSTOMER_FILTERS ) + const [createNewCustomer] = useMutation(CREATE_CUSTOMER, { + onCompleted: () => setShowCreationModal(false), + refetchQueries: () => [ + { + query: GET_CUSTOMERS, + variables + } + ] + }) + const configData = R.path(['config'])(customersResponse) ?? [] const locale = configData && fromNamespace(namespaces.LOCALE, configData) - const customersData = R.sortWith([R.descend(R.prop('lastActive'))])( - filteredCustomers ?? [] - ) + const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData) + const customersData = R.sortWith([ + R.descend(it => new Date(R.prop('lastActive', it) ?? '0')) + ])(filteredCustomers ?? []) const onFilterChange = filters => { - const filtersObject = R.compose( - R.mergeAll, - R.map(f => ({ - [f.type]: f.value - })) - )(filters) + const filtersObject = getFiltersObj(filters) setFilters(filters) @@ -101,10 +131,38 @@ const Customers = () => { refetch && refetch() } - const onFilterDelete = filter => - setFilters( - R.filter(f => !R.whereEq(R.pick(['type', 'value'], f), filter))(filters) - ) + const onFilterDelete = filter => { + const newFilters = R.filter( + 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) @@ -113,7 +171,7 @@ const Customers = () => { +
{ />
} - appendixClassName={baseStyles.buttonsWrapper} + appendixRight={ + + setShowCreationModal(true)}> + Add new user + + + } labels={[ { label: 'Cash-in', icon: }, { label: 'Cash-out', icon: } @@ -131,9 +195,10 @@ const Customers = () => { /> {filters.length > 0 && ( )} { locale={locale} onClick={handleCustomerClicked} loading={customerLoading} + triggers={triggers} + /> + setShowCreationModal(false)} + locale={locale} + onSubmit={createNewCustomer} /> ) diff --git a/new-lamassu-admin/src/pages/Customers/CustomersList.js b/new-lamassu-admin/src/pages/Customers/CustomersList.js index 43e40c2a..8b11e9ab 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomersList.js +++ b/new-lamassu-admin/src/pages/Customers/CustomersList.js @@ -13,42 +13,42 @@ import { getAuthorizedStatus, getFormattedPhone, getName } from './helper' const useStyles = makeStyles(styles) -const CustomersList = ({ data, locale, onClick, loading }) => { +const CustomersList = ({ data, locale, onClick, loading, triggers }) => { const classes = useStyles() const elements = [ { header: 'Phone', - width: 175, + width: 199, view: it => getFormattedPhone(it.phone, locale.country) }, { header: 'Name', - width: 247, + width: 241, view: getName }, { header: 'Total TXs', - width: 130, + width: 126, textAlign: 'right', view: it => `${Number.parseInt(it.totalTxs)}` }, { header: 'Total spent', - width: 155, + width: 152, textAlign: 'right', view: it => `${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}` }, { header: 'Last active', - width: 137, + width: 133, view: it => (it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? '' }, { header: 'Last transaction', - width: 165, + width: 161, textAlign: 'right', view: it => { const hasLastTx = !R.isNil(it.lastTxFiatCode) @@ -66,7 +66,7 @@ const CustomersList = ({ data, locale, onClick, loading }) => { { header: 'Status', width: 191, - view: it => + view: it => } ] diff --git a/new-lamassu-admin/src/pages/Customers/Wizard.js b/new-lamassu-admin/src/pages/Customers/Wizard.js index 87bebaa8..6f81b9de 100644 --- a/new-lamassu-admin/src/pages/Customers/Wizard.js +++ b/new-lamassu-admin/src/pages/Customers/Wizard.js @@ -9,7 +9,14 @@ import Stepper from 'src/components/Stepper' import { Button } from 'src/components/buttons' 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 @@ -41,23 +48,40 @@ const styles = { margin: [[0, 4, 0, 2]], borderBottom: `1px solid ${comet}`, display: 'inline-block' + }, + dropdownField: { + marginTop: 16, + minWidth: 155 } } const useStyles = makeStyles(styles) const getStep = (step, selectedValues) => { + const elements = + selectedValues?.entryType === REQUIREMENT && + !R.isNil(selectedValues?.requirement) + ? requirementElements[selectedValues?.requirement] + : customElements[selectedValues?.dataType] + switch (step) { case 1: return entryType case 2: - return customElements[selectedValues?.dataType] + return elements default: return Fragment } } -const Wizard = ({ onClose, save, error }) => { +const Wizard = ({ + onClose, + save, + error, + customInfoRequirementOptions, + addCustomerData, + addPhoto +}) => { const classes = useStyles() const [selectedValues, setSelectedValues] = useState(null) @@ -66,6 +90,10 @@ const Wizard = ({ onClose, save, error }) => { 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 stepOptions = getStep(step, selectedValues) @@ -74,7 +102,23 @@ const Wizard = ({ onClose, save, error }) => { setSelectedValues(newConfig) 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({ @@ -106,6 +150,7 @@ const Wizard = ({ onClose, save, error }) => {
diff --git a/new-lamassu-admin/src/pages/Customers/components/CreateCustomerModal.js b/new-lamassu-admin/src/pages/Customers/components/CreateCustomerModal.js new file mode 100644 index 00000000..984d9d64 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/CreateCustomerModal.js @@ -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 ( + + { + onSubmit({ + variables: { + phoneNumber: formatPhoneNumber( + possibleCountries, + values.phoneNumber + ) + } + }) + }}> + {({ errors, touched }) => ( + +

Create new customer

+ +
+ {getErrorMsg(errors, touched) && ( + {getErrorMsg(errors, touched)} + )} + +
+ + )} +
+
+ ) +} + +export default CreateCustomerModal diff --git a/new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js b/new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js index ced31cef..d8ca647d 100644 --- a/new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js +++ b/new-lamassu-admin/src/pages/Customers/components/CustomInfoRequestsData.js @@ -53,12 +53,12 @@ const SET_AUTHORIZED_REQUEST = gql` mutation setAuthorizedCustomRequest( $customerId: ID! $infoRequestId: ID! - $isAuthorized: Boolean! + $override: String! ) { setAuthorizedCustomRequest( customerId: $customerId infoRequestId: $infoRequestId - isAuthorized: $isAuthorized + override: $override ) } ` diff --git a/new-lamassu-admin/src/pages/Customers/components/CustomerSidebar.styles.js b/new-lamassu-admin/src/pages/Customers/components/CustomerSidebar.styles.js index 87ef5087..9485c7f4 100644 --- a/new-lamassu-admin/src/pages/Customers/components/CustomerSidebar.styles.js +++ b/new-lamassu-admin/src/pages/Customers/components/CustomerSidebar.styles.js @@ -11,8 +11,7 @@ export default { backgroundColor: sidebarColor, width: 219, flexDirection: 'column', - borderRadius: 5, - marginBottom: 50 + borderRadius: 5 }, link: { alignItems: 'center', diff --git a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js index 0239db8a..2a7cba0a 100644 --- a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js +++ b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js @@ -150,7 +150,7 @@ const EditableCard = ({

{title}

- {state && ( + {state && authorize && (
@@ -207,17 +207,19 @@ const EditableCard = ({
{!editing && (
-
- deleteEditedData()}> - {`Delete`} - -
- + {// TODO: Remove false condition for next release + false && ( +
+ deleteEditedData()}> + {`Delete`} + +
+ )}
- {authorized.label !== 'Accepted' && ( + {authorize && authorized.label !== 'Accepted' && (
)} - {authorized.label !== 'Rejected' && ( + {authorize && authorized.label !== 'Rejected' && ( { const { setFieldValue } = useFormikContext() 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( acceptedData => { - // TODO: attach the uploaded data to the form as well - setFieldValue(type, R.head(acceptedData).name) + setFieldValue(type, R.head(acceptedData)) setData({ preview: isImage @@ -84,12 +87,12 @@ const Upload = ({ type }) => {
)} - {!R.isEmpty(data) && type === IMAGE && ( + {!R.isEmpty(data) && isImage && (
)} - {!R.isEmpty(data) && type !== IMAGE && ( + {!R.isEmpty(data) && !isImage && (

{data.preview}

diff --git a/new-lamassu-admin/src/pages/Customers/helper.js b/new-lamassu-admin/src/pages/Customers/helper.js index 719ae628..83cbe3ea 100644 --- a/new-lamassu-admin/src/pages/Customers/helper.js +++ b/new-lamassu-admin/src/pages/Customers/helper.js @@ -1,13 +1,19 @@ import { makeStyles, Box } from '@material-ui/core' import classnames from 'classnames' +import { parse, isValid, format } from 'date-fns/fp' import { Field, useFormikContext } from 'formik' import { parsePhoneNumberFromString } from 'libphonenumber-js' import * as R from 'ramda' 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 { errorColor } from 'src/styling/variables' +import { MANUAL } from 'src/utils/constants' import { Upload } from './components' @@ -34,19 +40,63 @@ const useStyles = makeStyles({ specialGrid: { display: 'grid', gridTemplateColumns: [[182, 162, 141]] + }, + picker: { + width: 150 + }, + field: { + '& > *:last-child': { + marginBottom: 24 + } } }) const CUSTOMER_BLOCKED = 'blocked' +const CUSTOM = 'custom' +const REQUIREMENT = 'requirement' +const ID_CARD_DATA = 'idCardData' -const getAuthorizedStatus = it => - it.authorizedOverride === CUSTOMER_BLOCKED - ? { label: 'Blocked', type: 'error' } - : it.isSuspended - ? it.daysSuspended > 0 +const getAuthorizedStatus = (it, triggers) => { + const fields = [ + 'frontCameraPath', + 'idCardData', + '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: `< 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 phoneNumber = @@ -63,34 +113,46 @@ const getName = it => { ) ?? ''}`.trim() } +// Manual Entry Wizard + const entryOptions = [ { display: 'Custom entry', code: 'custom' }, { display: 'Populate existing requirement', code: 'requirement' } ] const dataOptions = [ - { display: 'Text', code: 'text' }, - { display: 'File', code: 'file' }, - { display: 'Image', code: 'image' } + { display: 'Text', code: 'text' } + // TODO: Requires backend modifications to support File and Image + // { display: 'File', code: 'file' }, + // { display: 'Image', code: 'image' } ] const requirementOptions = [ - { display: 'Birthdate', code: 'birthdate' }, { display: 'ID card image', code: 'idCardPhoto' }, { 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 = [ - { display: 'Data entry title', code: 'title' }, - { display: 'Data entry', code: 'data' } + { label: 'Data entry title', name: 'title' }, + { 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({ - entryType: Yup.string().required() +const entryTypeSchema = Yup.lazy(values => { + 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({ @@ -108,13 +170,18 @@ const customTextSchema = Yup.object().shape({ data: Yup.string().required() }) -const EntryType = () => { +const updateRequirementOptions = it => [ + { + display: 'Custom information requirement', + code: 'custom' + }, + ...it +] + +const EntryType = ({ customInfoRequirementOptions }) => { const classes = useStyles() const { values } = useFormikContext() - const CUSTOM = 'custom' - const REQUIREMENT = 'requirement' - const displayCustomOptions = values.entryType === CUSTOM const displayRequirementOptions = values.entryType === REQUIREMENT @@ -154,7 +221,13 @@ const EntryType = () => { { ) } -const CustomData = ({ selectedValues }) => { +const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => { + const classes = useStyles() + + const typeOfEntrySelected = selectedValues?.entryType 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 ( <> -

{`Custom ${dataTypeSelected} entry`}

+

{title}

- {customElements[dataTypeSelected].options.map(({ display, code }) => ( - - ))} - {upload && } + {isCustomInfoRequirement && ( + {}} + /> + )} +
+ {!upload && + !isCustomInfoRequirement && + elements.options.map(({ label, name }) => ( + + ))} +
+ {upload && ( + + )} ) } @@ -185,20 +313,23 @@ const customElements = { text: { schema: customTextSchema, options: customTextOptions, - Component: CustomData, - initialValues: { data: '', title: '' } + Component: ManualDataEntry, + initialValues: { data: '', title: '' }, + saveType: 'customEntry' }, file: { schema: customFileSchema, options: customUploadOptions, - Component: CustomData, - initialValues: { file: '', title: '' } + Component: ManualDataEntry, + initialValues: { file: null, title: '' }, + saveType: 'customEntryUpload' }, image: { schema: customImageSchema, options: customUploadOptions, - Component: CustomData, - initialValues: { image: '', title: '' } + Component: ManualDataEntry, + initialValues: { image: null, title: '' }, + saveType: 'customEntryUpload' } } @@ -209,6 +340,142 @@ const 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 [key, value] = pair if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') { @@ -245,5 +512,12 @@ export { getName, entryType, customElements, - formatPhotosData + requirementElements, + formatPhotosData, + customerDataElements, + customerDataSchemas, + formatDates, + REQUIREMENT, + CUSTOM, + ID_CARD_DATA } diff --git a/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/Graphs/RefScatterplot.js b/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/Graphs/RefScatterplot.js index f2eb4e78..ef092185 100644 --- a/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/Graphs/RefScatterplot.js +++ b/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/Graphs/RefScatterplot.js @@ -62,7 +62,7 @@ const Graph = ({ data, timeFrame, timezone }) => { [] ) - const filterDay = useMemo( + const filterDay = useCallback( x => (timeFrame === 'day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1), [timeFrame] ) diff --git a/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/SystemPerformance.js b/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/SystemPerformance.js index 29b3dca1..d437b6e5 100644 --- a/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/SystemPerformance.js +++ b/new-lamassu-admin/src/pages/Dashboard/SystemPerformance/SystemPerformance.js @@ -52,8 +52,8 @@ const ranges = { } const GET_DATA = gql` - query getData { - transactions { + query getData($excludeTestingCustomers: Boolean) { + transactions(excludeTestingCustomers: $excludeTestingCustomers) { fiatCode fiat cashInFee @@ -78,7 +78,9 @@ const reducer = (acc, it) => const SystemPerformance = () => { const classes = useStyles() 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 timezone = fromNamespace('locale')(data?.config).timezone diff --git a/new-lamassu-admin/src/pages/Dashboard/SystemStatus/MachinesTable.js b/new-lamassu-admin/src/pages/Dashboard/SystemStatus/MachinesTable.js index e97805f9..ee85d4af 100644 --- a/new-lamassu-admin/src/pages/Dashboard/SystemStatus/MachinesTable.js +++ b/new-lamassu-admin/src/pages/Dashboard/SystemStatus/MachinesTable.js @@ -128,17 +128,17 @@ const MachinesTable = ({ machines = [], numToRender }) => { onClick={() => redirect(machine)} className={classnames(classes.row)} key={machine.deviceId + idx}> - - {machine.name} - redirect(machine)} - /> + +
+ {machine.name} + redirect(machine)} + /> +
diff --git a/new-lamassu-admin/src/pages/Machines/MachineComponents/Transactions/Transactions.js b/new-lamassu-admin/src/pages/Machines/MachineComponents/Transactions/Transactions.js index a5902611..c5e71c95 100644 --- a/new-lamassu-admin/src/pages/Machines/MachineComponents/Transactions/Transactions.js +++ b/new-lamassu-admin/src/pages/Machines/MachineComponents/Transactions/Transactions.js @@ -145,7 +145,7 @@ const Transactions = ({ id }) => { width: 140 }, { - header: 'Date (UTC)', + header: 'Date', view: it => formatDate(it.created, timezone, 'yyyy-MM-dd'), textAlign: 'left', size: 'sm', diff --git a/new-lamassu-admin/src/pages/Machines/Machines.js b/new-lamassu-admin/src/pages/Machines/Machines.js index 8e13a21f..955d40f4 100644 --- a/new-lamassu-admin/src/pages/Machines/Machines.js +++ b/new-lamassu-admin/src/pages/Machines/Machines.js @@ -6,8 +6,8 @@ import NavigateNextIcon from '@material-ui/icons/NavigateNext' import classnames from 'classnames' import gql from 'graphql-tag' import * as R from 'ramda' -import React from 'react' -import { Link, useLocation } from 'react-router-dom' +import React, { useState } from 'react' +import { Link, useLocation, useHistory } from 'react-router-dom' 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 Machines = () => { +const MachineRoute = () => { 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: { - deviceId: getMachineID(location.pathname), - billFilters: { - deviceId: getMachineID(location.pathname), - batch: 'none' - } + deviceId: id + }, + billFilters: { + deviceId: id, + batch: 'none' } }) + const reload = () => { + return history.push(location.pathname) + } + + return ( + !loading && ( + + ) + ) +} + +const Machines = ({ data, refetch, reload }) => { const classes = useStyles() const timezone = R.path(['config', 'locale_timezone'], data) ?? {} @@ -82,54 +106,52 @@ const Machines = () => { const machineID = R.path(['deviceId'])(machine) ?? null return ( - !loading && ( - - - -
- }> - - - Dashboard - - - - {machineName} - - - -
-
-
- -
-
- {'Details'} -
-
-
- {'Cash box & cassettes'} - -
-
- {'Latest transactions'} - -
-
- {'Commissions'} - -
+ + + +
+ }> + + + Dashboard + + + + {machineName} + + +
- ) + +
+
+ {'Details'} +
+
+
+ {'Cash box & cassettes'} + +
+
+ {'Latest transactions'} + +
+
+ {'Commissions'} + +
+
+
+
) } -export default Machines +export default MachineRoute diff --git a/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js b/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js index 294cb13e..b67cb991 100644 --- a/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js +++ b/new-lamassu-admin/src/pages/Maintenance/CashCassettes.js @@ -28,6 +28,30 @@ import Wizard from './Wizard/Wizard' 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({ name: Yup.string().required(), cashbox: Yup.number() @@ -201,14 +225,14 @@ const CashCassettes = () => { { name: 'name', header: 'Machine', - width: 184, + width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine, view: name => <>{name}, input: ({ field: { value: name } }) => <>{name} }, { name: 'cashbox', header: 'Cash box', - width: maxNumberOfCassettes > 2 ? 140 : 280, + width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cashbox, view: (value, { id }) => ( { elements.push({ name: `cassette${it}`, header: `Cassette ${it}`, - width: (maxNumberOfCassettes > 2 ? 560 : 650) / maxNumberOfCassettes, + width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassette, stripe: true, doubleHeader: 'Cash-out', view: (value, { id }) => ( @@ -238,7 +262,9 @@ const CashCassettes = () => { denomination={getCashoutSettings(id)?.[`cassette${it}`]} currency={{ code: fiatCurrency }} notes={value} - width={50} + width={ + widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph + } threshold={ fillingPercentageSettings[`fillingPercentageCassette${it}`] } @@ -248,7 +274,7 @@ const CashCassettes = () => { input: CashCassetteInput, inputProps: { decimalPlaces: 0, - width: 50, + width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph, inputClassName: classes.cashbox } }) @@ -260,7 +286,8 @@ const CashCassettes = () => { elements.push({ name: 'edit', header: 'Edit', - width: 87, + width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.editWidth, + textAlign: 'center', view: (value, { id }) => { return ( { <> {!showHistory && ( diff --git a/new-lamassu-admin/src/pages/Maintenance/CashboxHistory.js b/new-lamassu-admin/src/pages/Maintenance/CashboxHistory.js index 6eef4ae4..c84d4b82 100644 --- a/new-lamassu-admin/src/pages/Maintenance/CashboxHistory.js +++ b/new-lamassu-admin/src/pages/Maintenance/CashboxHistory.js @@ -28,7 +28,6 @@ const GET_BATCHES = gql` fiat deviceId created - cashbox } } } diff --git a/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js b/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js index 8e6f8b4e..f00081dd 100644 --- a/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js +++ b/new-lamassu-admin/src/pages/Maintenance/Wizard/WizardStep.js @@ -274,6 +274,7 @@ const WizardStep = ({ placeholder={originalCassetteCount.toString()} name={cassetteField} className={classes.cashboxBills} + autoFocus />

{cassetteDenomination} {fiatCurrency} bills loaded diff --git a/new-lamassu-admin/src/pages/SessionManagement/SessionManagement.js b/new-lamassu-admin/src/pages/SessionManagement/SessionManagement.js index 13d1fe0c..83457474 100644 --- a/new-lamassu-admin/src/pages/SessionManagement/SessionManagement.js +++ b/new-lamassu-admin/src/pages/SessionManagement/SessionManagement.js @@ -79,7 +79,7 @@ const SessionManagement = () => { } }, { - header: 'Expiration date (UTC)', + header: 'Expiration date', width: 290, textAlign: 'right', size: 'sm', diff --git a/new-lamassu-admin/src/pages/Transactions/DetailsCard.js b/new-lamassu-admin/src/pages/Transactions/DetailsCard.js index 334ee3a5..c960a3ee 100644 --- a/new-lamassu-admin/src/pages/Transactions/DetailsCard.js +++ b/new-lamassu-admin/src/pages/Transactions/DetailsCard.js @@ -1,7 +1,7 @@ import { useLazyQuery, useMutation } from '@apollo/react-hooks' import { makeStyles, Box } from '@material-ui/core' 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 gql from 'graphql-tag' import JSZip from 'jszip' @@ -116,17 +116,27 @@ const DetailsRow = ({ it: tx, timezone }) => { const exchangeRate = BigNumber(fiat / crypto).toFormat(2) const displayExRate = `1 ${tx.cryptoCode} = ${exchangeRate} ${tx.fiatCode}` + const parseDateString = parse(new Date(), 'yyyyMMdd') + const customer = tx.customerIdCardData && { name: `${onlyFirstToUpper( tx.customerIdCardData.firstName )} ${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, idCardNumber: tx.customerIdCardData.documentNumber, - idCardExpirationDate: format( - 'dd-MM-yyyy', - tx.customerIdCardData.expirationDate - ) + idCardExpirationDate: + (tx.customerIdCardData.expirationDate && + format('yyyy-MM-dd')( + parseDateString(tx.customerIdCardData.expirationDate) + )) ?? + '' } const from = sub({ minutes: MINUTES_OFFSET }, tx.created) diff --git a/new-lamassu-admin/src/pages/Transactions/Transactions.js b/new-lamassu-admin/src/pages/Transactions/Transactions.js index 016c8b37..546f7476 100644 --- a/new-lamassu-admin/src/pages/Transactions/Transactions.js +++ b/new-lamassu-admin/src/pages/Transactions/Transactions.js @@ -40,6 +40,7 @@ const GET_TRANSACTIONS_CSV = gql` $from: Date $until: Date $timezone: String + $excludeTestingCustomers: Boolean ) { transactionsCsv( simplified: $simplified @@ -47,6 +48,7 @@ const GET_TRANSACTIONS_CSV = gql` from: $from until: $until timezone: $timezone + excludeTestingCustomers: $excludeTestingCustomers ) } ` @@ -222,7 +224,7 @@ const Transactions = () => { width: 140 }, { - header: 'Date (UTC)', + header: 'Date', view: it => timezone && formatDate(it.created, timezone, 'yyyy-MM-dd HH:mm:ss'), textAlign: 'right', diff --git a/new-lamassu-admin/src/pages/Triggers/TriggerView.js b/new-lamassu-admin/src/pages/Triggers/TriggerView.js index 52256f55..d07bdce9 100644 --- a/new-lamassu-admin/src/pages/Triggers/TriggerView.js +++ b/new-lamassu-admin/src/pages/Triggers/TriggerView.js @@ -75,6 +75,7 @@ const TriggerView = ({ error={error?.message} save={add} onClose={toggleWizard} + customInfoRequests={customInfoRequests} /> )} {R.isEmpty(triggers) && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Triggers.js b/new-lamassu-admin/src/pages/Triggers/Triggers.js index af4046bc..c7e82550 100644 --- a/new-lamassu-admin/src/pages/Triggers/Triggers.js +++ b/new-lamassu-admin/src/pages/Triggers/Triggers.js @@ -48,8 +48,10 @@ const GET_CUSTOM_REQUESTS = gql` const Triggers = () => { const classes = useStyles() const [wizardType, setWizard] = useState(false) - const { data, loading } = useQuery(GET_CONFIG) - const { data: customInfoReqData } = useQuery(GET_CUSTOM_REQUESTS) + const { data, loading: configLoading } = useQuery(GET_CONFIG) + const { data: customInfoReqData, loading: customInfoLoading } = useQuery( + GET_CUSTOM_REQUESTS + ) const [error, setError] = useState(null) const [subMenu, setSubMenu] = useState(false) @@ -94,6 +96,8 @@ const Triggers = () => { return setWizard(wizardName) } + const loading = configLoading || customInfoLoading + return ( <> { showWizard={wizardType === 'newTrigger'} config={data?.config ?? {}} toggleWizard={toggleWizard('newTrigger')} - customInfoRequests={customInfoRequests} + customInfoRequests={enabledCustomInfoRequests} /> )} {!loading && subMenu === 'advancedSettings' && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Wizard.js b/new-lamassu-admin/src/pages/Triggers/Wizard.js index 44eb7875..b78fc5fa 100644 --- a/new-lamassu-admin/src/pages/Triggers/Wizard.js +++ b/new-lamassu-admin/src/pages/Triggers/Wizard.js @@ -48,14 +48,14 @@ const styles = { const useStyles = makeStyles(styles) -const getStep = (step, currency) => { +const getStep = (step, currency, customInfoRequests) => { switch (step) { // case 1: // return txDirection case 1: return type(currency) case 2: - return requirements + return requirements(customInfoRequests) default: return Fragment } @@ -202,7 +202,7 @@ const GetValues = ({ setValues }) => { return null } -const Wizard = ({ onClose, save, error, currency }) => { +const Wizard = ({ onClose, save, error, currency, customInfoRequests }) => { const classes = useStyles() const [liveValues, setLiveValues] = useState({}) @@ -211,7 +211,7 @@ const Wizard = ({ onClose, save, error, currency }) => { }) const isLastStep = step === LAST_STEP - const stepOptions = getStep(step, currency) + const stepOptions = getStep(step, currency, customInfoRequests) const onContinue = async 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 containsType = R.contains(triggerType) const isSuspend = values?.requirement?.requirement === 'suspend' + const isCustom = values?.requirement?.requirement === 'custom' - const hasRequirementError = - !!errors.requirement && - !!touched.requirement?.suspensionDays && - (!values.requirement?.suspensionDays || - values.requirement?.suspensionDays < 0) + const hasRequirementError = requirements().hasRequirementError( + errors, + touched, + values + ) + const hasCustomRequirementError = requirements().hasCustomRequirementError( + errors, + touched, + values + ) const hasAmountError = !!errors.threshold && @@ -258,7 +264,11 @@ const Wizard = ({ onClose, save, error, currency }) => { ) return errors.threshold - if (isSuspend && hasRequirementError) return errors.requirement + if ( + (isSuspend && hasRequirementError) || + (isCustom && hasCustomRequirementError) + ) + return errors.requirement } return ( diff --git a/new-lamassu-admin/src/pages/Triggers/components/AdvancedTriggers.js b/new-lamassu-admin/src/pages/Triggers/components/AdvancedTriggers.js index cb58bfa2..038a6825 100644 --- a/new-lamassu-admin/src/pages/Triggers/components/AdvancedTriggers.js +++ b/new-lamassu-admin/src/pages/Triggers/components/AdvancedTriggers.js @@ -28,13 +28,34 @@ const GET_INFO = gql` } ` +const GET_CUSTOM_REQUESTS = gql` + query customInfoRequests { + customInfoRequests { + id + customRequest + enabled + } + } +` + const AdvancedTriggersSettings = memo(() => { const SCREEN_KEY = namespaces.TRIGGERS const [error, setError] = useState(null) const [isEditingDefault, setEditingDefault] = 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, { refetchQueries: () => ['getData'], @@ -67,42 +88,47 @@ const AdvancedTriggersSettings = memo(() => { const onEditingOverrides = (it, editing) => setEditingOverrides(editing) return ( - <> -

- -
-
- -
- + !loading && ( + <> +
+ +
+
+ +
+ + ) ) }) diff --git a/new-lamassu-admin/src/pages/Triggers/components/helper.js b/new-lamassu-admin/src/pages/Triggers/components/helper.js index 0737bca8..e7c660df 100644 --- a/new-lamassu-admin/src/pages/Triggers/components/helper.js +++ b/new-lamassu-admin/src/pages/Triggers/components/helper.js @@ -4,18 +4,29 @@ import * as Yup from 'yup' import Autocomplete from 'src/components/inputs/formik/Autocomplete.js' import { getView } from 'src/pages/Triggers/helper' -const advancedRequirementOptions = [ - { display: 'Sanctions', code: 'sanctions' }, - { display: 'ID card image', code: 'idCardPhoto' }, - { display: 'ID data', code: 'idCardData' }, - { display: 'Customer camera', code: 'facephoto' }, - { display: 'US SSN', code: 'usSsn' } -] +const buildAdvancedRequirementOptions = customInfoRequests => { + const base = [ + { display: 'Sanctions', code: 'sanctions' }, + { display: 'ID card image', code: 'idCardPhoto' }, + { display: 'ID data', code: 'idCardData' }, + { 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( '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() }) -const getOverridesSchema = values => { +const getOverridesSchema = (values, customInfoRequests) => { return Yup.object().shape({ id: Yup.string() .label('Requirement') @@ -40,7 +51,8 @@ const getOverridesSchema = values => { if (R.find(R.propEq('requirement', requirement))(values)) { return this.createError({ message: `Requirement ${displayRequirement( - requirement + requirement, + customInfoRequests )} already overriden` }) } @@ -84,17 +96,20 @@ const getDefaultSettings = () => { ] } -const getOverrides = () => { +const getOverrides = customInfoRequests => { return [ { name: 'requirement', header: 'Requirement', width: 196, size: 'sm', - view: getView(advancedRequirementOptions, 'display'), + view: getView( + buildAdvancedRequirementOptions(customInfoRequests), + 'display' + ), input: Autocomplete, inputProps: { - options: advancedRequirementOptions, + options: buildAdvancedRequirementOptions(customInfoRequests), labelProp: 'display', valueProp: 'code' } diff --git a/new-lamassu-admin/src/pages/Triggers/helper.js b/new-lamassu-admin/src/pages/Triggers/helper.js index fcf91e71..6830c99e 100644 --- a/new-lamassu-admin/src/pages/Triggers/helper.js +++ b/new-lamassu-admin/src/pages/Triggers/helper.js @@ -1,8 +1,6 @@ -import { useQuery } from '@apollo/react-hooks' import { makeStyles, Box } from '@material-ui/core' import classnames from 'classnames' import { Field, useFormikContext } from 'formik' -import gql from 'graphql-tag' import * as R from 'ramda' import React, { memo } from 'react' import * as Yup from 'yup' @@ -479,21 +477,43 @@ const requirementSchema = Yup.object() otherwise: Yup.number() .nullable() .transform(() => null) + }), + customInfoRequestId: Yup.string().when('requirement', { + is: value => value === 'custom', + then: Yup.string(), + otherwise: Yup.string() + .nullable() + .transform(() => '') }) }).required() }) .test(({ requirement }, context) => { - const requirementValidator = requirement => - requirement.requirement === 'suspend' - ? requirement.suspensionDays > 0 - : true + const requirementValidator = (requirement, type) => { + switch (type) { + case 'suspend': + 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({ - path: 'requirement', - message: 'Suspension days must be greater than 0' - }) + if (requirement && !requirementValidator(requirement, 'custom')) + return context.createError({ + path: 'requirement', + message: 'You must select an item' + }) }) const requirementOptions = [ @@ -508,16 +528,19 @@ const requirementOptions = [ { display: 'Block', code: 'block' } ] -const GET_ACTIVE_CUSTOM_REQUESTS = gql` - query customInfoRequests($onlyEnabled: Boolean) { - customInfoRequests(onlyEnabled: $onlyEnabled) { - id - customRequest - } - } -` +const hasRequirementError = (errors, touched, values) => + !!errors.requirement && + !!touched.requirement?.suspensionDays && + (!values.requirement?.suspensionDays || + 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 { touched, @@ -526,11 +549,6 @@ const Requirement = () => { handleChange, setTouched } = useFormikContext() - const { data } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, { - variables: { - onlyEnabled: true - } - }) const isSuspend = values?.requirement?.requirement === 'suspend' const isCustom = values?.requirement?.requirement === 'custom' @@ -540,24 +558,19 @@ const Requirement = () => { display: it.customRequest.name })) - const hasRequirementError = - !!errors.requirement && - !!touched.requirement?.suspensionDays && - (!values.requirement?.suspensionDays || - values.requirement?.suspensionDays < 0) - - const customInfoRequests = R.path(['customInfoRequests'])(data) ?? [] - const enableCustomRequirement = customInfoRequests.length > 0 + const enableCustomRequirement = customInfoRequests?.length > 0 const customInfoOption = { display: 'Custom information requirement', code: 'custom' } const options = enableCustomRequirement ? [...requirementOptions, customInfoOption] - : [...requirementOptions, { ...customInfoOption, disabled: true }] + : [...requirementOptions] const titleClass = { [classes.error]: - (!!errors.requirement && !isSuspend) || (isSuspend && hasRequirementError) + (!!errors.requirement && !isSuspend && !isCustom) || + (isSuspend && hasRequirementError(errors, touched, values)) || + (isCustom && hasCustomRequirementError(errors, touched, values)) } return ( @@ -586,7 +599,7 @@ const Requirement = () => { label="Days" size="lg" name="requirement.suspensionDays" - error={hasRequirementError} + error={hasRequirementError(errors, touched, values)} /> )} {isCustom && ( @@ -604,10 +617,13 @@ const Requirement = () => { ) } -const requirements = { +const requirements = customInfoRequests => ({ schema: requirementSchema, options: requirementOptions, Component: Requirement, + props: { customInfoRequests }, + hasRequirementError: hasRequirementError, + hasCustomRequirementError: hasCustomRequirementError, initialValues: { requirement: { requirement: '', @@ -615,7 +631,7 @@ const requirements = { customInfoRequestId: '' } } -} +}) const getView = (data, code, compare) => it => { if (!data) return '' diff --git a/new-lamassu-admin/src/pages/Wallet/Wallet.js b/new-lamassu-admin/src/pages/Wallet/Wallet.js index b80ea805..1fff8153 100644 --- a/new-lamassu-admin/src/pages/Wallet/Wallet.js +++ b/new-lamassu-admin/src/pages/Wallet/Wallet.js @@ -140,12 +140,14 @@ const Wallet = ({ name: SCREEN_KEY }) => {
Fee discount diff --git a/new-lamassu-admin/src/routing/lamassu.routes.js b/new-lamassu-admin/src/routing/lamassu.routes.js index 2626e30b..e647c65b 100644 --- a/new-lamassu-admin/src/routing/lamassu.routes.js +++ b/new-lamassu-admin/src/routing/lamassu.routes.js @@ -130,7 +130,7 @@ const getLamassuRoutes = () => [ }, { key: 'services', - label: '3rd party services', + label: '3rd Party Services', route: '/settings/3rd-party-services', allowedRoles: [ROLES.USER, ROLES.SUPERUSER], component: Services diff --git a/new-lamassu-admin/src/routing/pazuz.routes.js b/new-lamassu-admin/src/routing/pazuz.routes.js index eaed555c..153d0205 100644 --- a/new-lamassu-admin/src/routing/pazuz.routes.js +++ b/new-lamassu-admin/src/routing/pazuz.routes.js @@ -132,7 +132,7 @@ const getPazuzRoutes = () => [ }, { key: 'services', - label: '3rd party services', + label: '3rd Party Services', route: '/settings/3rd-party-services', allowedRoles: [ROLES.USER, ROLES.SUPERUSER], component: Services diff --git a/new-lamassu-admin/src/styling/global/index.js b/new-lamassu-admin/src/styling/global/index.js index 7c57661e..4e5083ef 100644 --- a/new-lamassu-admin/src/styling/global/index.js +++ b/new-lamassu-admin/src/styling/global/index.js @@ -28,7 +28,10 @@ export default { pointerEvents: 'none' }, html: { - height: fill + height: fill, + '@media screen and (max-height: 900px)': { + scrollbarGutter: 'stable' + } }, body: { width: mainWidth, diff --git a/new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg b/new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg index 58db5b0d..0cf3417a 100644 --- a/new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg +++ b/new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg @@ -1,5 +1,5 @@ - +