diff --git a/README.md b/README.md
index cb0322e..802c146 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,8 @@ A basic web app client in the **/client** directory will show basic API usage an
- > **NOTE:** Take note to make sure that the value starts and ends with a double-quote on WINDOWS OS localhost. Some systems may or may not require the double-quotes (i.e., Ubuntu running on heroku).
- `ALLOWED_ORIGINS`
- IP/domain origins in comma-separated values that are allowed to access the API
+ - `EMAIL_WHITELIST`
+ - Comma-separated email addresses linked to Firebase Auth UserRecords that are not allowed to be deleted or updated (write-protected)
### client
diff --git a/client/src/.eslintrc.js b/client/src/.eslintrc.js
index fa37f67..9a34c74 100644
--- a/client/src/.eslintrc.js
+++ b/client/src/.eslintrc.js
@@ -15,7 +15,7 @@ module.exports = {
ecmaFeatures: {
jsx: true
},
- ecmaVersion: 2018,
+ ecmaVersion: 2020,
sourceType: 'module'
},
plugins: [
diff --git a/client/src/App.js b/client/src/App.js
index 935cb20..6f95536 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types'
-import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'
+import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import Container from '@mui/material/Container'
import Navigation from './components/common/navigation'
import WithAuth from './containers/login/withauth'
diff --git a/client/src/components/common/userform/index.js b/client/src/components/common/userform/index.js
new file mode 100644
index 0000000..d89841f
--- /dev/null
+++ b/client/src/components/common/userform/index.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types'
+import Button from '@mui/material/Button'
+import Box from '@mui/material/Box'
+import FormControlLabel from '@mui/material/FormControlLabel'
+import InputLabel from '@mui/material/InputLabel'
+import MenuItem from '@mui/material/MenuItem'
+import Select from '@mui/material/Select'
+import Switch from '@mui/material/Switch'
+import TextField from '@mui/material/TextField'
+import AlertMessage from '../alert_message'
+import styles from './styles'
+
+function UserForm (props) {
+ const { state, loadstatus, onTextChange, onBtnClick, type = 'create' } = props
+
+ return (
+
+
+ {type !== 'create' &&
+ }
+
+
+
+
+
+ Account Type
+
+
+ }
+ label="Account Disabled" />
+ }
+ label="Email Verified" />
+
+
+
+ {(loadstatus.message !== '' || loadstatus.error !== '') &&
+
+ }
+
+ )
+}
+
+UserForm.propTypes = {
+ state: PropTypes.object,
+ loadstatus: PropTypes.object,
+ onTextChange: PropTypes.func,
+ onBtnClick: PropTypes.func,
+ type: PropTypes.string
+}
+
+export default UserForm
diff --git a/client/src/components/common/userform/styles.js b/client/src/components/common/userform/styles.js
new file mode 100644
index 0000000..8d7e6ea
--- /dev/null
+++ b/client/src/components/common/userform/styles.js
@@ -0,0 +1,17 @@
+const styles = {
+ container: {
+ width: '400px',
+ display: 'flex',
+ flexDirection: 'column',
+ '& .MuiTextField-root, button': {
+ marginTop: (theme) => theme.spacing(2)
+ }
+ },
+ formlabel: {
+ fontSize: '12px',
+ marginTop: (theme) => theme.spacing(1),
+ marginBottom: '4px'
+ }
+}
+
+export default styles
diff --git a/client/src/components/dashboard/index.js b/client/src/components/dashboard/index.js
index fda2152..37a721e 100644
--- a/client/src/components/dashboard/index.js
+++ b/client/src/components/dashboard/index.js
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types'
+import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
@@ -7,7 +8,7 @@ import Stack from '@mui/material/Stack'
import AlertMessage from '../../components/common/alert_message'
import styles from './styles'
-function Dashboard ({ currentUser, users, loadstatus, onBtnClick }) {
+function Dashboard ({ currentUser, users, loadstatus, onBtnClick, onBtnEditClick }) {
return (
Dashboard
@@ -58,14 +59,22 @@ function Dashboard ({ currentUser, users, loadstatus, onBtnClick }) {
}
-
+
-
+
+
+
})}
@@ -77,7 +86,8 @@ Dashboard.propTypes = {
currentUser: PropTypes.object,
users: PropTypes.array,
loadstatus: PropTypes.object,
- onBtnClick: PropTypes.func
+ onBtnClick: PropTypes.func,
+ onBtnEditClick: PropTypes.func
}
export default Dashboard
diff --git a/client/src/components/dashboard/styles.js b/client/src/components/dashboard/styles.js
index 54fcda1..b2977f3 100644
--- a/client/src/components/dashboard/styles.js
+++ b/client/src/components/dashboard/styles.js
@@ -9,6 +9,12 @@ const styles = {
'& span': {
fontSize: '14px'
}
+ },
+ buttons: {
+ display: 'flex',
+ flexDirection: 'column',
+ marginTop: (theme) => theme.spacing(1),
+ width: '100%'
}
}
diff --git a/client/src/containers/createuser/index.js b/client/src/containers/createuser/index.js
index 7b5504b..538c14f 100644
--- a/client/src/containers/createuser/index.js
+++ b/client/src/containers/createuser/index.js
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { createUser } from '../../utils/service'
-import Home from '../../components/createuser'
+import UserForm from '../../components/common/userform'
const defaultState = {
- email: '', displayname: '', account_level: '1'
+ email: '', displayname: '', account_level: '1', disabled: false, emailverified: false
}
const defaultLoadingState = {
@@ -15,10 +15,14 @@ function CreateUserContainer () {
const [loading, setLoading] = useState(defaultLoadingState)
const onInputChange = (e) => {
- const { id, value } = e.target
+ let { id, value, checked } = e.target
const key = (id !== undefined) ? id : 'account_level'
- setState({ ...state, [key]: value })
+ if (['emailverified', 'disabled'].includes(key)) {
+ value = checked
+ }
+
+ setState({ ...state, [key]: value })
if (loading.error !== '' || loading.message !== '') {
setLoading(defaultLoadingState)
}
@@ -28,19 +32,23 @@ function CreateUserContainer () {
try {
setLoading({ ...loading, isLoading: true })
await createUser(state)
- setLoading({ ...loading, isLoading: false, message: 'User created!' })
+ setLoading(prev => ({ ...defaultLoadingState, message: 'User created!' }))
} catch (err) {
- setLoading({ ...loading, isLoading: false, error: err.response ? err.response.data : err.message })
+ setLoading(prev => ({ ...defaultLoadingState, error: err.response ? err.response.data : err.message }))
}
}
return (
-
+
+
Create User
+
+
+
)
}
diff --git a/client/src/containers/dashboard/index.js b/client/src/containers/dashboard/index.js
index 2222a5d..4401ed4 100644
--- a/client/src/containers/dashboard/index.js
+++ b/client/src/containers/dashboard/index.js
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types'
import Dashboard from '../../components/dashboard'
import { getUsers, deleteUser } from '../../utils/service'
@@ -10,6 +11,7 @@ const defaultLoadingState = {
function DashboardContainer (props) {
const [state, setState] = useState([])
const [loading, setLoading] = useState(defaultLoadingState)
+ const navigate = useNavigate()
useEffect(() => {
let loaded = true
@@ -32,22 +34,37 @@ function DashboardContainer (props) {
const onDeleteUser = async (uid) => {
try {
- setLoading({ ...loading, isLoading: true })
+ setLoading({ ...defaultLoadingState, isLoading: true })
await deleteUser(uid)
const result = await getUsers()
setState(prev => result.data.users)
- setLoading({ ...loading, isLoading: false, message: 'User deleted!' })
+ setLoading(prev => ({ ...defaultLoadingState, message: 'User deleted!' }))
} catch (err) {
- setLoading({ ...loading, isLoading: false, error: err.response ? err.response.data : err.message })
+ setLoading(prev => ({ ...defaultLoadingState, error: err.response ? err.response.data : err.message }))
}
}
+ const onEditUser = (info) => {
+ navigate('/edit', {
+ replace: true,
+ state: {
+ uid: info.uid,
+ email: info.email,
+ displayname: info.displayName,
+ disabled: info.disabled,
+ emailverified: info.emailVerified,
+ account_level: (info.customClaims) ? info.customClaims.account_level : -1
+ }
+ })
+ }
+
return (
)
}
diff --git a/client/src/containers/updateuser/index.js b/client/src/containers/updateuser/index.js
new file mode 100644
index 0000000..9b472f5
--- /dev/null
+++ b/client/src/containers/updateuser/index.js
@@ -0,0 +1,58 @@
+import { useState } from 'react'
+import { useLocation } from 'react-router-dom'
+import { updateUser } from '../../utils/service'
+import UserForm from '../../components/common/userform'
+
+const defaultState = {
+ email: '', displayname: '', account_level: '1', disabled: false, emailverified: false
+}
+
+const defaultLoadingState = {
+ isLoading: false, error: '', message: ''
+}
+
+function UpdateUserContainer () {
+ const location = useLocation()
+ const [state, setState] = useState(location?.state || defaultState)
+ const [loading, setLoading] = useState(defaultLoadingState)
+
+ const onInputChange = (e) => {
+ let { id, value, checked } = e.target
+ const key = (id !== undefined) ? id : 'account_level'
+
+ if (['emailverified', 'disabled'].includes(key)) {
+ value = checked
+ }
+
+ setState({ ...state, [key]: value })
+ if (loading.error !== '' || loading.message !== '') {
+ setLoading(defaultLoadingState)
+ }
+ }
+
+ const onBtnUpdateClick = async () => {
+ try {
+ setLoading({ ...loading, isLoading: true })
+ await updateUser(state)
+ setLoading(prev => ({ ...defaultLoadingState, message: 'User info updated.' }))
+ } catch (err) {
+ setLoading(prev => ({ ...defaultLoadingState, error: err.response ? err.response.data : err.message }))
+ }
+ }
+
+ return (
+
+
Update User
+
+
+
+ )
+}
+
+export default UpdateUserContainer
diff --git a/client/src/routes.js b/client/src/routes.js
index c8eb50c..e1259c2 100644
--- a/client/src/routes.js
+++ b/client/src/routes.js
@@ -1,6 +1,7 @@
import DashboardContainer from './containers/dashboard'
import LoginContainer from './containers/login'
import CreateUserContainer from './containers/createuser'
+import UpdateUserContainer from './containers/updateuser'
import Home from './components/home'
const routes = [
@@ -19,6 +20,11 @@ const routes = [
isProtected: true,
component: CreateUserContainer
},
+ {
+ path: '/edit',
+ isProtected: true,
+ component: UpdateUserContainer
+ },
{
path: '/',
isProtected: false,
diff --git a/client/src/utils/service/service.js b/client/src/utils/service/service.js
index a5021c3..2a9e644 100644
--- a/client/src/utils/service/service.js
+++ b/client/src/utils/service/service.js
@@ -50,11 +50,11 @@ export default class Service {
}
async createUser (user) {
- const fields = ['email', 'displayname', 'account_level']
+ const fields = ['email', 'displayname', 'account_level', 'disabled', 'emailverified']
const body = {}
fields.forEach((item) => {
- if (user[item]) {
+ if (user[item] !== undefined) {
body[item] = user[item]
}
})
@@ -65,11 +65,11 @@ export default class Service {
}
async updateUser (info) {
- const fields = ['uid', 'displayname', 'disabled', 'emailverified', 'account_level']
+ const fields = ['uid', 'email', 'displayname', 'disabled', 'emailverified', 'account_level']
const body = {}
fields.forEach((item) => {
- if (info[item.toLowerCase()]) {
+ if (info[item.toLowerCase()] !== undefined) {
body[item] = info[item.toLowerCase()]
}
})
diff --git a/package.json b/package.json
index 7ca3ad8..7c75088 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
},
"scripts": {
"install": "cd server && npm install",
+ "postinstall": "cd server && npm run seed",
"build": "cd server && npm run build",
"start": "cd server && npm start"
},
diff --git a/server/.env.example b/server/.env.example
index 3db75e8..6a3cf11 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -1,3 +1,4 @@
ALLOWED_ORIGINS=http://localhost,http://mywebsite.com,http://yourwebsite.com
FIREBASE_SERVICE_ACC=YOUR-FIREBASE-PROJ-SERVICE-ACCOUNT-JSON-CREDENTIALS-ONE-LINER-NO-SPACES
FIREBASE_PRIVATE_KEY=PRIVATE-KEY-FROM-FIREBASE-SERVICE-ACCOUNT-JSON-WITH-DOUBLE-QUOTES
+EMAIL_WHITELIST=superadmin@gmail.com
\ No newline at end of file
diff --git a/server/.eslintrc.js b/server/.eslintrc.js
index 1e5830d..ebafbe8 100644
--- a/server/.eslintrc.js
+++ b/server/.eslintrc.js
@@ -13,7 +13,7 @@ module.exports = {
SharedArrayBuffer: 'readonly'
},
parserOptions: {
- ecmaVersion: 2018
+ ecmaVersion: 2020
},
rules: {
'indent': ['error', 2],
diff --git a/server/src/classes/user/user.js b/server/src/classes/user/user.js
index 7a8d24b..4dd59e2 100644
--- a/server/src/classes/user/user.js
+++ b/server/src/classes/user/user.js
@@ -7,17 +7,17 @@ const { getAuth } = require('../../utils/db')
class User {
// Create a new User with custom claims
async createuser (params) {
- const { email, displayname, account_level } = params
+ const { email, displayname, account_level, emailverified, disabled } = params
let user
try {
user = await getAuth()
.createUser({
email,
- emailVerified: false,
+ emailVerified: (emailverified !== undefined) ? emailverified : false,
password: '123456789',
displayName: displayname,
- disabled: false
+ disabled: (disabled !== undefined) ? disabled : false
})
} catch (err) {
throw new Error(err.message)
@@ -25,7 +25,8 @@ class User {
if (user) {
try {
- await getAuth().setCustomUserClaims(user.uid, { account_level })
+ const acclevel = (account_level !== undefined) ? account_level : 2
+ await getAuth().setCustomUserClaims(user.uid, { account_level: acclevel })
} catch (err) {
throw new Error(err.message)
}
@@ -50,7 +51,7 @@ class User {
fields.forEach((item) => {
const key = item.toLowerCase()
- if (params[key]) {
+ if (params[key] !== undefined) {
info[item] = params[key]
}
})
diff --git a/server/src/controllers/index.js b/server/src/controllers/index.js
index 0a42dc9..4a69ce3 100644
--- a/server/src/controllers/index.js
+++ b/server/src/controllers/index.js
@@ -9,8 +9,11 @@ const {
listUsers
} = require('./user')
-const validFirebaseToken = require('../middleware/valid-token')
-const isSuperAdmin = require('../middleware/superadmin')
+const {
+ validFirebaseToken,
+ isSuperAdmin,
+ isProtected
+} = require('../middleware')
// ----------------------------------------
// USERS
@@ -94,7 +97,7 @@ router.post('/user', validFirebaseToken, isSuperAdmin, createUser)
*
* const res = await axios({ ...obj, url: 'http://localhost:3001/api/user', method: 'PATCH' })
*/
-router.patch('/user', validFirebaseToken, isSuperAdmin, updateUser)
+router.patch('/user', validFirebaseToken, isSuperAdmin, isProtected, updateUser)
/**
* @api {delete} /user/:uid Delete UserRecord
@@ -118,7 +121,7 @@ router.patch('/user', validFirebaseToken, isSuperAdmin, updateUser)
*
* await axios.delete('http://localhost:3001/api/user/6uHhmVfPdjb6MR4ad5v9Np38z733', obj)
*/
-router.delete('/user/:uid', validFirebaseToken, isSuperAdmin, deleteUser)
+router.delete('/user/:uid', validFirebaseToken, isSuperAdmin, isProtected, deleteUser)
/**
* @api {get} /user Get UserRecord
diff --git a/server/src/controllers/user.js b/server/src/controllers/user.js
index 21c202b..b604ca9 100644
--- a/server/src/controllers/user.js
+++ b/server/src/controllers/user.js
@@ -6,15 +6,20 @@ const {
listusers
} = require('../classes/user')
+const { EMAIL_WHITELIST } = require('../utils/constants')
+
module.exports.createUser = async (req, res) => {
- const { email, displayname, account_level } = req.body
+ const { email, displayname, account_level, emailverified, disabled } = req.body
if (!email || !displayname || !account_level) {
return res.status(500).send('Missing parameter/s.')
}
try {
- const user = await createuser({ email, displayname, account_level })
+ const user = await createuser({
+ email, displayname, account_level, emailverified, disabled
+ })
+
return res.status(200).json(user)
} catch (err) {
return res.status(500).send(err.message)
diff --git a/server/src/middleware/index.js b/server/src/middleware/index.js
new file mode 100644
index 0000000..0ffb4ea
--- /dev/null
+++ b/server/src/middleware/index.js
@@ -0,0 +1,9 @@
+const validFirebaseToken = require('./valid-token')
+const isSuperAdmin = require('./superadmin')
+const isProtected = require('./protected')
+
+module.exports = {
+ validFirebaseToken,
+ isSuperAdmin,
+ isProtected
+}
diff --git a/server/src/middleware/protected.js b/server/src/middleware/protected.js
new file mode 100644
index 0000000..0e943ca
--- /dev/null
+++ b/server/src/middleware/protected.js
@@ -0,0 +1,23 @@
+const { EMAIL_WHITELIST } = require('../utils/constants')
+const { getuser } = require('../classes/user')
+
+// Reject the request if the uid's associated email is included in the whitelist
+const isProtected = async (req, res, next) => {
+ let uid = req.body.uid ?? ''
+ uid = req.params.uid ?? uid
+
+ try {
+ // Check if account is protected
+ const user = await getuser({ uid })
+
+ if (EMAIL_WHITELIST.includes(user.email)) {
+ return res.status(403).send('The resource you are trying to access is protected.')
+ }
+ } catch (err) {
+ return res.status(500).send(err.message)
+ }
+
+ next()
+}
+
+module.exports = isProtected
diff --git a/server/src/middleware/superadmin.js b/server/src/middleware/superadmin.js
index aab6e93..f5dd400 100644
--- a/server/src/middleware/superadmin.js
+++ b/server/src/middleware/superadmin.js
@@ -1,5 +1,7 @@
const { ACCOUNT_LEVEL } = require('../utils/constants')
+// Checks if a signed-in user is a superadmin
+// Requires the validFirebaseToken middleware
const isSuperAdmin = async (req, res, next) => {
let level = 0
diff --git a/server/src/utils/constants.js b/server/src/utils/constants.js
index 9dce5d1..8fe205c 100644
--- a/server/src/utils/constants.js
+++ b/server/src/utils/constants.js
@@ -3,6 +3,9 @@ const ACCOUNT_LEVEL = {
ADMIN: 2
}
+const EMAIL_WHITELIST = process.env.EMAIL_WHITELIST.split(',')
+
module.exports = {
- ACCOUNT_LEVEL
+ ACCOUNT_LEVEL,
+ EMAIL_WHITELIST
}