feat: add user management screen

feat: login screen

fix: login routing and layout

feat: add users migration

feat: passport login strategy

fix: users migration

feat: simple authentication

fix: request body

feat: JWT authorization

feat: 2fa step on login

feat: 2fa flow

feat: add rememberme to req body

fix: hide 2fa secret from jwt

fix: block login access to logged in user

fix: rerouting to wizard

refactor: login screen

feat: setup 2fa state on login

feat: 2fa secret qr code

fix: remove jwt from 2fa secret

fix: wizard redirect after login

fix: 2fa setup flow

fix: user id to uuid

feat: user roles

feat: user sessions and db persistence

feat: session saving on DB and cookie

refactor: unused code

feat: cookie auto renew on request

feat: get user data endpoint

fix: repeated requests

feat: react routing

fix: private routes

refactor: auth

feat: sessions aware of ua and ip

feat: sessions on gql

feat: session management screen

feat: replace user_tokens usage for users

feat: user deletion also deletes active sessions

feat: remember me alters session cookie accordingly

feat: last session by all users

fix: login feedback

fix: page loading UX

feat: routes based on user role

feat: header aware of roles

feat: reset password

fix: reset password endpoint

feat: handle password change

feat: reset 2FA

feat: user role on management screen

feat: change user role

fix: user last session query

fix: context

fix: destroy own session

feat: reset password now resets sessions

feat: reset 2fa now resets sessions

refactor: user data

refactor: user management screen

feat: user enable

feat: schema directives

fix: remove schema directive temp

feat: create new users

feat: register endpoint

feat: modals for reset links

fix: directive Date errors

feat: superuser directive

feat: create user url modal

fix: user management layout

feat: confirmation modals

fix: info text

feat: 2fa input component

feat: code input on 2fa state

feat: add button styling

feat: confirmation modal on superuser action

feat: rework 2fa setup screen

feat: rework reset 2fa screen

fix: session management screen

fix: user management screen

fix: blacklist roles

chore: migrate old customer values to new columns

fix: value migration

fix: value migration

refactor: remove old code
This commit is contained in:
Sérgio Salgado 2020-10-27 10:05:06 +00:00 committed by Josh Harvey
parent 368781864e
commit fded22f39a
50 changed files with 9839 additions and 4501 deletions

View file

@ -8,14 +8,20 @@ const cors = require('cors')
const helmet = require('helmet')
const nocache = require('nocache')
const cookieParser = require('cookie-parser')
const bodyParser = require('body-parser')
const { ApolloServer, AuthenticationError } = require('apollo-server-express')
const _ = require('lodash/fp')
const session = require('express-session')
const pgSession = require('connect-pg-simple')(session)
const { typeDefs, resolvers } = require('./graphql/schema')
const login = require('./services/login')
const register = require('./routes/authentication')
const options = require('../options')
const db = require('../db')
const users = require('../users')
const { typeDefs, resolvers, AuthDirective, SuperuserDirective } = require('./graphql/schema')
const devMode = require('minimist')(process.argv.slice(2)).dev
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
@ -32,11 +38,35 @@ app.use(helmet())
app.use(compression())
app.use(nocache())
app.use(cookieParser())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true })) // support encoded bodies
app.use(express.static(path.resolve(__dirname, '..', '..', 'public')))
app.use(['*'], session({
store: new pgSession({
pgPromise: db,
tableName: 'user_sessions'
}),
name: 'lid',
secret: 'MY_SECRET',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
domain: hostname,
sameSite: true,
maxAge: 60 * 10 * 1000 // 10 minutes
}
}))
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
schemaDirectives: {
auth: AuthDirective,
superuser: SuperuserDirective
},
playground: false,
introspection: false,
formatError: error => {
@ -44,10 +74,19 @@ const apolloServer = new ApolloServer({
return error
},
context: async ({ req }) => {
const token = req.cookies && req.cookies.token
if (!req.session.user) throw new AuthenticationError('Authentication failed')
const user = await users.verifyAndUpdateUser(
req.session.user.id,
req.headers['user-agent'] || 'Unknown',
req.ip
)
if (!user || !user.enabled) throw new AuthenticationError('Authentication failed')
const success = await login.authenticate(token)
if (!success) throw new AuthenticationError('Authentication failed')
req.session.ua = req.headers['user-agent'] || 'Unknown'
req.session.ipAddress = req.ip
req.session.lastUsed = new Date(Date.now()).toISOString()
req.session.user.id = user.id
req.session.user.role = user.role
return { req: { ...req } }
}
})
@ -67,6 +106,8 @@ app.use('/id-card-photo', serveStatic(idPhotoCardBasedir, { index: false }))
app.use('/front-camera-photo', serveStatic(frontCameraBasedir, { index: false }))
app.use('/api', register)
require('./routes/auth')(app)
// Everything not on graphql or api/register is redirected to the front-end
app.get('*', (req, res) => res.sendFile(path.resolve(__dirname, '..', '..', 'public', 'index.html')))

View file

@ -0,0 +1,259 @@
const otplib = require('otplib')
const bcrypt = require('bcrypt')
const users = require('../../users')
const login = require('../login')
async function isValidUser (username, password) {
const hashedPassword = await login.checkUser(username)
if (!hashedPassword) return false
const isMatch = await bcrypt.compare(password, hashedPassword)
if (!isMatch) return false
const user = await login.validateUser(username, hashedPassword)
if (!user) return false
return user
}
module.exports = function (app) {
app.post('/api/login', function (req, res, next) {
const usernameInput = req.body.username
const passwordInput = req.body.password
isValidUser(usernameInput, passwordInput).then(user => {
if (!user) return res.sendStatus(403)
users.get2FASecret(user.id).then(user => {
const twoFASecret = user.twofa_code
if (twoFASecret) return res.status(200).json({ message: 'INPUT2FA' })
if (!twoFASecret) return res.status(200).json({ message: 'SETUP2FA' })
})
})
})
app.post('/api/login/2fa', function (req, res, next) {
const code = req.body.twoFACode
const username = req.body.username
const password = req.body.password
const rememberMeInput = req.body.rememberMe
isValidUser(username, password).then(user => {
if (!user) return res.sendStatus(403)
users.get2FASecret(user.id).then(user => {
const secret = user.twofa_code
const isCodeValid = otplib.authenticator.verify({ token: code, secret: secret })
if (!isCodeValid) return res.sendStatus(403)
const finalUser = { id: user.id, username: user.username, role: user.role }
req.session.user = finalUser
if (rememberMeInput) req.session.cookie.maxAge = 90 * 24 * 60 * 60 * 1000 // 90 days
return res.sendStatus(200)
})
})
})
app.post('/api/login/2fa/setup', function (req, res, next) {
const username = req.body.username
const password = req.body.password
// TODO: maybe check if the user already has a 2fa secret
isValidUser(username, password).then(user => {
if (!user) return res.sendStatus(403)
const secret = otplib.authenticator.generateSecret()
const otpauth = otplib.authenticator.keyuri(username, 'Lamassu Industries', secret)
return res.status(200).json({ secret, otpauth })
})
})
app.post('/api/login/2fa/save', function (req, res, next) {
const username = req.body.username
const password = req.body.password
const secret = req.body.secret
const code = req.body.code
isValidUser(username, password).then(user => {
if (!user || !secret) return res.sendStatus(403)
const isCodeValid = otplib.authenticator.verify({ token: code, secret: secret })
if (!isCodeValid) return res.sendStatus(403)
users.save2FASecret(user.id, secret)
return res.sendStatus(200)
})
})
app.get('/user-data', function (req, res, next) {
const lidCookie = req.cookies && req.cookies.lid
if (!lidCookie) {
res.sendStatus(403)
return
}
const user = req.session.user
return res.status(200).json({ message: 'Success', user: user })
})
app.post('/api/resetpassword', function (req, res, next) {
const userID = req.body.userID
users.findById(userID)
.then(user => {
if (!user) return res.sendStatus(403)
return users.createResetPasswordToken(user.id)
})
.then(token => {
return res.status(200).json({ token })
})
.catch(err => console.log(err))
})
app.get('/api/resetpassword', function (req, res, next) {
const token = req.query.t
if (!token) return res.sendStatus(400)
return users.validatePasswordResetToken(token)
.then(r => {
if (!r.success) return res.status(200).send('The link has expired')
return res.status(200).json({ userID: r.userID })
})
.catch(err => {
console.log(err)
res.sendStatus(400)
})
})
app.post('/api/updatepassword', function (req, res, next) {
const userID = req.body.userID
const newPassword = req.body.newPassword
users.findById(userID).then(user => {
if (req.session.user && user.id === req.session.user.id) req.session.destroy()
return users.updatePassword(user.id, newPassword)
}).then(() => {
res.sendStatus(200)
}).catch(err => {
console.log(err)
res.sendStatus(400)
})
})
app.post('/api/reset2fa', function (req, res, next) {
const userID = req.body.userID
users.findById(userID)
.then(user => {
if (!user) return res.sendStatus(403)
return users.createReset2FAToken(user.id)
})
.then(token => {
return res.status(200).json({ token })
})
.catch(err => console.log(err))
})
app.get('/api/reset2fa', function (req, res, next) {
const token = req.query.t
if (!token) return res.sendStatus(400)
return users.validate2FAResetToken(token)
.then(r => {
if (!r.success) return res.status(200).send('The link has expired')
return users.findById(r.userID)
})
.then(user => {
const secret = otplib.authenticator.generateSecret()
const otpauth = otplib.authenticator.keyuri(user.username, 'Lamassu Industries', secret)
return res.status(200).json({ userID: user.id, secret, otpauth })
})
.catch(err => {
console.log(err)
res.sendStatus(400)
})
})
app.post('/api/update2fa', function (req, res, next) {
const userID = req.body.userID
const secret = req.body.secret
const code = req.body.code
users.findById(userID).then(user => {
const isCodeValid = otplib.authenticator.verify({ token: code, secret: secret })
if (!isCodeValid) return res.sendStatus(401)
if (req.session.user && user.id === req.session.user.id) req.session.destroy()
users.save2FASecret(user.id, secret).then(() => { return res.sendStatus(200) })
}).catch(err => {
console.log(err)
return res.sendStatus(400)
})
})
app.post('/api/createuser', function (req, res, next) {
const username = req.body.username
const role = req.body.role
users.getByName(username)
.then(user => {
if (user) return res.status(200).json({ message: 'User already exists!' })
users.createUserRegistrationToken(username, role).then(token => {
return res.status(200).json({ token })
})
})
.catch(err => {
console.log(err)
res.sendStatus(400)
})
})
app.get('/api/register', function (req, res, next) {
const token = req.query.t
if (!token) return res.sendStatus(400)
users.validateUserRegistrationToken(token)
.then(r => {
if (!r.success) return res.status(200).json({ message: 'The link has expired' })
return res.status(200).json({ username: r.username, role: r.role })
})
.catch(err => {
console.log(err)
res.sendStatus(400)
})
})
app.post('/api/register', function (req, res, next) {
const username = req.body.username
const password = req.body.password
const role = req.body.role
users.getByName(username)
.then(user => {
if (user) return res.status(200).json({ message: 'User already exists!' })
users.createUser(username, password, role)
res.sendStatus(200)
})
.catch(err => {
console.log(err)
res.sendStatus(400)
})
})
app.post('/api/confirm2fa', function (req, res, next) {
const code = req.body.code
const requestingUser = req.session.user
if (!requestingUser) return res.status(403)
users.get2FASecret(requestingUser.id).then(user => {
const secret = user.twofa_code
const isCodeValid = otplib.authenticator.verify({ token: code, secret: secret })
if (!isCodeValid) return res.sendStatus(401)
return res.sendStatus(200)
})
})
}

View file

