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:
parent
368781864e
commit
fded22f39a
50 changed files with 9839 additions and 4501 deletions
|
|
@ -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')))
|
||||
|
||||
|
|
|
|||
259
lib/new-admin/routes/auth.js
Normal file
259
lib/new-admin/routes/auth.js
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
42
lib/session-manager.js
Normal 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 }
|
||||
|
|
@ -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 }
|
||||
144
lib/users.js
144
lib/users.js
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
13
migrations/1603804834628-add-last-accessed-tokens.js
Normal file
13
migrations/1603804834628-add-last-accessed-tokens.js
Normal 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()
|
||||
}
|
||||
93
migrations/1605181184453-users.js
Normal file
93
migrations/1605181184453-users.js
Normal 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()
|
||||
}
|
||||
10549
new-lamassu-admin/package-lock.json
generated
10549
new-lamassu-admin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
BIN
new-lamassu-admin/public/fonts/Rubik/Rubik-Black.otf
Normal file
BIN
new-lamassu-admin/public/fonts/Rubik/Rubik-Black.otf
Normal file
Binary file not shown.
BIN
new-lamassu-admin/public/fonts/Rubik/Rubik-Bold.otf
Normal file
BIN
new-lamassu-admin/public/fonts/Rubik/Rubik-Bold.otf
Normal file
Binary file not shown.
BIN
new-lamassu-admin/public/fonts/Rubik/Rubik-Medium.otf
Normal file
BIN
new-lamassu-admin/public/fonts/Rubik/Rubik-Medium.otf
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
40
new-lamassu-admin/src/components/inputs/base/CodeInput.js
Normal file
40
new-lamassu-admin/src/components/inputs/base/CodeInput.js
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
115
new-lamassu-admin/src/pages/Authentication/Input2FAState.js
Normal file
115
new-lamassu-admin/src/pages/Authentication/Input2FAState.js
Normal 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
|
||||
30
new-lamassu-admin/src/pages/Authentication/Login.js
Normal file
30
new-lamassu-admin/src/pages/Authentication/Login.js
Normal 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
|
||||
121
new-lamassu-admin/src/pages/Authentication/Login.styles.js
Normal file
121
new-lamassu-admin/src/pages/Authentication/Login.styles.js
Normal 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
|
||||
104
new-lamassu-admin/src/pages/Authentication/LoginCard.js
Normal file
104
new-lamassu-admin/src/pages/Authentication/LoginCard.js
Normal 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
|
||||
130
new-lamassu-admin/src/pages/Authentication/LoginState.js
Normal file
130
new-lamassu-admin/src/pages/Authentication/LoginState.js
Normal 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
|
||||
180
new-lamassu-admin/src/pages/Authentication/Register.js
Normal file
180
new-lamassu-admin/src/pages/Authentication/Register.js
Normal 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
|
||||
186
new-lamassu-admin/src/pages/Authentication/Reset2FA.js
Normal file
186
new-lamassu-admin/src/pages/Authentication/Reset2FA.js
Normal 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
|
||||
177
new-lamassu-admin/src/pages/Authentication/ResetPassword.js
Normal file
177
new-lamassu-admin/src/pages/Authentication/ResetPassword.js
Normal 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
|
||||
180
new-lamassu-admin/src/pages/Authentication/Setup2FAState.js
Normal file
180
new-lamassu-admin/src/pages/Authentication/Setup2FAState.js
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
386
new-lamassu-admin/src/pages/UserManagement/UserManagement.js
Normal file
386
new-lamassu-admin/src/pages/UserManagement/UserManagement.js
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
new-lamassu-admin/src/routing/PublicRoute.js
Normal file
25
new-lamassu-admin/src/routing/PublicRoute.js
Normal 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
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
8
new-lamassu-admin/src/routing/utils.js
Normal file
8
new-lamassu-admin/src/routing/utils.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const isLoggedIn = userData => {
|
||||
return userData
|
||||
}
|
||||
|
||||
export const ROLES = {
|
||||
USER: { key: 'user', value: '0' },
|
||||
SUPERUSER: { key: 'superuser', value: '1' }
|
||||
}
|
||||
|
|
@ -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
208
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue