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 }