@ -1,48 +1,20 @@
const crypto = require('crypto')
const db = require('../../db')
function generateOTP (name) {
const otp = crypto.randomBytes(32).toString('hex')
const sql = 'insert into one_time_passes (token, name) values ($1, $2)'
return db.none(sql, [otp, name])
.then(() => otp)
function checkUser (username) {
const sql = 'select * from users where username=$1'
return db.oneOrNone(sql, [username]).then(value => { return value.password }).catch(() => false)
}
function validateOTP (otp) {
const sql = `delete from one_time_passes
where token=$1
returning name, created < now() - interval '1 hour' as expired`
function validateUser (username, password) {
const sql = 'select id, username from users where username=$1 and password=$2'
const sqlUpdateLastAccessed = 'update users set last_accessed = now() where username=$1'
return db.one(sql, [otp])
.then(r => ({ success: !r.expired, expired: r.expired, name: r.name }))
.catch(() => ({ success: false, expired: false }))
}
function register (otp, ua, ip) {
return validateOTP(otp)
.then(r => {
if (!r.success) return r
const token = crypto.randomBytes(32).toString('hex')
const sql = 'insert into user_tokens (token, name, user_agent, ip_address) values ($1, $2, $3, $4)'
return db.none(sql, [token, r.name, ua, ip])
.then(() => ({ success: true, token: token }))
})
.catch(() => ({ success: false, expired: false }))
}
function authenticate (token) {
const sql = 'select token from user_tokens where token=$1'
return db.one(sql, [token]).then(() => true).catch(() => false)
return db.oneOrNone(sql, [username, password])
.then(user => { db.none(sqlUpdateLastAccessed, [user.username]); return user })
.catch(() => false)
}
module.exports = {
generateOTP,
register,
authenticate
checkUser,
validateUser
}

42
lib/session-manager.js Normal file
View file

@ -0,0 +1,42 @@
const db = require('./db')
function getSessionList () {
const sql = `select * from user_sessions order by sess -> 'user' ->> 'username'`
return db.any(sql)
}
function getLastSessionByUser () {
const sql = `select b.username, a.user_agent, a.ip_address, a.last_used, b.role from (
select sess -> 'user' ->> 'username' as username,
sess ->> 'ua' as user_agent,
sess ->> 'ipAddress' as ip_address,
sess ->> 'lastUsed' as last_used
from user_sessions
) a right join (
select distinct on (username)
username, role
from users) b on a.username = b.username`
return db.any(sql)
}
function getUserSessions (username) {
const sql = `select * from user_sessions where sess -> 'user' ->> 'username'=$1`
return db.any(sql, [username])
}
function getSession (sessionID) {
const sql = `select * from user_sessions where sid=$1`
return db.any(sql, [sessionID])
}
function deleteUserSessions (username) {
const sql = `delete from user_sessions where sess -> 'user' ->> 'username'=$1`
return db.none(sql, [username])
}
function deleteSession (sessionID) {
const sql = `delete from user_sessions where sid=$1`
return db.none(sql, [sessionID])
}
module.exports = { getSessionList, getLastSessionByUser, getUserSessions, getSession, deleteUserSessions, deleteSession }

View file

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

View file

@ -1,5 +1,8 @@
const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const crypto = require('crypto')
const bcrypt = require('bcrypt')
const uuid = require('uuid')
const db = require('./db')
@ -33,4 +36,143 @@ function getByIds (tokens) {
const tokensClause = _.map(pgp.as.text, tokens).join(',')
return db.any(sql, [tokensClause])
}
module.exports = { get, getByIds }
function getUsers () {
const sql = `select id, username, role, enabled, last_accessed, last_accessed_from, last_accessed_address from users order by username`
return db.any(sql)
}
function getByName (username) {
const sql = `select id, username, role, last_accessed from users where username=$1 limit 1`
return db.oneOrNone(sql, [username])
}
function verifyAndUpdateUser (id, ua, ip) {
const sql = `select id, username, role, enabled from users where id=$1 limit 1`
return db.oneOrNone(sql, [id]).then(user => {
if (!user) return null
const sql2 = `update users set last_accessed=now(), last_accessed_from=$1, last_accessed_address=$2 where id=$3 returning id, role, enabled`
return db.one(sql2, [ua, ip, id]).then(user => {
return user
})
})
}
function createUser (username, password, role) {
const sql = `insert into users (id, username, password, role) values ($1, $2, $3, $4)`
bcrypt.hash(password, 12).then(function (hash) {
return db.none(sql, [uuid.v4(), username, hash, role])
})
}
function deleteUser (id) {
const sql = `delete from users where id=$1`
const sql2 = `delete from user_sessions where sess -> 'user' ->> 'id'=$1`
return db.none(sql, [id]).then(() => db.none(sql2, [id]))
}
function findById (id) {
const sql = 'select id, username from users where id=$1'
return db.oneOrNone(sql, [id])
}
function get2FASecret (id) {
const sql = 'select id, username, twofa_code, role from users where id=$1'
return db.oneOrNone(sql, [id])
}
function save2FASecret (id, secret) {
const sql = 'update users set twofa_code=$1 where id=$2'
const sql2 = `delete from user_sessions where sess -> 'user' ->> 'id'=$1`
return db.none(sql, [secret, id]).then(() => db.none(sql2, [id]))
}
function validate2FAResetToken (token) {
const sql = `delete from reset_twofa
where token=$1
returning user_id, now() < expire as success`
return db.one(sql, [token])
.then(res => ({ userID: res.user_id, success: res.success }))
}
function createReset2FAToken (userID) {
const token = crypto.randomBytes(32).toString('hex')
const sql = `insert into reset_twofa (token, user_id) values ($1, $2) on conflict (user_id) do update set token=$1, expire=now() + interval '30 minutes' returning *`
return db.one(sql, [token, userID])
}
function updatePassword (id, password) {
bcrypt.hash(password, 12).then(function (hash) {
const sql = `update users set password=$1 where id=$2`
const sql2 = `delete from user_sessions where sess -> 'user' ->> 'id'=$1`
return db.none(sql, [hash, id]).then(() => db.none(sql2, [id]))
})
}
function validatePasswordResetToken (token) {
const sql = `delete from reset_password
where token=$1
returning user_id, now() < expire as success`
return db.one(sql, [token])
.then(res => ({ userID: res.user_id, success: res.success }))
}
function createResetPasswordToken (userID) {
const token = crypto.randomBytes(32).toString('hex')
const sql = `insert into reset_password (token, user_id) values ($1, $2) on conflict (user_id) do update set token=$1, expire=now() + interval '30 minutes' returning *`
return db.one(sql, [token, userID])
}
function createUserRegistrationToken (username, role) {
const token = crypto.randomBytes(32).toString('hex')
const sql = `insert into user_register_tokens (token, username, role) values ($1, $2, $3) on conflict (username)
do update set token=$1, expire=now() + interval '30 minutes' returning *`
return db.one(sql, [token, username, role])
}
function validateUserRegistrationToken (token) {
const sql = `delete from user_register_tokens where token=$1
returning username, role, now() < expire as success`
return db.one(sql, [token])
.then(res => ({ username: res.username, role: res.role, success: res.success }))
}
function changeUserRole (id, newRole) {
const sql = `update users set role=$1 where id=$2`
return db.none(sql, [newRole, id])
}
function toggleUserEnable (id) {
const sql = `update users set enabled=not enabled where id=$1`
return db.none(sql, [id])
}
module.exports = {
get,
getByIds,
getUsers,
getByName,
verifyAndUpdateUser,
createUser,
deleteUser,
findById,
updatePassword,
get2FASecret,
save2FASecret,
validate2FAResetToken,
createReset2FAToken,
validatePasswordResetToken,
createResetPasswordToken,
createUserRegistrationToken,
validateUserRegistrationToken,
changeUserRole,
toggleUserEnable
}

View file

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

View file

@ -0,0 +1,93 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`create type role as ENUM('user', 'superuser')`,
`create table users (
id uuid PRIMARY KEY,
username varchar(50) UNIQUE,
password varchar(100),
role role default 'user',
enabled boolean default true,
twofa_code varchar(100),
created timestamptz not null default now(),
last_accessed timestamptz not null default now(),
last_accessed_from text,
last_accessed_address inet )`,
`CREATE TABLE "user_sessions" (
"sid" varchar NOT NULL COLLATE "default",
"sess" json NOT NULL,
"expire" timestamp(6) NOT NULL )
WITH (OIDS=FALSE)`,
`ALTER TABLE "user_sessions" ADD CONSTRAINT "session_pkey" PRIMARY KEY ("sid") NOT DEFERRABLE INITIALLY IMMEDIATE`,
`CREATE INDEX "IDX_session_expire" ON "user_sessions" ("expire")`,
`create table reset_password (
token text not null PRIMARY KEY,
user_id uuid references users(id) on delete cascade unique,
expire timestamptz not null default now() + interval '30 minutes'
)`,
`create index "idx_reset_pw_expire" on "reset_password" ("expire")`,
`create table reset_twofa (
token text not null PRIMARY KEY,
user_id uuid references users(id) on delete cascade unique,
expire timestamptz not null default now() + interval '30 minutes'
)`,
`create index "idx_reset_twofa_expire" on "reset_twofa" ("expire")`,
`create table user_register_tokens (
token text not null PRIMARY KEY,
username text not null unique,
role role default 'user',
expire timestamptz not null default now() + interval '30 minutes'
)`,
// migrate values from customers which reference user_tokens for data persistence
`alter table customers add column sms_override_by_old text`,
`alter table customers add column id_card_data_override_by_old text`,
`alter table customers add column id_card_photo_override_by_old text`,
`alter table customers add column front_camera_override_by_old text`,
`alter table customers add column sanctions_override_by_old text`,
`alter table customers add column authorized_override_by_old text`,
`alter table customers add column us_ssn_override_by_old text`,
`update customers set sms_override_by_old=ut.name from user_tokens ut
where customers.sms_override_by=ut.token`,
`update customers set id_card_data_override_by_old=ut.name from user_tokens ut
where customers.id_card_data_override_by=ut.token`,
`update customers set id_card_photo_override_by_old=ut.name from user_tokens ut
where customers.id_card_photo_override_by=ut.token`,
`update customers set front_camera_override_by_old=ut.name from user_tokens ut
where customers.front_camera_override_by=ut.token`,
`update customers set sanctions_override_by_old=ut.name from user_tokens ut
where customers.sanctions_override_by=ut.token`,
`update customers set authorized_override_by_old=ut.name from user_tokens ut
where customers.authorized_override_by=ut.token`,
`update customers set us_ssn_override_by_old=ut.name from user_tokens ut
where customers.us_ssn_override_by=ut.token`,
`alter table customers drop column sms_override_by`,
`alter table customers drop column id_card_data_override_by`,
`alter table customers drop column id_card_photo_override_by`,
`alter table customers drop column front_camera_override_by`,
`alter table customers drop column sanctions_override_by`,
`alter table customers drop column authorized_override_by`,
`alter table customers drop column us_ssn_override_by`,
`alter table customers add column sms_override_by uuid references users(id)`,
`alter table customers add column id_card_data_override_by uuid references users(id)`,
`alter table customers add column id_card_photo_override_by uuid references users(id)`,
`alter table customers add column front_camera_override_by uuid references users(id)`,
`alter table customers add column sanctions_override_by uuid references users(id)`,
`alter table customers add column authorized_override_by uuid references users(id)`,
`alter table customers add column us_ssn_override_by uuid references users(id)`,
// migrate values from compliance_overrides which reference user_tokens for data persistence
`alter table compliance_overrides add column override_by_old text`,
`update compliance_overrides set override_by_old=ut.name from user_tokens ut
where compliance_overrides.override_by=ut.token`,
`alter table compliance_overrides drop column override_by`,
`alter table compliance_overrides add column override_by uuid references users(id)`,
`drop table if exists one_time_passes`,
`drop table if exists user_tokens`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,7 @@
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "^16.10.2",
"react-number-format": "^4.4.1",
"react-otp-input": "^2.3.0",
"react-router-dom": "5.1.2",
"react-use": "15.3.2",
"react-virtualized": "^9.21.2",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -0,0 +1,40 @@
import { makeStyles } from '@material-ui/core'
import classnames from 'classnames'
import React from 'react'
import OtpInput from 'react-otp-input'
import styles from './CodeInput.styles'
const useStyles = makeStyles(styles)
const CodeInput = ({
name,
value,
onChange,
numInputs,
error,
inputStyle,
containerStyle,
...props
}) => {
const classes = useStyles()
return (
<OtpInput
id={name}
value={value}
onChange={onChange}
numInputs={numInputs}
separator={<span> </span>}
containerStyle={classnames(containerStyle, classes.container)}
inputStyle={classnames(inputStyle, classes.input)}
focusStyle={classes.focus}
errorStyle={classes.error}
hasErrored={error}
isInputNum={true}
{...props}
/>
)
}
export default CodeInput

View file

@ -0,0 +1,33 @@
import {
fontPrimary,
primaryColor,
zircon,
errorColor
} from 'src/styling/variables'
const styles = {
input: {
width: '3.5rem !important',
height: '5rem',
fontFamily: fontPrimary,
fontSize: 35,
color: primaryColor,
border: '2px solid',
borderColor: zircon,
borderRadius: '4px'
},
focus: {
color: primaryColor,
border: '2px solid',
borderColor: primaryColor,
borderRadius: '4px'
},
error: {
borderColor: errorColor
},
container: {
justifyContent: 'space-evenly'
}
}
export default styles

View file

@ -1,5 +1,6 @@
import Autocomplete from './Autocomplete'
import Checkbox from './Checkbox'
import CodeInput from './CodeInput'
import NumberInput from './NumberInput'
import RadioGroup from './RadioGroup'
import SecretInput from './SecretInput'
@ -8,6 +9,7 @@ import TextInput from './TextInput'
export {
Checkbox,
CodeInput,
TextInput,
NumberInput,
Switch,

View file

@ -1,5 +1,6 @@
import Autocomplete from './base/Autocomplete'
import Checkbox from './base/Checkbox'
import CodeInput from './base/CodeInput'
import RadioGroup from './base/RadioGroup'
import Select from './base/Select'
import Switch from './base/Switch'
@ -10,6 +11,7 @@ export {
Autocomplete,
TextInput,
Checkbox,
CodeInput,
Switch,
Select,
RadioGroup,

View file

@ -59,7 +59,7 @@ const Subheader = ({ item, classes }) => {
const notNil = R.compose(R.not, R.isNil)
const Header = memo(({ tree }) => {
const Header = memo(({ tree, user }) => {
const [open, setOpen] = useState(false)
const [anchorEl, setAnchorEl] = useState(null)
const [notifButtonCoords, setNotifButtonCoords] = useState({ x: 0, y: 0 })
@ -119,24 +119,35 @@ const Header = memo(({ tree }) => {
</div>
<nav className={classes.nav}>
<ul className={classes.ul}>
{tree.map((it, idx) => (
<NavLink
key={idx}
to={it.route || it.children[0].route}
isActive={match => {
if (!match) return false
setActive(it)
return true
}}
className={classnames(classes.link, classes.whiteLink)}
activeClassName={classes.activeLink}>
<li className={classes.li}>
<span className={classes.forceSize} forcesize={it.label}>
{it.label}
</span>
</li>
</NavLink>
))}
{tree.map((it, idx) => {
if (
!R.includes(
user.role,
it.allowedRoles.map(v => {
return v.key
})
)
)
return <></>
return (
<NavLink
key={idx}
to={it.route || it.children[0].route}
isActive={match => {
if (!match) return false
setActive(it)
return true
}}
className={classnames(classes.link, classes.whiteLink)}
activeClassName={classes.activeLink}>
<li className={classes.li}>
<span className={classes.forceSize} forcesize={it.label}>
{it.label}
</span>
</li>
</NavLink>
)
})}
</ul>
</nav>
<div className={classes.actionButtonsContainer}>

View file

@ -1,15 +1,15 @@
import CssBaseline from '@material-ui/core/CssBaseline'
import Grid from '@material-ui/core/Grid'
import Slide from '@material-ui/core/Slide'
import {
StylesProvider,
jssPreset,
MuiThemeProvider,
makeStyles
} from '@material-ui/core/styles'
import { axios } from '@use-hooks/axios'
import { create } from 'jss'
import extendJss from 'jss-plugin-extend'
import React, { useContext, useState } from 'react'
import React, { createContext, useContext, useEffect, useState } from 'react'
import {
useLocation,
useHistory,
@ -17,15 +17,14 @@ import {
} from 'react-router-dom'
import AppContext from 'src/AppContext'
import Header from 'src/components/layout/Header'
import Sidebar from 'src/components/layout/Sidebar'
import TitleSection from 'src/components/layout/TitleSection'
import ApolloProvider from 'src/utils/apollo'
import Header from '../components/layout/Header'
import { tree, hasSidebar, Routes, getParent } from '../routing/routes'
import global from '../styling/global'
import theme from '../styling/theme'
import { backgroundColor, mainWidth } from '../styling/variables'
import ApolloProvider from 'src/pazuz/apollo/Provider'
import { tree, hasSidebar, Routes, getParent } from 'src/pazuz/routing/routes'
import global from 'src/styling/global'
import theme from 'src/styling/theme'
import { backgroundColor, mainWidth } from 'src/styling/variables'
if (process.env.NODE_ENV !== 'production') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
@ -74,7 +73,7 @@ const Main = () => {
const classes = useStyles()
const location = useLocation()
const history = useHistory()
const { wizardTested } = useContext(AppContext)
const { wizardTested, userData } = useContext(AppContext)
const route = location.pathname
@ -93,20 +92,12 @@ const Main = () => {
return (
<div className={classes.root}>
{!is404 && wizardTested && <Header tree={tree} />}
{!is404 && wizardTested && userData && (
<Header tree={tree} user={userData} />
)}
<main className={classes.wrapper}>
{sidebar && !is404 && wizardTested && (
<Slide
direction="left"
in={true}
mountOnEnter
unmountOnExit
children={
<div>
<TitleSection title={parent.title}></TitleSection>
</div>
}
/>
<TitleSection title={parent.title}></TitleSection>
)}
<Grid container className={classes.grid}>
@ -129,19 +120,47 @@ const Main = () => {
const App = () => {
const [wizardTested, setWizardTested] = useState(false)
const [userData, setUserData] = useState(null)
const [loading, setLoading] = useState(true)
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
useEffect(() => {
getUserData()
}, [])
const getUserData = () => {
axios({
method: 'GET',
url: `${url}/user-data`,
withCredentials: true
})
.then(res => {
setLoading(false)
if (res.status === 200) setUserData(res.data.user)
})
.catch(err => {
setLoading(false)
if (err.status === 403) setUserData(null)
})
}
return (
<AppContext.Provider value={{ wizardTested, setWizardTested }}>
<Router>
<ApolloProvider>
<StylesProvider jss={jss}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
<Main />
</MuiThemeProvider>
</StylesProvider>
</ApolloProvider>
</Router>
<AppContext.Provider
value={{ wizardTested, setWizardTested, userData, setUserData }}>
{!loading && (
<Router>
<ApolloProvider>
<StylesProvider jss={jss}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
<Main />
</MuiThemeProvider>
</StylesProvider>
</ApolloProvider>
</Router>
)}
</AppContext.Provider>
)
}

View file

@ -0,0 +1,115 @@
import { makeStyles } from '@material-ui/core/styles'
import axios from 'axios'
import React, { useContext, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { AppContext } from 'src/App'
import { Button } from 'src/components/buttons'
import { CodeInput } from 'src/components/inputs/base'
import { H2, P } from 'src/components/typography'
import styles from './Login.styles'
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const useStyles = makeStyles(styles)
const Input2FAState = ({
twoFAField,
onTwoFAChange,
clientField,
passwordField,
rememberMeField
}) => {
const classes = useStyles()
const history = useHistory()
const { setUserData } = useContext(AppContext)
const [invalidToken, setInvalidToken] = useState(false)
const handle2FAChange = value => {
onTwoFAChange(value)
setInvalidToken(false)
}
const handle2FA = () => {
axios({
method: 'POST',
url: `${url}/api/login/2fa`,
data: {
username: clientField,
password: passwordField,
rememberMe: rememberMeField,
twoFACode: twoFAField
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res) {
const status = res.status
if (status === 200) {
getUserData()
history.push('/')
}
}
})
.catch(err => {
if (err.response && err.response.data) {
if (err.response.status === 403) {
onTwoFAChange('')
setInvalidToken(true)
}
}
})
}
const getUserData = () => {
axios({
method: 'GET',
url: `${url}/user-data`,
withCredentials: true
})
.then(res => {
if (res.status === 200) setUserData(res.data.user)
})
.catch(err => {
if (err.status === 403) setUserData(null)
})
}
return (
<>
<H2 className={classes.info}>
Enter your two-factor authentication code
</H2>
<CodeInput
name="2fa"
value={twoFAField}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
/>
<div className={classes.twofaFooter}>
{invalidToken && (
<P className={classes.errorMessage}>
Code is invalid. Please try again.
</P>
)}
<Button
onClick={() => {
handle2FA()
}}
buttonClassName={classes.loginButton}>
Login
</Button>
</div>
</>
)
}
export default Input2FAState

View file

@ -0,0 +1,30 @@
import { makeStyles, Grid } from '@material-ui/core'
import React from 'react'
import styles from './Login.styles'
import LoginCard from './LoginCard'
const useStyles = makeStyles(styles)
const Login = () => {
const classes = useStyles()
return (
<>
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justify="center"
style={{ minHeight: '100vh' }}
className={classes.welcomeBackground}>
<Grid>
<LoginCard />
</Grid>
</Grid>
</>
)
}
export default Login

View file

@ -0,0 +1,121 @@
import {
fontPrimary,
fontSecondary,
primaryColor,
backgroundColor,
errorColor
} from 'src/styling/variables'
const styles = {
title: {
color: primaryColor,
fontFamily: fontPrimary,
fontSize: '18px',
fontWeight: 'normal',
paddingTop: 8
},
inputLabel: {
fontSize: '16px',
fontWeight: 550
},
input: {
marginBottom: 25,
marginTop: -15
},
wrapper: {
padding: '2.5em 4em',
width: 575,
display: 'flex',
flexDirection: 'column'
},
titleWrapper: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
marginBottom: 30
},
rememberMeWrapper: {
display: 'flex',
flexDirection: 'row'
},
icon: {
transform: 'scale(1.5)',
marginRight: 25
},
checkbox: {
transform: 'scale(2)',
marginRight: 5,
marginLeft: -5
},
footer: {
marginTop: '10vh'
},
twofaFooter: {
marginTop: '6vh'
},
loginButton: {
display: 'block',
width: '100%'
},
welcomeBackground: {
background: 'url(/wizard-background.svg) no-repeat center center fixed',
backgroundColor: backgroundColor,
backgroundSize: 'cover',
// filter: 'blur(4px)',
// pointerEvents: 'none',
height: '100vh',
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw'
},
info: {
fontFamily: fontSecondary,
marginBottom: '5vh'
},
info2: {
fontFamily: fontSecondary,
fontSize: '14px',
textAlign: 'justify'
},
infoWrapper: {
marginBottom: '3vh'
},
errorMessage: {
fontFamily: fontSecondary,
color: errorColor
},
qrCodeWrapper: {
display: 'flex',
justifyContent: 'center',
marginBottom: '3vh'
},
secretWrapper: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
},
secretLabel: {
fontSize: '16px',
fontWeight: 550,
marginRight: 15
},
secret: {
fontSize: '16px',
fontWeight: 550,
marginRight: 35
},
hiddenSecret: {
fontSize: '16px',
fontWeight: 550,
marginRight: 35,
filter: 'blur(8px)'
},
confirm2FAInput: {
marginTop: 25
}
}
export default styles

View file

@ -0,0 +1,104 @@
import Paper from '@material-ui/core/Paper'
import { makeStyles } from '@material-ui/core/styles'
import React, { useState } from 'react'
import { H2 } from 'src/components/typography'
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg'
import Input2FAState from './Input2FAState'
import styles from './Login.styles'
import LoginState from './LoginState'
import Setup2FAState from './Setup2FAState'
const useStyles = makeStyles(styles)
const STATES = {
LOGIN: 'Login',
SETUP_2FA: 'Setup 2FA',
INPUT_2FA: 'Input 2FA'
}
const LoginCard = () => {
const classes = useStyles()
const [twoFAField, setTwoFAField] = useState('')
const [clientField, setClientField] = useState('')
const [passwordField, setPasswordField] = useState('')
const [rememberMeField, setRememberMeField] = useState(false)
const [loginState, setLoginState] = useState(STATES.LOGIN)
const onClientChange = newValue => {
setClientField(newValue)
}
const onPasswordChange = newValue => {
setPasswordField(newValue)
}
const onRememberMeChange = newValue => {
setRememberMeField(newValue)
}
const onTwoFAChange = newValue => {
setTwoFAField(newValue)
}
const handleLoginState = newState => {
setLoginState(newState)
}
const renderState = () => {
switch (loginState) {
case STATES.LOGIN:
return (
<LoginState
clientField={clientField}
onClientChange={onClientChange}
passwordField={passwordField}
onPasswordChange={onPasswordChange}
rememberMeField={rememberMeField}
onRememberMeChange={onRememberMeChange}
STATES={STATES}
handleLoginState={handleLoginState}
/>
)
case STATES.INPUT_2FA:
return (
<Input2FAState
twoFAField={twoFAField}
onTwoFAChange={onTwoFAChange}
clientField={clientField}
passwordField={passwordField}
rememberMeField={rememberMeField}
/>
)
case STATES.SETUP_2FA:
return (
<Setup2FAState
clientField={clientField}
passwordField={passwordField}
STATES={STATES}
handleLoginState={handleLoginState}
/>
)
default:
break
}
}
return (
<div>
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<H2 className={classes.title}>Lamassu Admin</H2>
</div>
{renderState()}
</div>
</Paper>
</div>
)
}
export default LoginCard

View file

@ -0,0 +1,130 @@
import { makeStyles } from '@material-ui/core/styles'
import axios from 'axios'
import React, { useState } from 'react'
import { Button } from 'src/components/buttons'
import { Checkbox, TextInput } from 'src/components/inputs/base'
import { Label2, P } from 'src/components/typography'
import styles from './Login.styles'
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const useStyles = makeStyles(styles)
const LoginState = ({
clientField,
onClientChange,
passwordField,
onPasswordChange,
rememberMeField,
onRememberMeChange,
STATES,
handleLoginState
}) => {
const classes = useStyles()
const [invalidLogin, setInvalidLogin] = useState(false)
const handleClientChange = event => {
onClientChange(event.target.value)
setInvalidLogin(false)
}
const handlePasswordChange = event => {
onPasswordChange(event.target.value)
setInvalidLogin(false)
}
const handleRememberMeChange = () => {
onRememberMeChange(!rememberMeField)
}
const handleLogin = () => {
axios({
method: 'POST',
url: `${url}/api/login`,
data: {
username: clientField,
password: passwordField,
rememberMe: rememberMeField
},
options: {
withCredentials: true
},
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res) {
const status = res.status
const message = res.data.message
if (status === 200 && message === 'INPUT2FA')
handleLoginState(STATES.INPUT_2FA)
if (status === 200 && message === 'SETUP2FA')
handleLoginState(STATES.SETUP_2FA)
}
})
.catch(err => {
if (err.response && err.response.data) {
if (err.response.status === 403) setInvalidLogin(true)
}
})
}
return (
<>
<Label2 className={classes.inputLabel}>Client</Label2>
<TextInput
className={classes.input}
error={invalidLogin}
name="client-name"
autoFocus
id="client-name"
type="text"
size="lg"
onChange={handleClientChange}
value={clientField}
/>
<Label2 className={classes.inputLabel}>Password</Label2>
<TextInput
className={classes.input}
error={invalidLogin}
name="password"
id="password"
type="password"
size="lg"
onChange={handlePasswordChange}
value={passwordField}
/>
<div className={classes.rememberMeWrapper}>
<Checkbox
className={classes.checkbox}
id="remember-me"
onChange={handleRememberMeChange}
value={rememberMeField}
/>
<Label2 className={classes.inputLabel}>Keep me logged in</Label2>
</div>
<div className={classes.footer}>
{invalidLogin && (
<P className={classes.errorMessage}>
Invalid login/password combination.
</P>
)}
<Button
onClick={() => {
handleLogin()
}}
buttonClassName={classes.loginButton}>
Login
</Button>
</div>
</>
)
}
export default LoginState

View file

@ -0,0 +1,180 @@
import { makeStyles, Grid } from '@material-ui/core'
import Paper from '@material-ui/core/Paper'
import axios from 'axios'
import React, { useState, useEffect } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/base'
import { H2, Label2, P } from 'src/components/typography'
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg'
import styles from './Login.styles'
const useQuery = () => new URLSearchParams(useLocation().search)
const useStyles = makeStyles(styles)
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const Register = () => {
const classes = useStyles()
const history = useHistory()
const query = useQuery()
const [passwordField, setPasswordField] = useState('')
const [confirmPasswordField, setConfirmPasswordField] = useState('')
const [invalidPassword, setInvalidPassword] = useState(false)
const [username, setUsername] = useState(null)
const [role, setRole] = useState(null)
const [isLoading, setLoading] = useState(true)
const [wasSuccessful, setSuccess] = useState(false)
useEffect(() => {
validateQuery()
}, [])
const validateQuery = () => {
axios({
url: `${url}/api/register?t=${query.get('t')}`,
method: 'GET',
options: {
withCredentials: true
}
})
.then((res, err) => {
if (err) return
if (res && res.status === 200) {
setLoading(false)
if (res.data === 'The link has expired') setSuccess(false)
else {
setSuccess(true)
setUsername(res.data.username)
setRole(res.data.role)
}
}
})
.catch(err => {
console.log(err)
history.push('/')
})
}
const handleRegister = () => {
if (!isValidPassword()) return setInvalidPassword(true)
axios({
url: `${url}/api/register`,
method: 'POST',
data: {
username: username,
password: passwordField,
role: role
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res && res.status === 200) {
history.push('/wizard', { fromAuthRegister: true })
}
})
.catch(err => {
console.log(err)
history.push('/')
})
}
const isValidPassword = () => {
return passwordField === confirmPasswordField
}
const handlePasswordChange = event => {
setInvalidPassword(false)
setPasswordField(event.target.value)
}
const handleConfirmPasswordChange = event => {
setInvalidPassword(false)
setConfirmPasswordField(event.target.value)
}
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justify="center"
style={{ minHeight: '100vh' }}
className={classes.welcomeBackground}>
<Grid>
<div>
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<H2 className={classes.title}>Lamassu Admin</H2>
</div>
{!isLoading && wasSuccessful && (
<>
<Label2 className={classes.inputLabel}>
Insert a password
</Label2>
<TextInput
className={classes.input}
error={invalidPassword}
name="new-password"
autoFocus
id="new-password"
type="password"
size="lg"
onChange={handlePasswordChange}
value={passwordField}
/>
<Label2 className={classes.inputLabel}>
Confirm password
</Label2>
<TextInput
className={classes.input}
error={invalidPassword}
name="confirm-password"
id="confirm-password"
type="password"
size="lg"
onChange={handleConfirmPasswordChange}
value={confirmPasswordField}
/>
<div className={classes.footer}>
{invalidPassword && (
<P className={classes.errorMessage}>
Passwords do not match!
</P>
)}
<Button
onClick={() => {
handleRegister()
}}
buttonClassName={classes.loginButton}>
Done
</Button>
</div>
</>
)}
{!isLoading && !wasSuccessful && (
<>
<Label2 className={classes.inputLabel}>
Link has expired
</Label2>
</>
)}
</div>
</Paper>
</div>
</Grid>
</Grid>
)
}
export default Register

View file

@ -0,0 +1,186 @@
import { makeStyles, Grid } from '@material-ui/core'
import Paper from '@material-ui/core/Paper'
import axios from 'axios'
import QRCode from 'qrcode.react'
import React, { useState, useEffect } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { ActionButton, Button } from 'src/components/buttons'
import { CodeInput } from 'src/components/inputs/base'
import { H2, Label2, P } from 'src/components/typography'
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg'
import { primaryColor } from 'src/styling/variables'
import styles from './Login.styles'
const useQuery = () => new URLSearchParams(useLocation().search)
const useStyles = makeStyles(styles)
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const Reset2FA = () => {
const classes = useStyles()
const history = useHistory()
const query = useQuery()
const [userID, setUserID] = useState(null)
const [isLoading, setLoading] = useState(true)
const [wasSuccessful, setSuccess] = useState(false)
const [secret, setSecret] = useState(null)
const [otpauth, setOtpauth] = useState(null)
const [isShowing, setShowing] = useState(false)
const [invalidToken, setInvalidToken] = useState(false)
const [twoFAConfirmation, setTwoFAConfirmation] = useState('')
const handle2FAChange = value => {
setTwoFAConfirmation(value)
setInvalidToken(false)
}
useEffect(() => {
validateQuery()
}, [])
const validateQuery = () => {
axios({
url: `${url}/api/reset2fa?t=${query.get('t')}`,
method: 'GET',
options: {
withCredentials: true
}
})
.then((res, err) => {
if (err) return
if (res && res.status === 200) {
setLoading(false)
if (res.data === 'The link has expired') setSuccess(false)
else {
setUserID(res.data.userID)
setSecret(res.data.secret)
setOtpauth(res.data.otpauth)
setSuccess(true)
}
}
})
.catch(err => {
console.log(err)
history.push('/')
})
}
const handle2FAReset = () => {
axios({
url: `${url}/api/update2fa`,
method: 'POST',
data: {
userID: userID,
secret: secret,
code: twoFAConfirmation
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res && res.status === 200) {
history.push('/')
}
})
.catch(err => {
console.log(err)
setInvalidToken(true)
})
}
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justify="center"
style={{ minHeight: '100vh' }}
className={classes.welcomeBackground}>
<Grid>
<div>
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<H2 className={classes.title}>Lamassu Admin</H2>
</div>
{!isLoading && wasSuccessful && (
<>
<div className={classes.infoWrapper}>
<Label2 className={classes.info2}>
To finish this process, please scan the following QR code
or insert the secret further below on an authentication
app of your choice, preferably Google Authenticator or
Authy.
</Label2>
</div>
<div className={classes.qrCodeWrapper}>
<QRCode size={240} fgColor={primaryColor} value={otpauth} />
</div>
<div className={classes.secretWrapper}>
<Label2 className={classes.secretLabel}>
Your secret:
</Label2>
<Label2
className={
isShowing ? classes.secret : classes.hiddenSecret
}>
{secret}
</Label2>
<ActionButton
color="primary"
onClick={() => {
setShowing(!isShowing)
}}>
{isShowing ? 'Hide' : 'Show'}
</ActionButton>
</div>
<div className={classes.confirm2FAInput}>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
/>
</div>
<div className={classes.twofaFooter}>
{invalidToken && (
<P className={classes.errorMessage}>
Code is invalid. Please try again.
</P>
)}
<Button
onClick={() => {
handle2FAReset()
}}
buttonClassName={classes.loginButton}>
Done
</Button>
</div>
</>
)}
{!isLoading && !wasSuccessful && (
<>
<Label2 className={classes.inputLabel}>
Link has expired
</Label2>
</>
)}
</div>
</Paper>
</div>
</Grid>
</Grid>
)
}
export default Reset2FA

View file

@ -0,0 +1,177 @@
import { makeStyles, Grid } from '@material-ui/core'
import Paper from '@material-ui/core/Paper'
import axios from 'axios'
import React, { useState, useEffect } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/base'
import { H2, Label2, P } from 'src/components/typography'
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg'
import styles from './Login.styles'
const useQuery = () => new URLSearchParams(useLocation().search)
const useStyles = makeStyles(styles)
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const ResetPassword = () => {
const classes = useStyles()
const history = useHistory()
const query = useQuery()
const [newPasswordField, setNewPasswordField] = useState('')
const [confirmPasswordField, setConfirmPasswordField] = useState('')
const [invalidPassword, setInvalidPassword] = useState(false)
const [userID, setUserID] = useState(null)
const [isLoading, setLoading] = useState(true)
const [wasSuccessful, setSuccess] = useState(false)
useEffect(() => {
validateQuery()
}, [])
const validateQuery = () => {
axios({
url: `${url}/api/resetpassword?t=${query.get('t')}`,
method: 'GET',
options: {
withCredentials: true
}
})
.then((res, err) => {
if (err) return
if (res && res.status === 200) {
setLoading(false)
if (res.data === 'The link has expired') setSuccess(false)
else {
setSuccess(true)
setUserID(res.data.userID)
}
}
})
.catch(err => {
console.log(err)
history.push('/')
})
}
const handlePasswordReset = () => {
if (!isValidPasswordChange()) return setInvalidPassword(true)
axios({
url: `${url}/api/updatepassword`,
method: 'POST',
data: {
userID: userID,
newPassword: newPasswordField
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res && res.status === 200) {
history.push('/')
}
})
.catch(err => {
console.log(err)
history.push('/')
})
}
const isValidPasswordChange = () => {
return newPasswordField === confirmPasswordField
}
const handleNewPasswordChange = event => {
setInvalidPassword(false)
setNewPasswordField(event.target.value)
}
const handleConfirmPasswordChange = event => {
setInvalidPassword(false)
setConfirmPasswordField(event.target.value)
}
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justify="center"
style={{ minHeight: '100vh' }}
className={classes.welcomeBackground}>
<Grid>
<div>
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<H2 className={classes.title}>Lamassu Admin</H2>
</div>
{!isLoading && wasSuccessful && (
<>
<Label2 className={classes.inputLabel}>
Insert new password
</Label2>
<TextInput
className={classes.input}
error={invalidPassword}
name="new-password"
autoFocus
id="new-password"
type="password"
size="lg"
onChange={handleNewPasswordChange}
value={newPasswordField}
/>
<Label2 className={classes.inputLabel}>
Confirm new password
</Label2>
<TextInput
className={classes.input}
error={invalidPassword}
name="confirm-password"
id="confirm-password"
type="password"
size="lg"
onChange={handleConfirmPasswordChange}
value={confirmPasswordField}
/>
<div className={classes.footer}>
{invalidPassword && (
<P className={classes.errorMessage}>
Passwords do not match!
</P>
)}
<Button
onClick={() => {
handlePasswordReset()
}}
buttonClassName={classes.loginButton}>
Done
</Button>
</div>
</>
)}
{!isLoading && !wasSuccessful && (
<>
<Label2 className={classes.inputLabel}>
Link has expired
</Label2>
</>
)}
</div>
</Paper>
</div>
</Grid>
</Grid>
)
}
export default ResetPassword

View file

@ -0,0 +1,180 @@
import { makeStyles } from '@material-ui/core/styles'
import axios from 'axios'
import QRCode from 'qrcode.react'
import React, { useState, useEffect } from 'react'
import { ActionButton, Button } from 'src/components/buttons'
import { CodeInput } from 'src/components/inputs/base'
import { Label2, P } from 'src/components/typography'
import { primaryColor } from 'src/styling/variables'
import styles from './Login.styles'
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const useStyles = makeStyles(styles)
const Setup2FAState = ({
clientField,
passwordField,
STATES,
handleLoginState
}) => {
const classes = useStyles()
const [secret, setSecret] = useState(null)
const [otpauth, setOtpauth] = useState(null)
const [isShowing, setShowing] = useState(false)
const [invalidToken, setInvalidToken] = useState(false)
const [twoFAConfirmation, setTwoFAConfirmation] = useState('')
const handle2FAChange = value => {
setTwoFAConfirmation(value)
setInvalidToken(false)
}
useEffect(() => {
get2FASecret()
}, [])
const get2FASecret = () => {
axios({
method: 'POST',
url: `${url}/api/login/2fa/setup`,
data: {
username: clientField,
password: passwordField
},
options: {
withCredentials: true
},
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res) {
setSecret(res.data.secret)
setOtpauth(res.data.otpauth)
}
})
.catch(err => {
if (err.response && err.response.data) {
if (err.response.status === 403) {
handleLoginState(STATES.LOGIN)
}
}
})
}
const save2FASecret = () => {
axios({
method: 'POST',
url: `${url}/api/login/2fa/save`,
data: {
username: clientField,
password: passwordField,
secret: secret,
code: twoFAConfirmation
},
options: {
withCredentials: true
},
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) console.log(err)
if (res) {
const status = res.status
if (status === 200) handleLoginState(STATES.LOGIN)
}
})
.catch(err => {
if (err.response && err.response.data) {
if (err.response.status === 403) {
setInvalidToken(true)
}
}
})
}
return (
<>
{secret && otpauth ? (
<>
<div className={classes.infoWrapper}>
<Label2 className={classes.info2}>
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.
</Label2>
<Label2 className={classes.info2}>
To finish this process, please scan the following QR code or
insert the secret further below on an authentication app of your
choice, preferably Google Authenticator or Authy.
</Label2>
</div>
<div className={classes.qrCodeWrapper}>
<QRCode size={240} fgColor={primaryColor} value={otpauth} />
</div>
<div className={classes.secretWrapper}>
<Label2 className={classes.secretLabel}>Your secret:</Label2>
<Label2
className={isShowing ? classes.secret : classes.hiddenSecret}>
{secret}
</Label2>
<ActionButton
disabled={!secret && !otpauth}
color="primary"
onClick={() => {
setShowing(!isShowing)
}}>
{isShowing ? 'Hide' : 'Show'}
</ActionButton>
</div>
<div className={classes.confirm2FAInput}>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
/>
</div>
<div className={classes.twofaFooter}>
{invalidToken && (
<P className={classes.errorMessage}>
Code is invalid. Please try again.
</P>
)}
<Button
onClick={() => {
save2FASecret()
}}
buttonClassName={classes.loginButton}>
Done
</Button>
</div>
</>
) : (
// TODO: should maybe show a spinner here?
<div className={classes.twofaFooter}>
<Button
onClick={() => {
console.log('response should be arriving soon')
}}
buttonClassName={classes.loginButton}>
Generate Two Factor Authentication Secret
</Button>
</div>
)}
</>
)
}
export default Setup2FAState

View file

@ -34,7 +34,12 @@ const GET_MACHINES = gql`
const NUM_LOG_RESULTS = 500
const GET_MACHINE_LOGS_CSV = gql`
query MachineLogs($deviceId: ID!, $limit: Int, $from: Date, $until: Date) {
query MachineLogs(
$deviceId: ID!
$limit: Int
$from: DateTime
$until: DateTime
) {
machineLogsCsv(
deviceId: $deviceId
limit: $limit
@ -45,7 +50,12 @@ const GET_MACHINE_LOGS_CSV = gql`
`
const GET_MACHINE_LOGS = gql`
query MachineLogs($deviceId: ID!, $limit: Int, $from: Date, $until: Date) {
query MachineLogs(
$deviceId: ID!
$limit: Int
$from: DateTime
$until: DateTime
) {
machineLogs(
deviceId: $deviceId
limit: $limit

View file

@ -61,13 +61,13 @@ const formatDate = date => {
const NUM_LOG_RESULTS = 500
const GET_CSV = gql`
query ServerData($limit: Int, $from: Date, $until: Date) {
query ServerData($limit: Int, $from: DateTime, $until: DateTime) {
serverLogsCsv(limit: $limit, from: $from, until: $until)
}
`
const GET_DATA = gql`
query ServerData($limit: Int, $from: Date, $until: Date) {
query ServerData($limit: Int, $from: DateTime, $until: DateTime) {
serverVersion
uptime {
name

View file

@ -0,0 +1,105 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import gql from 'graphql-tag'
import moment from 'moment'
import * as R from 'ramda'
import React from 'react'
import parser from 'ua-parser-js'
import { IconButton } from 'src/components/buttons'
import TitleSection from 'src/components/layout/TitleSection'
import DataTable from 'src/components/tables/DataTable'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
const GET_SESSIONS = gql`
query sessions {
sessions {
sid
sess
expire
}
}
`
const DELETE_SESSION = gql`
mutation deleteSession($sid: String!) {
deleteSession(sid: $sid) {
sid
}
}
`
const isLocalhost = ip => {
return ip === 'localhost' || ip === '::1' || ip === '127.0.0.1'
}
const SessionManagement = () => {
const { data: tknResponse } = useQuery(GET_SESSIONS)
const [deleteSession] = useMutation(DELETE_SESSION, {
refetchQueries: () => ['sessions']
})
const elements = [
{
header: 'Login',
width: 207,
textAlign: 'left',
size: 'sm',
view: s => s.sess.user.username
},
{
header: 'Last known use',
width: 305,
textAlign: 'center',
size: 'sm',
view: s => {
const ua = parser(s.sess.ua)
return s.sess.ua
? `${ua.browser.name} ${ua.browser.version} on ${ua.os.name} ${ua.os.version}`
: `No Record`
}
},
{
header: 'Last known location',
width: 250,
textAlign: 'center',
size: 'sm',
view: s => {
return isLocalhost(s.sess.ipAddress) ? 'This device' : s.sess.ipAddress
}
},
{
header: 'Expiration date (UTC)',
width: 290,
textAlign: 'right',
size: 'sm',
view: s =>
`${moment.utc(s.expire).format('YYYY-MM-DD')} ${moment
.utc(s.expire)
.format('HH:mm:ss')}`
},
{
header: '',
width: 80,
textAlign: 'center',
size: 'sm',
view: s => (
<IconButton
onClick={() => {
deleteSession({ variables: { sid: s.sid } })
}}>
<DeleteIcon />
</IconButton>
)
}
]
return (
<>
<TitleSection title="Session Management" />
<DataTable elements={elements} data={R.path(['sessions'])(tknResponse)} />
</>
)
}
export default SessionManagement

View file

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

View file

@ -24,13 +24,13 @@ const useStyles = makeStyles(mainStyles)
const NUM_LOG_RESULTS = 1000
const GET_TRANSACTIONS_CSV = gql`
query transactions($limit: Int, $from: Date, $until: Date) {
query transactions($limit: Int, $from: DateTime, $until: DateTime) {
transactionsCsv(limit: $limit, from: $from, until: $until)
}
`
const GET_TRANSACTIONS = gql`
query transactions($limit: Int, $from: Date, $until: Date) {
query transactions($limit: Int, $from: DateTime, $until: DateTime) {
transactions(limit: $limit, from: $from, until: $until) {
id
txClass

View file

@ -0,0 +1,386 @@
/* eslint-disable prettier/prettier */
import { useQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Box, Chip } from '@material-ui/core'
import axios from 'axios'
import gql from 'graphql-tag'
// import moment from 'moment'
import * as R from 'ramda'
import React, { useState, useContext } from 'react'
// import parser from 'ua-parser-js'
import { AppContext } from 'src/App'
import { Link /*, IconButton */ } from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import TitleSection from 'src/components/layout/TitleSection'
import DataTable from 'src/components/tables/DataTable'
// import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import styles from './UserManagement.styles'
import ChangeRoleModal from './modals/ChangeRoleModal'
import CreateUserModal from './modals/CreateUserModal'
// import DeleteUserModal from './modals/DeleteUserModal'
import EnableUserModal from './modals/EnableUserModal'
import Input2FAModal from './modals/Input2FAModal'
import Reset2FAModal from './modals/Reset2FAModal'
import ResetPasswordModal from './modals/ResetPasswordModal'
const useStyles = makeStyles(styles)
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const GET_USERS = gql`
query users {
users {
id
username
role
enabled
last_accessed
last_accessed_from
last_accessed_address
}
}
`
/* const DELETE_USERS = gql`
mutation deleteUser($id: ID!) {
deleteUser(id: $id) {
id
}
}
` */
const CHANGE_USER_ROLE = gql`
mutation changeUserRole($id: ID!, $newRole: String!) {
changeUserRole(id: $id, newRole: $newRole) {
id
}
}
`
const TOGGLE_USER_ENABLE = gql`
mutation toggleUserEnable($id: ID!) {
toggleUserEnable(id: $id) {
id
}
}
`
const Users = () => {
const classes = useStyles()
const { userData } = useContext(AppContext)
const { data: userResponse } = useQuery(GET_USERS)
/* const [deleteUser] = useMutation(DELETE_USERS, {
refetchQueries: () => ['users']
}) */
const [changeUserRole] = useMutation(CHANGE_USER_ROLE, {
refetchQueries: () => ['users']
})
const [toggleUserEnable] = useMutation(TOGGLE_USER_ENABLE, {
refetchQueries: () => ['users']
})
const [userInfo, setUserInfo] = useState(null)
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
const toggleCreateUserModal = () =>
setShowCreateUserModal(!showCreateUserModal)
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
const [resetPasswordUrl, setResetPasswordUrl] = useState('')
const toggleResetPasswordModal = () =>
setShowResetPasswordModal(!showResetPasswordModal)
const [showReset2FAModal, setShowReset2FAModal] = useState(false)
const [reset2FAUrl, setReset2FAUrl] = useState('')
const toggleReset2FAModal = () => setShowReset2FAModal(!showReset2FAModal)
const [showRoleModal, setShowRoleModal] = useState(false)
const toggleRoleModal = () =>
setShowRoleModal(!showRoleModal)
const [showEnableUserModal, setShowEnableUserModal] = useState(false)
const toggleEnableUserModal = () =>
setShowEnableUserModal(!showEnableUserModal)
/* const [showDeleteUserModal, setShowDeleteUserModal] = useState(false)
const toggleDeleteUserModal = () =>
setShowDeleteUserModal(!showDeleteUserModal) */
const [showInputConfirmModal, setShowInputConfirmModal] = useState(false)
const toggleInputConfirmModal = () =>
setShowInputConfirmModal(!showInputConfirmModal)
const [action, setAction] = useState(null)
const requestNewPassword = userID => {
axios({
method: 'POST',
url: `${url}/api/resetpassword`,
data: {
userID: userID
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res) {
const status = res.status
if (status === 200) {
const token = res.data.token
setResetPasswordUrl(
`https://localhost:3001/resetpassword?t=${token.token}`
)
toggleResetPasswordModal()
}
}
})
.catch(err => {
if (err) console.log('error')
})
}
const requestNew2FA = userID => {
axios({
method: 'POST',
url: `${url}/api/reset2fa`,
data: {
userID: userID
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res) {
const status = res.status
if (status === 200) {
const token = res.data.token
setReset2FAUrl(`https://localhost:3001/reset2fa?t=${token.token}`)
toggleReset2FAModal()
}
}
})
.catch(err => {
if (err) console.log('error')
})
}
const elements = [
{
header: 'Login',
width: 257,
textAlign: 'left',
size: 'sm',
view: u => {
if (userData.id === u.id)
return (
<>
{u.username}
<Chip size="small" label="You" className={classes.chip} />
</>
)
return u.username
}
},
{
header: 'Role',
width: 105,
textAlign: 'center',
size: 'sm',
view: u => {
switch (u.role) {
case 'user':
return 'Regular'
case 'superuser':
return 'Superuser'
default:
return u.role
}
}
},
{
header: '',
width: 80,
textAlign: 'center',
size: 'sm',
view: u => (
<Switch
disabled={userData.id === u.id}
checked={u.role === 'superuser'}
onClick={() => {
setUserInfo(u)
toggleRoleModal()
}}
value={u.role === 'superuser'}
/>
)
},
{
header: '',
width: 25,
textAlign: 'center',
size: 'sm',
view: u => {}
},
{
header: 'Actions',
width: 565,
textAlign: 'left',
size: 'sm',
view: u => {
return (
<>
<Chip
size="small"
label="Reset password"
className={classes.actionChip}
onClick={() => {
setUserInfo(u)
if(u.role === 'superuser') {
setAction(() => requestNewPassword.bind(null, u.id))
toggleInputConfirmModal()
} else {
requestNewPassword(u.id)
}
}}
/>
<Chip
size="small"
label="Reset 2FA"
className={classes.actionChip}
onClick={() => {
setUserInfo(u)
if(u.role === 'superuser') {
setAction(() => requestNew2FA.bind(null, u.id))
toggleInputConfirmModal()
} else {
requestNew2FA(u.id)
}
}}
/>
</>
)
}
},
/* {
header: 'Actions',
width: 535,
textAlign: 'left',
size: 'sm',
view: u => {
const ua = parser(u.last_accessed_from)
return u.last_accessed_from
? `${ua.browser.name} ${ua.browser.version} on ${ua.os.name} ${ua.os.version}`
: `No Record`
}
}, */
{
header: 'Enabled',
width: 100,
textAlign: 'center',
size: 'sm',
view: u => (
<Switch
disabled={userData.id === u.id}
checked={u.enabled}
onClick={() => {
setUserInfo(u)
toggleEnableUserModal()
}}
value={u.enabled}
/>
)
}/* ,
{
header: 'Delete',
width: 100,
textAlign: 'center',
size: 'sm',
view: u => (
<IconButton
onClick={() => {
setUserInfo(u)
toggleDeleteUserModal()
}}>
<DeleteIcon />
</IconButton>
)
} */
]
return (
<>
<TitleSection title="User Management" />
<Box
marginBottom={3}
marginTop={-5}
className={classes.tableWidth}
display="flex"
justifyContent="flex-end">
<Link color="primary" onClick={toggleCreateUserModal}>
Add new user
</Link>
</Box>
<DataTable elements={elements} data={R.path(['users'])(userResponse)} />
<CreateUserModal
showModal={showCreateUserModal}
toggleModal={toggleCreateUserModal}
/>
<ResetPasswordModal
showModal={showResetPasswordModal}
toggleModal={toggleResetPasswordModal}
resetPasswordURL={resetPasswordUrl}
user={userInfo}
/>
<Reset2FAModal
showModal={showReset2FAModal}
toggleModal={toggleReset2FAModal}
reset2FAURL={reset2FAUrl}
user={userInfo}
/>
<ChangeRoleModal
showModal={showRoleModal}
toggleModal={toggleRoleModal}
user={userInfo}
confirm={changeUserRole}
inputConfirmToggle={toggleInputConfirmModal}
setAction={setAction}
/>
<EnableUserModal
showModal={showEnableUserModal}
toggleModal={toggleEnableUserModal}
user={userInfo}
confirm={toggleUserEnable}
inputConfirmToggle={toggleInputConfirmModal}
setAction={setAction}
/>
{/* <DeleteUserModal
showModal={showDeleteUserModal}
toggleModal={toggleDeleteUserModal}
user={userInfo}
confirm={deleteUser}
inputConfirmToggle={toggleInputConfirmModal}
setAction={setAction}
/> */}
<Input2FAModal
showModal={showInputConfirmModal}
toggleModal={toggleInputConfirmModal}
action={action}
/>
</>
)
}
export default Users

View file

@ -0,0 +1,81 @@
import {
spacer,
fontPrimary,
fontSecondary,
primaryColor,
subheaderColor,
errorColor
} from 'src/styling/variables'
const styles = {
footer: {
margin: [['auto', 0, spacer * 3, 'auto']]
},
modalTitle: {
marginTop: -5,
color: primaryColor,
fontFamily: fontPrimary
},
modalLabel1: {
marginTop: 20
},
modalLabel2: {
marginTop: 40
},
inputLabel: {
color: primaryColor,
fontFamily: fontPrimary,
fontSize: 24,
marginLeft: 8,
marginTop: 15
},
tableWidth: {
width: 1132
},
radioGroup: {
flexDirection: 'row',
width: 500
},
radioLabel: {
width: 150,
height: 48
},
copyToClipboard: {
marginLeft: 'auto',
paddingTop: 6,
paddingLeft: 15,
marginRight: -11
},
chip: {
backgroundColor: subheaderColor,
fontFamily: fontPrimary,
marginLeft: 15,
marginTop: -5
},
actionChip: {
backgroundColor: subheaderColor,
marginRight: 15,
marginTop: -5
},
info: {
fontFamily: fontSecondary,
textAlign: 'justify'
},
addressWrapper: {
backgroundColor: subheaderColor,
marginTop: 8
},
address: {
margin: `${spacer * 1.5}px ${spacer * 3}px`
},
errorMessage: {
fontFamily: fontSecondary,
color: errorColor
},
codeContainer: {
marginTop: 15,
marginBottom: 15
}
}
export default styles

View file

@ -0,0 +1,64 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { H2, Info3 } from 'src/components/typography'
import styles from '../UserManagement.styles'
const useStyles = makeStyles(styles)
const ChangeRoleModal = ({
showModal,
toggleModal,
user,
confirm,
inputConfirmToggle,
setAction
}) => {
const classes = useStyles()
const handleClose = () => {
toggleModal()
}
return (
<>
{showModal && (
<Modal
closeOnBackdropClick={true}
width={600}
height={275}
handleClose={handleClose}
open={true}>
<H2 className={classes.modalTitle}>Change {user.username}'s role?</H2>
<Info3 className={classes.info}>
You are about to alter {user.username}'s role. This will change this
user's permission to access certain resources.
</Info3>
<Info3 className={classes.info}>Do you wish to proceed?</Info3>
<div className={classes.footer}>
<Button
onClick={() => {
setAction(() =>
confirm.bind(null, {
variables: {
id: user.id,
newRole: user.role === 'superuser' ? 'user' : 'superuser'
}
})
)
inputConfirmToggle()
handleClose()
}}>
Finish
</Button>
</div>
</Modal>
)}
</>
)
}
export default ChangeRoleModal

View file

@ -0,0 +1,156 @@
import { makeStyles } from '@material-ui/core/styles'
import axios from 'axios'
import React, { useState } from 'react'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { RadioGroup } from 'src/components/inputs'
import { TextInput } from 'src/components/inputs/base'
import { H1, H2, H3, Info3, Mono } from 'src/components/typography'
import CopyToClipboard from 'src/pages/Transactions/CopyToClipboard'
import styles from '../UserManagement.styles'
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const useStyles = makeStyles(styles)
const CreateUserModal = ({ showModal, toggleModal }) => {
const classes = useStyles()
const [usernameField, setUsernameField] = useState('')
const [roleField, setRoleField] = useState('')
const [createUserURL, setCreateUserURL] = useState(null)
const [invalidUser, setInvalidUser] = useState(false)
const radioOptions = [
{
code: 'user',
display: 'Regular user'
},
{
code: 'superuser',
display: 'Superuser'
}
]
const handleUsernameChange = event => {
if (event.target.value === '') {
setInvalidUser(false)
}
setUsernameField(event.target.value)
}
const handleRoleChange = event => {
setRoleField(event.target.value)
}
const handleClose = () => {
setUsernameField('')
setRoleField('')
setInvalidUser(false)
setCreateUserURL(null)
toggleModal()
}
const handleCreateUser = () => {
const username = usernameField.trim()
if (username === '') {
setInvalidUser(true)
return
}
axios({
method: 'POST',
url: `${url}/api/createuser`,
data: {
username: username,
role: roleField
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res) {
const status = res.status
const message = res.data.message
if (status === 200 && message) setInvalidUser(true)
if (status === 200 && !message) {
const token = res.data.token
setCreateUserURL(`https://localhost:3001/register?t=${token.token}`)
}
}
})
.catch(err => {
if (err) console.log('error')
})
}
return (
<>
{showModal && !createUserURL && (
<Modal
closeOnBackdropClick={true}
width={600}
height={400}
handleClose={handleClose}
open={true}>
<H1 className={classes.modalTitle}>Create new user</H1>
<H3 className={classes.modalLabel1}>User login</H3>
<TextInput
error={invalidUser}
name="username"
autoFocus
id="username"
type="text"
size="lg"
width={338}
onChange={handleUsernameChange}
value={usernameField}
/>
<H3 className={classes.modalLabel2}>Role</H3>
<RadioGroup
name="userrole"
value={roleField}
options={radioOptions}
onChange={handleRoleChange}
className={classes.radioGroup}
labelClassName={classes.radioLabel}
/>
<div className={classes.footer}>
<Button onClick={handleCreateUser}>Finish</Button>
</div>
</Modal>
)}
{showModal && createUserURL && (
<Modal
closeOnBackdropClick={true}
width={600}
height={215}
handleClose={handleClose}
open={true}>
<H2 className={classes.modalTitle}>Creating {usernameField}...</H2>
<Info3 className={classes.info}>
Safely share this link with {usernameField} to finish the
registration process.
</Info3>
<div className={classes.addressWrapper}>
<Mono className={classes.address}>
<strong>
<CopyToClipboard buttonClassname={classes.copyToClipboard}>
{createUserURL}
</CopyToClipboard>
</strong>
</Mono>
</div>
</Modal>
)}
</>
)
}
export default CreateUserModal

View file

@ -0,0 +1,73 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { H2, Info3 } from 'src/components/typography'
import styles from '../UserManagement.styles'
const useStyles = makeStyles(styles)
const DeleteUserModal = ({
showModal,
toggleModal,
user,
confirm,
inputConfirmToggle,
setAction
}) => {
const classes = useStyles()
const handleClose = () => {
toggleModal()
}
return (
<>
{showModal && (
<Modal
closeOnBackdropClick={true}
width={600}
height={275}
handleClose={handleClose}
open={true}>
<H2 className={classes.modalTitle}>Delete {user.username}?</H2>
<Info3 className={classes.info}>
You are about to delete {user.username}. This will remove existent
sessions and revoke this user's permissions to access the system.
</Info3>
<Info3 className={classes.info}>
This is a <b>PERMANENT</b> operation. Do you wish to proceed?
</Info3>
<div className={classes.footer}>
<Button
onClick={() => {
if (user.role === 'superuser') {
setAction(() =>
confirm.bind(null, {
variables: {
id: user.id
}
})
)
inputConfirmToggle()
} else {
confirm({
variables: {
id: user.id
}
})
}
handleClose()
}}>
Finish
</Button>
</div>
</Modal>
)}
</>
)
}
export default DeleteUserModal

View file

@ -0,0 +1,87 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { H2, Info3 } from 'src/components/typography'
import styles from '../UserManagement.styles'
const useStyles = makeStyles(styles)
const EnableUserModal = ({
showModal,
toggleModal,
user,
confirm,
inputConfirmToggle,
setAction
}) => {
const classes = useStyles()
const handleClose = () => {
toggleModal()
}
return (
<>
{showModal && (
<Modal
closeOnBackdropClick={true}
width={600}
height={275}
handleClose={handleClose}
open={true}>
{!user.enabled && (
<>
<H2 className={classes.modalTitle}>Enable {user.username}?</H2>
<Info3 className={classes.info}>
You are about to enable {user.username} into the system,
activating previous eligible sessions and grant permissions to
access the system.
</Info3>
<Info3 className={classes.info}>Do you wish to proceed?</Info3>
</>
)}
{user.enabled && (
<>
<H2 className={classes.modalTitle}>Disable {user.username}?</H2>
<Info3 className={classes.info}>
You are about to disable {user.username} from the system,
deactivating previous eligible sessions and removing permissions
to access the system.
</Info3>
<Info3 className={classes.info}>Do you wish to proceed?</Info3>
</>
)}
<div className={classes.footer}>
<Button
onClick={() => {
if (user.role === 'superuser') {
setAction(() =>
confirm.bind(null, {
variables: {
id: user.id
}
})
)
inputConfirmToggle()
} else {
confirm({
variables: {
id: user.id
}
})
}
handleClose()
}}>
Finish
</Button>
</div>
</Modal>
)}
</>
)
}
export default EnableUserModal

View file

@ -0,0 +1,99 @@
import { makeStyles } from '@material-ui/core/styles'
import axios from 'axios'
import React, { useState } from 'react'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { CodeInput } from 'src/components/inputs/base'
import { H2, Info3, P } from 'src/components/typography'
import styles from '../UserManagement.styles'
const url =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const useStyles = makeStyles(styles)
const Input2FAModal = ({ showModal, toggleModal, action, vars }) => {
const classes = useStyles()
const [twoFACode, setTwoFACode] = useState('')
const [invalidCode, setInvalidCode] = useState(false)
const handleCodeChange = value => {
setTwoFACode(value)
setInvalidCode(false)
}
const handleClose = () => {
setTwoFACode('')
setInvalidCode(false)
toggleModal()
}
const handleActionConfirm = () => {
axios({
method: 'POST',
url: `${url}/api/confirm2fa`,
data: {
code: twoFACode
},
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
.then((res, err) => {
if (err) return
if (res) {
const status = res.status
if (status === 200) {
action()
handleClose()
}
}
})
.catch(err => {
const errStatus = err.response.status
if (errStatus === 401) setInvalidCode(true)
})
}
return (
<>
{showModal && (
<Modal
closeOnBackdropClick={true}
width={600}
height={400}
handleClose={handleClose}
open={true}>
<H2 className={classes.modalTitle}>Confirm action</H2>
<Info3 className={classes.info}>
Please confirm this action by placing your two-factor authentication
code below.
</Info3>
<CodeInput
name="2fa"
value={twoFACode}
onChange={handleCodeChange}
numInputs={6}
error={invalidCode}
containerStyle={classes.codeContainer}
shouldAutoFocus
/>
{invalidCode && (
<P className={classes.errorMessage}>
Code is invalid. Please try again.
</P>
)}
<div className={classes.footer}>
<Button onClick={handleActionConfirm}>Finish</Button>
</div>
</Modal>
)}
</>
)
}
export default Input2FAModal

View file

@ -0,0 +1,48 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import Modal from 'src/components/Modal'
import { H2, Info3, Mono } from 'src/components/typography'
import CopyToClipboard from 'src/pages/Transactions/CopyToClipboard'
import styles from '../UserManagement.styles'
const useStyles = makeStyles(styles)
const Reset2FAModal = ({ showModal, toggleModal, reset2FAURL, user }) => {
const classes = useStyles()
const handleClose = () => {
toggleModal()
}
return (
<>
{showModal && (
<Modal
closeOnBackdropClick={true}
width={600}
height={215}
handleClose={handleClose}
open={true}>
<H2 className={classes.modalTitle}>Reset 2FA for {user.username}</H2>
<Info3 className={classes.info}>
Safely share this link with {user.username} for a two-factor
authentication reset.
</Info3>
<div className={classes.addressWrapper}>
<Mono className={classes.address}>
<strong>
<CopyToClipboard buttonClassname={classes.copyToClipboard}>
{reset2FAURL}
</CopyToClipboard>
</strong>
</Mono>
</div>
</Modal>
)}
</>
)
}
export default Reset2FAModal

View file

@ -0,0 +1,54 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import Modal from 'src/components/Modal'
import { H2, Info3, Mono } from 'src/components/typography'
import CopyToClipboard from 'src/pages/Transactions/CopyToClipboard'
import styles from '../UserManagement.styles'
const useStyles = makeStyles(styles)
const ResetPasswordModal = ({
showModal,
toggleModal,
resetPasswordURL,
user
}) => {
const classes = useStyles()
const handleClose = () => {
toggleModal()
}
return (
<>
{showModal && (
<Modal
closeOnBackdropClick={true}
width={600}
height={215}
handleClose={handleClose}
open={true}>
<H2 className={classes.modalTitle}>
Reset password for {user.username}
</H2>
<Info3 className={classes.info}>
Safely share this link with {user.username} for a password reset.
</Info3>
<div className={classes.addressWrapper}>
<Mono className={classes.address}>
<strong>
<CopyToClipboard buttonClassname={classes.copyToClipboard}>
{resetPasswordURL}
</CopyToClipboard>
</strong>
</Mono>
</div>
</Modal>
)}
</>
)
}
export default ResetPasswordModal

View file

@ -1,27 +1,14 @@
import React from 'react'
import React, { useContext } from 'react'
import { Route, Redirect } from 'react-router-dom'
const isAuthenticated = () => {
return localStorage.getItem('loggedIn')
}
import { AppContext } from 'src/App'
const PrivateRoute = ({ children, ...rest }) => {
return (
<Route
{...rest}
render={({ location }) =>
isAuthenticated() ? (
children
) : (
<Redirect
to={{
pathname: '/login'
}}
/>
)
}
/>
)
import { isLoggedIn } from './utils'
const PrivateRoute = ({ ...rest }) => {
const { userData } = useContext(AppContext)
return isLoggedIn(userData) ? <Route {...rest} /> : <Redirect to="/login" />
}
export default PrivateRoute

View file

@ -0,0 +1,25 @@
import React, { useContext } from 'react'
import { Route, Redirect } from 'react-router-dom'
import { AppContext } from 'src/App'
import { isLoggedIn } from './utils'
const PublicRoute = ({ component: Component, restricted, ...rest }) => {
const { userData } = useContext(AppContext)
return (
<Route
{...rest}
render={props =>
isLoggedIn(userData) && restricted ? (
<Redirect to="/" />
) : (
<Component {...props} />
)
}
/>
)
}
export default PublicRoute

View file

@ -13,7 +13,11 @@ import {
} from 'react-router-dom'
import AppContext from 'src/AppContext'
import AuthRegister from 'src/pages/AuthRegister'
// import AuthRegister from 'src/pages/AuthRegister'
import Login from 'src/pages/Authentication/Login'
import Register from 'src/pages/Authentication/Register'
import Reset2FA from 'src/pages/Authentication/Reset2FA'
import ResetPassword from 'src/pages/Authentication/ResetPassword'
import Blacklist from 'src/pages/Blacklist'
import Cashout from 'src/pages/Cashout'
import Commissions from 'src/pages/Commissions'
@ -34,13 +38,18 @@ import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting'
import TermsConditions from 'src/pages/OperatorInfo/TermsConditions'
import ServerLogs from 'src/pages/ServerLogs'
import Services from 'src/pages/Services/Services'
// import TokenManagement from 'src/pages/TokenManagement/TokenManagement'
import SessionManagement from 'src/pages/SessionManagement/SessionManagement'
import Transactions from 'src/pages/Transactions/Transactions'
import Triggers from 'src/pages/Triggers'
import UserManagement from 'src/pages/UserManagement/UserManagement'
import WalletSettings from 'src/pages/Wallet/Wallet'
import Wizard from 'src/pages/Wizard'
import { namespaces } from 'src/utils/config'
import PrivateRoute from './PrivateRoute'
import PublicRoute from './PublicRoute'
import { ROLES } from './utils'
const useStyles = makeStyles({
wrapper: {
flex: 1,
@ -55,12 +64,14 @@ const tree = [
key: 'transactions',
label: 'Transactions',
route: '/transactions',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Transactions
},
{
key: 'maintenance',
label: 'Maintenance',
route: '/maintenance',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
get component() {
return () => <Redirect to={this.children[0].route} />
},
@ -69,30 +80,35 @@ const tree = [
key: 'cash_cassettes',
label: 'Cash Cassettes',
route: '/maintenance/cash-cassettes',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CashCassettes
},
{
key: 'funding',
label: 'Funding',
route: '/maintenance/funding',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Funding
},
{
key: 'logs',
label: 'Machine Logs',
route: '/maintenance/logs',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: MachineLogs
},
{
key: 'machine-status',
label: 'Machine Status',
route: '/maintenance/machine-status',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: MachineStatus
},
{
key: 'server-logs',
label: 'Server',
route: '/maintenance/server-logs',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: ServerLogs
}
]
@ -101,6 +117,7 @@ const tree = [
key: 'settings',
label: 'Settings',
route: '/settings',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
get component() {
return () => <Redirect to={this.children[0].route} />
},
@ -109,36 +126,42 @@ const tree = [
key: namespaces.COMMISSIONS,
label: 'Commissions',
route: '/settings/commissions',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Commissions
},
{
key: namespaces.LOCALE,
label: 'Locales',
route: '/settings/locale',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Locales
},
{
key: namespaces.CASH_OUT,
label: 'Cash-out',
route: '/settings/cash-out',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Cashout
},
{
key: namespaces.NOTIFICATIONS,
label: 'Notifications',
route: '/settings/notifications',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Notifications
},
{
key: 'services',
label: '3rd party services',
route: '/settings/3rd-party-services',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Services
},
{
key: namespaces.WALLETS,
label: 'Wallet',
route: '/settings/wallet-settings',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: WalletSettings
},
{
@ -146,6 +169,7 @@ const tree = [
label: 'Operator Info',
route: '/settings/operator-info',
title: 'Operator Information',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
get component() {
return () => (
<Redirect
@ -161,24 +185,28 @@ const tree = [
key: 'contact-info',
label: 'Contact information',
route: '/settings/operator-info/contact-info',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: ContactInfo
},
{
key: 'receipt-printing',
label: 'Receipt',
route: '/settings/operator-info/receipt-printing',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: ReceiptPrinting
},
{
key: 'coin-atm-radar',
label: 'Coin ATM Radar',
route: '/settings/operator-info/coin-atm-radar',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CoinAtmRadar
},
{
key: 'terms-conditions',
label: 'Terms & Conditions',
route: '/settings/operator-info/terms-conditions',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: TermsConditions
}
]
@ -189,6 +217,7 @@ const tree = [
key: 'compliance',
label: 'Compliance',
route: '/compliance',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
get component() {
return () => <Redirect to={this.children[0].route} />
},
@ -197,18 +226,21 @@ const tree = [
key: 'triggers',
label: 'Triggers',
route: '/compliance/triggers',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Triggers
},
{
key: 'customers',
label: 'Customers',
route: '/compliance/customers',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Customers
},
{
key: 'blacklist',
label: 'Blacklist',
route: '/compliance/blacklist',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Blacklist
},
{
@ -220,9 +252,35 @@ const tree = [
{
key: 'customer',
route: '/compliance/customer/:id',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CustomerProfile
}
]
},
{
key: 'system',
label: 'System',
route: '/system',
allowedRoles: [ROLES.SUPERUSER],
get component() {
return () => <Redirect to={this.children[0].route} />
},
children: [
{
key: 'user-management',
label: 'User Management',
route: '/system/user-management',
allowedRoles: [ROLES.SUPERUSER],
component: UserManagement
},
{
key: 'session-management',
label: 'Session Management',
route: '/system/session-management',
allowedRoles: [ROLES.SUPERUSER],
component: SessionManagement
}
]
}
// {
// key: 'system',
@ -276,13 +334,32 @@ const Routes = () => {
const history = useHistory()
const location = useLocation()
const { wizardTested, userData } = useContext(AppContext)
const { wizardTested } = useContext(AppContext)
const dontTriggerPages = ['/404', '/register', '/wizard']
const dontTriggerPages = [
'/404',
'/register',
'/wizard',
'/login',
'/register',
'/resetpassword',
'/reset2fa'
]
if (!wizardTested && !R.contains(location.pathname)(dontTriggerPages)) {
history.push('/wizard')
return null
}
const getFilteredRoutes = () => {
if (!userData) return []
return flattened.filter(value => {
const keys = value.allowedRoles.map(v => {
return v.key
})
return R.includes(userData.role, keys)
})
}
const Transition = location.state ? Slide : Fade
@ -300,10 +377,10 @@ const Routes = () => {
return (
<Switch>
<Route exact path="/">
<Redirect to={{ pathname: '/dashboard' }} />
</Route>
<Route path={'/dashboard'}>
<PrivateRoute exact path="/">
<Redirect to={{ pathname: '/transactions' }} />
</PrivateRoute>
<PrivateRoute path={'/dashboard'}>
<Transition
className={classes.wrapper}
{...transitionProps}
@ -316,12 +393,15 @@ const Routes = () => {
</div>
}
/>
</Route>
<Route path="/machines" component={Machines} />
<Route path="/wizard" component={Wizard} />
<Route path="/register" component={AuthRegister} />
</PrivateRoute>
<PrivateRoute path="/machines" component={Machines} />
<PrivateRoute path="/wizard" component={Wizard} />
<Route path="/register" component={Register} />
<PublicRoute path="/login" restricted component={Login} />
<Route path="/resetpassword" component={ResetPassword} />
<Route path="/reset2fa" component={Reset2FA} />
{/* <Route path="/configmigration" component={ConfigMigration} /> */}
{flattened.map(({ route, component: Page, key }) => (
{getFilteredRoutes().map(({ route, component: Page, key }) => (
<Route path={route} key={key}>
<Transition
className={classes.wrapper}
@ -331,7 +411,9 @@ const Routes = () => {
unmountOnExit
children={
<div className={classes.wrapper}>
<Page name={key} />
<PrivateRoute path={route} key={key}>
<Page name={key} />
</PrivateRoute>
</div>
}
/>

View file

@ -0,0 +1,8 @@
export const isLoggedIn = userData => {
return userData
}
export const ROLES = {
USER: { key: 'user', value: '0' },
SUPERUSER: { key: 'superuser', value: '1' }
}

View file

@ -4,20 +4,23 @@ import { ApolloClient } from 'apollo-client'
import { ApolloLink } from 'apollo-link'
import { onError } from 'apollo-link-error'
import { HttpLink } from 'apollo-link-http'
import React from 'react'
import React, { useContext } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { AppContext } from 'src/App'
const URI =
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
const getClient = (history, location) =>
const getClient = (history, location, setUserData) =>
new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
if (extensions?.code === 'UNAUTHENTICATED') {
if (location.pathname !== '/404') history.push('/404')
setUserData(null)
if (location.pathname !== '/login') history.push('/login')
}
console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
@ -49,7 +52,9 @@ const getClient = (history, location) =>
const Provider = ({ children }) => {
const history = useHistory()
const location = useLocation()
const client = getClient(history, location)
const { setUserData } = useContext(AppContext)
const client = getClient(history, location, setUserData)
return <ApolloProvider client={client}>{children}</ApolloProvider>
}

208
package-lock.json generated
View file

@ -1936,6 +1936,48 @@
"fastq": "^1.6.0"
}
},
"@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="
},
"@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"requires": {
"@otplib/core": "^12.0.1"
}
},
"@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"requires": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"requires": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"requires": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@ -2300,6 +2342,34 @@
"@types/node": "*"
}
},
"@types/pg": {
"version": "7.14.9",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-7.14.9.tgz",
"integrity": "sha512-ThTOEwOvYM++zRSGiajRqKyTQboCEJE2VI+30d93WX94sQ7CnrcJ7CICT9oC+QD8Co9JTYJkKEfEXSb5DjUOFA==",
"requires": {
"@types/node": "*",
"pg-types": "^2.2.0"
},
"dependencies": {
"pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"requires": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
}
},
"postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="
}
}
},
"@types/prettier": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.1.6.tgz",
@ -3817,6 +3887,22 @@
"cashaddrjs": "^0.3.3"
}
},
"bcrypt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.0.tgz",
"integrity": "sha512-jB0yCBl4W/kVHM2whjfyqnxTmOHkCX4kHEa5nYKSoGeYe8YrjTYTc87/6bwt1g8cmV0QrbhKriETg9jWtcREhg==",
"requires": {
"node-addon-api": "^3.0.0",
"node-pre-gyp": "0.15.0"
},
"dependencies": {
"node-addon-api": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz",
"integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw=="
}
}
},
"bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@ -5642,6 +5728,68 @@
}
}
},
"connect-pg-simple": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-6.2.1.tgz",
"integrity": "sha512-bwDp/gKyRtyz0V5Vxy3SATSxItWBK/wDhaacncC79+q1B1VB8SQ49AlVaQCM+XxmIO29cWX4cvsFj65mD2qrzA==",
"requires": {
"@types/pg": "^7.14.4",
"pg": "^8.2.1"
},
"dependencies": {
"buffer-writer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="
},
"packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"pg": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.5.1.tgz",
"integrity": "sha512-9wm3yX9lCfjvA98ybCyw2pADUivyNWT/yIP4ZcDVpMN0og70BUWYEGXPCTAQdGTAqnytfRADb7NERrY1qxhIqw==",
"requires": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.4.0",
"pg-pool": "^3.2.2",
"pg-protocol": "^1.4.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
}
},
"pg-connection-string": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.4.0.tgz",
"integrity": "sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ=="
},
"pg-pool": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.2.2.tgz",
"integrity": "sha512-ORJoFxAlmmros8igi608iVEbQNNZlp89diFVx6yV5v+ehmpMY9sK6QgpmgoXbmkNaBAx8cOOZh9g80kJv1ooyA=="
},
"pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"requires": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
}
},
"postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="
}
}
},
"console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@ -7458,6 +7606,33 @@
"defaults": "^1.0.3"
}
},
"express-session": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz",
"integrity": "sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q==",
"requires": {
"cookie": "0.4.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.0",
"uid-safe": "~2.1.5"
},
"dependencies": {
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
},
"safe-buffer": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
}
}
},
"express-ws": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/express-ws/-/express-ws-3.0.0.tgz",
@ -12674,6 +12849,16 @@
"os-tmpdir": "^1.0.0"
}
},
"otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"requires": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"p-cancelable": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.3.0.tgz",
@ -13098,6 +13283,11 @@
"spex": "~2.0.2"
}
},
"pg-protocol": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.4.0.tgz",
"integrity": "sha512-El+aXWcwG/8wuFICMQjM5ZSAm6OWiJicFdNYo+VY3QP+8vI4SvLIWVe51PppTzMhikUJR+PsyIFKqfdXPz/yxA=="
},
"pg-types": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-1.13.0.tgz",
@ -13725,6 +13915,11 @@
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -16389,6 +16584,11 @@
"integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
"dev": true
},
"thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha1-TKL//AKlEpDSdEueP1V2k8prYno="
},
"throat": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
@ -16745,6 +16945,14 @@
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.22.tgz",
"integrity": "sha512-YUxzMjJ5T71w6a8WWVcMGM6YWOTX27rCoIQgLXiWaxqXSx9D7DNjiGWn1aJIRSQ5qr0xuhra77bSIh6voR/46Q=="
},
"uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"requires": {
"random-bytes": "~1.0.0"
}
},
"ultron": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz",

View file

@ -11,6 +11,7 @@
"axios": "^0.16.1",
"base-x": "^3.0.2",
"bchaddrjs": "^0.3.0",
"bcrypt": "^5.0.0",
"bignumber.js": "^4.1.0",
"bip39": "^2.3.1",
"bitcoind-rpc": "^0.7.0",
@ -20,6 +21,7 @@
"body-parser": "^1.15.1",
"coinbase": "^2.0.6",
"compression": "^1.7.4",
"connect-pg-simple": "^6.2.1",
"console-log-level": "^1.4.0",
"cookie-parser": "^1.4.3",
"cors": "^2.8.5",
@ -31,6 +33,7 @@
"express": "^4.15.4",
"express-limiter": "^1.6.0",
"express-rate-limit": "^2.9.0",
"express-session": "^1.17.1",
"express-ws": "^3.0.0",
"futoin-hkdf": "^1.0.2",
"got": "^7.1.0",
@ -59,6 +62,7 @@
"ndjson": "^1.5.0",
"nocache": "^2.1.0",
"numeral": "^2.0.3",
"otplib": "^12.0.1",
"p-each-series": "^1.0.0",
"p-retry": "^4.4.0",
"pg-native": "^3.0.0",