Repository: illinois/queue
Pull request #265 (2019-04-10 17:31:19)

Admin management
Adds a new admin page where we can locate admin-only functionality. On that page, you can add and remove admin users. Features typeahead for netids. This is also the first page built entirely without Redux - it was easiest to manage all this state locally.

now[bot]: 
This pull request is [automatically deployed](https://zeit.co/docs/features/now-for-github?utm_source=automated&utm_medium=github&utm_campaign=now_bot) with [Now](https://zeit.co/now?utm_source=automated&utm_medium=github&utm_campaign=now_bot).
To access deployments, click Details below or on the icon next to each push.
	


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79dc639..ffcee51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ with the current semantic version and the next changes should go under a **[Next
 
 ## [Next]
 
+* Add page for admin management. ([@nwalters512](https://github.com/nwalters512) in [#265](https://github.com/illinois/queue/pull/265))
+
 ## v1.0.5
 
 * Fix logic for filtering confidential questions. ([@james9909](https://github.com/james9909) in [#257](https://github.com/illinois/queue/pull/257))
diff --git a/package-lock.json b/package-lock.json
index 554156f..b2e0dcc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3399,6 +3399,11 @@
       "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
       "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM="
     },
+    "compute-scroll-into-view": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.11.tgz",
+      "integrity": "sha512-uUnglJowSe0IPmWOdDtrlHXof5CTIJitfJEyITHBW6zDVOGu9Pjk5puaLM73SLcwak0L4hEjO7Td88/a6P5i7A=="
+    },
     "concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3796,6 +3801,15 @@
         "sha.js": "^2.4.8"
       }
     },
+    "create-react-context": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.3.tgz",
+      "integrity": "sha512-CQBmD0+QGgTaxDL3OX1IDXYqjkp2It4RIbcb99jS6AEg27Ga+a9G3JtK6SIu0HBwPLZlmwt9F7UwWA4Bn92Rag==",
+      "requires": {
+        "fbjs": "^0.8.0",
+        "gud": "^1.0.0"
+      }
+    },
     "cross-spawn": {
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@@ -4347,6 +4361,14 @@
       "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
       "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
     },
+    "encoding": {
+      "version": "0.1.12",
+      "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
+      "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
+      "requires": {
+        "iconv-lite": "~0.4.13"
+      }
+    },
     "end-of-stream": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz",
@@ -5364,6 +5386,27 @@
         "bser": "^2.0.0"
       }
     },
+    "fbjs": {
+      "version": "0.8.17",
+      "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz",
+      "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=",
+      "requires": {
+        "core-js": "^1.0.0",
+        "isomorphic-fetch": "^2.1.1",
+        "loose-envify": "^1.0.0",
+        "object-assign": "^4.1.0",
+        "promise": "^7.1.1",
+        "setimmediate": "^1.0.5",
+        "ua-parser-js": "^0.7.18"
+      },
+      "dependencies": {
+        "core-js": {
+          "version": "1.2.7",
+          "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz",
+          "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY="
+        }
+      }
+    },
     "fecha": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz",
@@ -6388,6 +6431,11 @@
       "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
       "dev": true
     },
+    "gud": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
+      "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
+    },
     "handlebars": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.0.tgz",
@@ -7391,6 +7439,26 @@
       "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
       "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
     },
+    "isomorphic-fetch": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz",
+      "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=",
+      "requires": {
+        "node-fetch": "^1.0.1",
+        "whatwg-fetch": ">=0.10.0"
+      },
+      "dependencies": {
+        "node-fetch": {
+          "version": "1.7.3",
+          "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
+          "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
+          "requires": {
+            "encoding": "^0.1.11",
+            "is-stream": "^1.0.1"
+          }
+        }
+      }
+    },
     "isomorphic-unfetch": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.0.0.tgz",
@@ -11110,6 +11178,25 @@
         "reflect.ownkeys": "^0.2.0"
       }
     },
+    "prop-types-extra": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.0.tgz",
+      "integrity": "sha512-QFyuDxvMipmIVKD2TwxLVPzMnO4e5oOf1vr3tJIomL8E7d0lr6phTHd5nkPhFIzTD1idBLLEPeylL9g+rrTzRg==",
+      "requires": {
+        "react-is": "^16.3.2",
+        "warning": "^3.0.0"
+      },
+      "dependencies": {
+        "warning": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
+          "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
+          "requires": {
+            "loose-envify": "^1.0.0"
+          }
+        }
+      }
+    },
     "proxy-addr": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz",
@@ -11309,6 +11396,65 @@
         "scheduler": "^0.13.1"
       }
     },
+    "react-bootstrap-typeahead": {
+      "version": "4.0.0-alpha.6",
+      "resolved": "https://registry.npmjs.org/react-bootstrap-typeahead/-/react-bootstrap-typeahead-4.0.0-alpha.6.tgz",
+      "integrity": "sha512-7FquCakCfzjuDEX0N6ig1DqKmTOujdXKqRXjZ+IRQDstfohh55poMJ9t4UnDR0BoWMhBYl6ljYqaiokPUmd3zA==",
+      "requires": {
+        "@babel/runtime": "^7.3.4",
+        "classnames": "^2.2.0",
+        "create-react-context": "^0.2.3",
+        "escape-string-regexp": "^1.0.5",
+        "invariant": "^2.2.1",
+        "lodash": "^4.17.2",
+        "prop-types": "^15.5.8",
+        "prop-types-extra": "^1.0.1",
+        "react-lifecycles-compat": "^3.0.4",
+        "react-overlays": "^0.8.1",
+        "react-popper": "^1.0.0",
+        "scroll-into-view-if-needed": "^2.2.20",
+        "warning": "^4.0.1"
+      },
+      "dependencies": {
+        "@babel/runtime": {
+          "version": "7.4.3",
+          "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.3.tgz",
+          "integrity": "sha512-9lsJwJLxDh/T3Q3SZszfWOTkk3pHbkmH+3KY+zwIDmsNlxsumuhS2TH3NIpktU4kNvfzy+k3eLT7aTJSPTo0OA==",
+          "requires": {
+            "regenerator-runtime": "^0.13.2"
+          }
+        },
+        "react-popper": {
+          "version": "1.3.3",
+          "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.3.tgz",
+          "integrity": "sha512-ynMZBPkXONPc5K4P5yFWgZx5JGAUIP3pGGLNs58cfAPgK67olx7fmLp+AdpZ0+GoQ+ieFDa/z4cdV6u7sioH6w==",
+          "requires": {
+            "@babel/runtime": "^7.1.2",
+            "create-react-context": "<=0.2.2",
+            "popper.js": "^1.14.4",
+            "prop-types": "^15.6.1",
+            "typed-styles": "^0.0.7",
+            "warning": "^4.0.2"
+          },
+          "dependencies": {
+            "create-react-context": {
+              "version": "0.2.2",
+              "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.2.tgz",
+              "integrity": "sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A==",
+              "requires": {
+                "fbjs": "^0.8.0",
+                "gud": "^1.0.0"
+              }
+            }
+          }
+        },
+        "regenerator-runtime": {
+          "version": "0.13.2",
+          "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz",
+          "integrity": "sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA=="
+        }
+      }
+    },
     "react-dom": {
       "version": "16.8.1",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.1.tgz",
@@ -11372,6 +11518,29 @@
       "resolved": "https://registry.npmjs.org/react-moment/-/react-moment-0.8.4.tgz",
       "integrity": "sha512-QhI19OcfhiAn60/O6bMR0w8ApXrPFCjv6+eV0I/P9/AswzjgEAx4L7VxMBCpS/jrythLa12Q9v88req+ys4YpA=="
     },
+    "react-overlays": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.8.3.tgz",
+      "integrity": "sha512-h6GT3jgy90PgctleP39Yu3eK1v9vaJAW73GOA/UbN9dJ7aAN4BTZD6793eI1D5U+ukMk17qiqN/wl3diK1Z5LA==",
+      "requires": {
+        "classnames": "^2.2.5",
+        "dom-helpers": "^3.2.1",
+        "prop-types": "^15.5.10",
+        "prop-types-extra": "^1.0.1",
+        "react-transition-group": "^2.2.0",
+        "warning": "^3.0.0"
+      },
+      "dependencies": {
+        "warning": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
+          "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
+          "requires": {
+            "loose-envify": "^1.0.0"
+          }
+        }
+      }
+    },
     "react-popper": {
       "version": "0.10.4",
       "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-0.10.4.tgz",
@@ -12083,6 +12252,14 @@
         "ajv-keywords": "^3.1.0"
       }
     },
+    "scroll-into-view-if-needed": {
+      "version": "2.2.20",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.20.tgz",
+      "integrity": "sha512-P9kYMrhi9f6dvWwTGpO5I3HgjSU/8Mts7xL3lkoH5xlewK7O9Obdc5WmMCzppln7bCVGNmf3qfoZXrpCeyNJXw==",
+      "requires": {
+        "compute-scroll-into-view": "1.0.11"
+      }
+    },
     "semver": {
       "version": "5.6.0",
       "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
@@ -13306,11 +13483,21 @@
         "mime-types": "~2.1.18"
       }
     },
+    "typed-styles": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz",
+      "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q=="
+    },
     "typedarray": {
       "version": "0.0.6",
       "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
       "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
     },
+    "ua-parser-js": {
+      "version": "0.7.19",
+      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz",
+      "integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ=="
+    },
     "uglify-js": {
       "version": "3.4.9",
       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz",
@@ -13658,6 +13845,11 @@
       "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
       "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
     },
+    "use-debounce": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-1.1.1.tgz",
+      "integrity": "sha512-wDA5sZaQj9TRd/yYeFmDateUfTRPfOILY7AZFNVsTbwYpcpBIxh6yx8/XS1UNNf9DIFJJAxazxbWWEOy+BG9vA=="
+    },
     "util": {
       "version": "0.11.1",
       "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
@@ -13770,6 +13962,14 @@
         "makeerror": "1.0.x"
       }
     },
+    "warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "requires": {
+        "loose-envify": "^1.0.0"
+      }
+    },
     "watch": {
       "version": "0.18.0",
       "resolved": "https://registry.npmjs.org/watch/-/watch-0.18.0.tgz",
@@ -13956,6 +14156,11 @@
         }
       }
     },
+    "whatwg-fetch": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz",
+      "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q=="
+    },
     "whatwg-mimetype": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",
diff --git a/package.json b/package.json
index 0d5d596..5e9285c 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
     "pg": "^7.8.0",
     "prop-types": "^15.7.1",
     "react": "^16.8.1",
+    "react-bootstrap-typeahead": "^4.0.0-alpha.6",
     "react-dom": "^16.8.1",
     "react-flip-move": "^3.0.3",
     "react-fontawesome": "^1.6.1",
@@ -70,6 +71,7 @@
     "styled-jsx": "^3.2.1",
     "supertest-session": "^3.3.0",
     "umzug": "^2.2.0",
+    "use-debounce": "^1.1.1",
     "validator": "^10.11.0",
     "winston": "^3.2.1"
   },
diff --git a/src/api/autocomplete.js b/src/api/autocomplete.js
new file mode 100644
index 0000000..a39873e
--- /dev/null
+++ b/src/api/autocomplete.js
@@ -0,0 +1,32 @@
+const router = require('express').Router()
+
+const { User, Sequelize } = require('../models')
+
+const { failIfErrors, ApiError } = require('./util')
+const requireAdmin = require('../middleware/requireAdmin')
+const safeAsync = require('../middleware/safeAsync')
+
+router.get(
+  '/users',
+  [requireAdmin, failIfErrors],
+  safeAsync(async (req, res, next) => {
+    const { q } = req.query
+    if (q === null || q === undefined) {
+      next(new ApiError(422, 'No query specified'))
+      return
+    }
+    const users = await User.findAll({
+      where: {
+        [Sequelize.Op.or]: {
+          netid: {
+            [Sequelize.Op.like]: `${q}%`,
+          },
+        },
+      },
+      limit: 10,
+    })
+    res.send(users)
+  })
+)
+
+module.exports = router
diff --git a/src/api/users.js b/src/api/users.js
index 254845f..1f05899 100644
--- a/src/api/users.js
+++ b/src/api/users.js
@@ -1,6 +1,6 @@
 const router = require('express').Router()
 
-const { requireUser, failIfErrors } = require('./util')
+const { requireUser, failIfErrors, ApiError } = require('./util')
 
 const { User, Course } = require('../models')
 
@@ -33,6 +33,56 @@ router.get(
   })
 )
 
+// Get a list of all admins
+router.get(
+  '/admins',
+  [requireAdmin, failIfErrors],
+  safeAsync(async (req, res, _next) => {
+    const users = await User.findAll({
+      where: {
+        isAdmin: true,
+      },
+    })
+    res.send(users)
+  })
+)
+
+// Add the specified user to the list of admins
+router.put(
+  '/admins/:userId',
+  [requireAdmin, requireUser, failIfErrors],
+  safeAsync(async (req, res, _next) => {
+    const { user } = res.locals
+    if (user.isAdmin) {
+      res.status(204).send()
+      return
+    }
+    user.isAdmin = true
+    await user.save()
+    res.status(201).send(user)
+  })
+)
+
+// Remove the specified user from the list of admins
+router.delete(
+  '/admins/:userId',
+  [requireAdmin, requireUser, failIfErrors],
+  safeAsync(async (req, res, _next) => {
+    const { id } = res.locals.userAuthn
+    const { user } = res.locals
+    // Prevent user from removing themselves as an admin
+    if (user.id === id) {
+      _next(new ApiError(403, 'You cannot remove yourself'))
+      return
+    }
+    if (user.isAdmin) {
+      user.isAdmin = false
+      await user.save()
+    }
+    res.status(204).send()
+  })
+)
+
 // Updates the information for a given user
 router.patch(
   '/me',
diff --git a/src/app.js b/src/app.js
index ad32b23..59e43b4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4,6 +4,7 @@ const bodyParser = require('body-parser')
 const cookieParser = require('cookie-parser')
 const rewrite = require('express-urlrewrite')
 
+const logger = require('./util/logger')
 const { baseUrl, isDev, isNow } = require('./util')
 
 app.use(cookieParser())
@@ -49,12 +50,14 @@ app.use(
   require('./api/questions')
 )
 app.use(`${baseUrl}/api/queues/:queueId/questions`, require('./api/questions'))
+app.use(`${baseUrl}/api/autocomplete`, require('./api/autocomplete'))
 
 // Use special not-found/error middleware for the API
 app.use(`${baseUrl}/api`, (err, _req, res, _next) => {
   const statusCode = err.httpStatusCode || 500
   const message = err.message || 'Something went wrong'
   res.status(statusCode).json({ message })
+  logger.error(err)
 })
 app.use(`${baseUrl}/api`, (_req, res, _next) => {
   res.status(404).json({
diff --git a/src/components/Header.js b/src/components/Header.js
index 5d834be..7ea9eb0 100644
--- a/src/components/Header.js
+++ b/src/components/Header.js
@@ -85,6 +85,13 @@ class Header extends React.Component {
           <Fragment>
             <NavbarToggler onClick={() => this.toggle()} />
             <Collapse isOpen={this.state.isOpen} navbar>
+              {user.isAdmin && (
+                <Nav navbar>
+                  <Link route="adminIndex" passHref>
+                    <NavLink>Admin</NavLink>
+                  </Link>
+                </Nav>
+              )}
               <Nav navbar className="ml-auto">
                 <Link route="userSettings" passHref>
                   <NavLink className="navbar-text mr-3">
diff --git a/src/components/Loading.js b/src/components/Loading.js
index c1b785a..914156e 100644
--- a/src/components/Loading.js
+++ b/src/components/Loading.js
@@ -6,7 +6,7 @@ import { faSpinner } from '@fortawesome/free-solid-svg-icons'
 
 const Loading = () => (
   <Container fluid className="text-center">
-    <FontAwesomeIcon icon={faSpinner} pulse size="lg" className="mt-4" />
+    <FontAwesomeIcon icon={faSpinner} pulse size="lg" className="my-3" />
   </Container>
 )
 
diff --git a/src/components/PageWithUser.js b/src/components/PageWithUser.js
index d69d273..4a3c18b 100644
--- a/src/components/PageWithUser.js
+++ b/src/components/PageWithUser.js
@@ -18,7 +18,10 @@ const syncLogout = e => {
 export default function(AuthedComponent, permissions) {
   class PageWithUser extends React.Component {
     static async getInitialProps(ctx) {
-      return AuthedComponent.getInitialProps(ctx)
+      if (AuthedComponent.getInitialProps) {
+        return AuthedComponent.getInitialProps(ctx)
+      }
+      return null
     }
 
     constructor(props) {
diff --git a/src/components/CourseStaffMember.js b/src/components/RemoveableUserItem.js
similarity index 56%
rename from src/components/CourseStaffMember.js
rename to src/components/RemoveableUserItem.js
index 9d756e9..d89b5ad 100644
--- a/src/components/CourseStaffMember.js
+++ b/src/components/RemoveableUserItem.js
@@ -4,7 +4,7 @@ import classNames from 'classnames'
 import { ListGroupItem, Button } from 'reactstrap'
 
 // eslint-disable-next-line react/prefer-stateless-function
-class CourseStaffMember extends React.Component {
+class RemoveableUserItem extends React.Component {
   render() {
     const netidClasses = classNames('text-muted', 'small', {
       'ml-2': this.props.name,
@@ -16,29 +16,32 @@ class CourseStaffMember extends React.Component {
           {this.props.name}
           <span className={netidClasses}>({this.props.netid})</span>
         </div>
-        <Button
-          color="danger"
-          tag="div"
-          className="ml-auto"
-          size="sm"
-          onClick={() => this.props.removeCourseStaff(this.props.id)}
-        >
-          Remove
-        </Button>
+        {this.props.showRemoveButton && (
+          <Button
+            color="danger"
+            className="ml-auto"
+            size="sm"
+            onClick={() => this.props.onRemove(this.props.id)}
+          >
+            Remove
+          </Button>
+        )}
       </ListGroupItem>
     )
   }
 }
 
-CourseStaffMember.defaultProps = {
+RemoveableUserItem.defaultProps = {
   name: null,
+  showRemoveButton: true,
 }
 
-CourseStaffMember.propTypes = {
+RemoveableUserItem.propTypes = {
   id: PropTypes.number.isRequired,
   netid: PropTypes.string.isRequired,
   name: PropTypes.string,
-  removeCourseStaff: PropTypes.func.isRequired,
+  showRemoveButton: PropTypes.bool,
+  onRemove: PropTypes.func.isRequired,
 }
 
-export default CourseStaffMember
+export default RemoveableUserItem
diff --git a/src/components/admin/AdminUsersPanel.jsx b/src/components/admin/AdminUsersPanel.jsx
new file mode 100644
index 0000000..8681a92
--- /dev/null
+++ b/src/components/admin/AdminUsersPanel.jsx
@@ -0,0 +1,210 @@
+import React, { useState, useEffect } from 'react'
+import PropTypes from 'prop-types'
+import ReactDOM from 'react-dom'
+import {
+  Card,
+  CardHeader,
+  CardTitle,
+  CardBody,
+  ListGroup,
+  ListGroupItem,
+  InputGroup,
+  InputGroupAddon,
+  Button,
+  FormText,
+} from 'reactstrap'
+import { useInput } from 'react-hanger'
+import { useDebounce } from 'use-debounce'
+import { CancelToken } from 'axios'
+import FlipMove from 'react-flip-move'
+import { AsyncTypeahead, Highlighter } from 'react-bootstrap-typeahead'
+
+import axios from '../../actions/axios'
+import Loading from '../Loading'
+
+import 'react-bootstrap-typeahead/css/Typeahead.css'
+import 'react-bootstrap-typeahead/css/Typeahead-bs4.css'
+import RemoveableUserItem from '../RemoveableUserItem'
+
+const AdminUsersPanel = props => {
+  const [admins, setAdmins] = useState([])
+  const [adminsLoading, setAdminsLoading] = useState(true)
+  const netidInput = useInput('')
+  const [netidQuery] = useDebounce(netidInput.value, 500)
+  const [userSuggestions, setUserSuggestions] = useState([])
+  const [userSuggestionsLoading, setUserSuggestionsLoading] = useState(false)
+  const [pendingAdmin, setPendingAdmin] = useState([])
+
+  useEffect(() => {
+    axios
+      .get('/api/users/admins')
+      .then(res => {
+        setAdmins(res.data)
+        setAdminsLoading(false)
+      })
+      .catch(err => {
+        console.error(err)
+      })
+  }, [])
+
+  useEffect(() => {
+    if (!netidQuery) {
+      return () => {}
+    }
+    const source = CancelToken.source()
+    setUserSuggestionsLoading(true)
+    axios
+      .get('/api/autocomplete/users', {
+        params: {
+          q: netidQuery,
+        },
+        cancelToken: source.token,
+      })
+      .then(res => {
+        ReactDOM.unstable_batchedUpdates(() => {
+          // The typeahead component will filter out existing admins
+          setUserSuggestions(res.data)
+          setUserSuggestionsLoading(false)
+        })
+      })
+      .catch(err => {
+        console.error(err)
+      })
+    return () => {
+      source.cancel()
+    }
+  }, [netidQuery])
+
+  const addAdmin = () => {
+    const user = pendingAdmin[0]
+    axios
+      .put(`/api/users/admins/${user.id}`)
+      .then(res => {
+        setPendingAdmin([])
+        setAdmins([...admins, res.data])
+      })
+      .catch(err => console.error(err))
+  }
+
+  const removeAdmin = userId => {
+    axios
+      .delete(`/api/users/admins/${userId}`)
+      .then(() => {
+        setAdmins(admins.filter(admin => admin.id !== userId))
+      })
+      .catch(err => console.error(err))
+  }
+
+  const handleKeyDown = event => {
+    if (event.key === 'Enter' && pendingAdmin.length > 0) {
+      addAdmin()
+    }
+  }
+
+  let contents
+  if (adminsLoading) {
+    contents = (
+      <div>
+        <ListGroupItem key="__loading">
+          <Loading />
+        </ListGroupItem>
+      </div>
+    )
+  } else if (admins.length > 0) {
+    contents = admins.map(admin => {
+      // Don't allow users to remove themselves as admins
+      const showRemoveButton = admin.id !== props.user.id
+      return (
+        <RemoveableUserItem
+          key={admin.id}
+          onRemove={removeAdmin}
+          showRemoveButton={showRemoveButton}
+          {...admin}
+        />
+      )
+    })
+  } else {
+    contents = (
+      <div>
+        <ListGroupItem key="__none">
+          <span className="text-muted">There are no admins. Yikes.</span>
+        </ListGroupItem>
+      </div>
+    )
+  }
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle tag="h5" className="mb-0">
+          Admin users
+        </CardTitle>
+      </CardHeader>
+      <ListGroup flush>
+        <FlipMove
+          enterAnimation="accordionVertical"
+          leaveAnimation="accordionVertical"
+          duration={200}
+        >
+          {contents}
+        </FlipMove>
+      </ListGroup>
+      <CardBody className="bg-light">
+        <FormText color="muted" className="mb-2">
+          Search for users by NetID
+        </FormText>
+        <InputGroup>
+          <AsyncTypeahead
+            id="admin-search"
+            isLoading={userSuggestionsLoading}
+            options={userSuggestions}
+            onSearch={() => {
+              /* This is handled by hooks, but prop must be specified */
+            }}
+            filterBy={option => {
+              return (
+                admins.findIndex(admin => admin.netid === option.netid) === -1
+              )
+            }}
+            labelKey="netid"
+            selected={pendingAdmin}
+            onChange={options => setPendingAdmin(options)}
+            onInputChange={value => netidInput.setValue(value)}
+            minLength={1}
+            useCache={false}
+            onKeyDown={handleKeyDown}
+            renderMenuItemChildren={(option, typeaheadProps) => {
+              return (
+                <>
+                  <Highlighter search={typeaheadProps.text}>
+                    {option.netid}
+                  </Highlighter>
+                  {option.name && (
+                    <span className="text-muted ml-2">({option.name})</span>
+                  )}
+                </>
+              )
+            }}
+          />
+          <InputGroupAddon addonType="append">
+            <Button
+              color="primary"
+              disabled={pendingAdmin.length === 0}
+              onClick={addAdmin}
+            >
+              Add admin
+            </Button>
+          </InputGroupAddon>
+        </InputGroup>
+      </CardBody>
+    </Card>
+  )
+}
+
+AdminUsersPanel.propTypes = {
+  user: PropTypes.shape({
+    netid: PropTypes.string,
+    id: PropTypes.number,
+  }).isRequired,
+}
+
+export default AdminUsersPanel
diff --git a/src/migrations/20190410180318-create-is-admin-index.js b/src/migrations/20190410180318-create-is-admin-index.js
new file mode 100644
index 0000000..bc1a36d
--- /dev/null
+++ b/src/migrations/20190410180318-create-is-admin-index.js
@@ -0,0 +1,9 @@
+module.exports = {
+  up: (queryInterface, _Sequelize) => {
+    return queryInterface.addIndex('users', ['isAdmin'])
+  },
+
+  down: (queryInterface, _Sequelize) => {
+    return queryInterface.removeIndex('users', ['isAdmin'])
+  },
+}
diff --git a/src/models/User.js b/src/models/User.js
index 1b4ac2a..9757664 100644
--- a/src/models/User.js
+++ b/src/models/User.js
@@ -1,26 +1,36 @@
 module.exports = (sequelize, DataTypes) => {
-  const obj = sequelize.define('user', {
-    netid: {
-      type: DataTypes.STRING,
-      allowNull: false,
-      unique: 'netid',
-    },
-    universityName: DataTypes.STRING,
-    preferredName: DataTypes.STRING,
-    name: {
-      type: DataTypes.VIRTUAL(DataTypes.STRING, [
-        'universityName',
-        'preferredName',
-      ]),
-      get() {
-        return this.get('preferredName') || this.get('universityName')
+  const obj = sequelize.define(
+    'user',
+    {
+      netid: {
+        type: DataTypes.STRING,
+        allowNull: false,
+        unique: 'netid',
+      },
+      universityName: DataTypes.STRING,
+      preferredName: DataTypes.STRING,
+      name: {
+        type: DataTypes.VIRTUAL(DataTypes.STRING, [
+          'universityName',
+          'preferredName',
+        ]),
+        get() {
+          return this.get('preferredName') || this.get('universityName')
+        },
+      },
+      isAdmin: {
+        type: DataTypes.BOOLEAN,
+        defaultValue: false,
       },
     },
-    isAdmin: {
-      type: DataTypes.BOOLEAN,
-      defaultValue: false,
-    },
-  })
+    {
+      indexes: [
+        {
+          fields: ['isAdmin'],
+        },
+      ],
+    }
+  )
 
   obj.associate = models => {
     models.User.belongsToMany(models.Course, {
diff --git a/src/pages/adminIndex.js b/src/pages/adminIndex.js
new file mode 100644
index 0000000..a7cd3bc
--- /dev/null
+++ b/src/pages/adminIndex.js
@@ -0,0 +1,22 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Container } from 'reactstrap'
+
+import PageWithUser from '../components/PageWithUser'
+import AdminUsersPanel from '../components/admin/AdminUsersPanel'
+
+const AdminIndex = ({ user }) => (
+  <Container>
+    <h1 className="display-4">Admin</h1>
+    <AdminUsersPanel user={user} />
+  </Container>
+)
+
+AdminIndex.propTypes = {
+  user: PropTypes.shape({
+    name: PropTypes.string,
+    netid: PropTypes.string,
+  }).isRequired,
+}
+
+export default PageWithUser(AdminIndex, { requireAdmin: true })
diff --git a/src/pages/courseStaff.js b/src/pages/courseStaff.js
index b782deb..acd00ec 100644
--- a/src/pages/courseStaff.js
+++ b/src/pages/courseStaff.js
@@ -21,7 +21,7 @@ import {
 
 import PageWithUser from '../components/PageWithUser'
 import AddStaff from '../components/AddStaff'
-import CourseStaffMember from '../components/CourseStaffMember'
+import RemoveableUserItem from '../components/RemoveableUserItem'
 
 class CourseStaff extends React.Component {
   static async getInitialProps({ isServer, store, query }) {
@@ -66,11 +66,9 @@ class CourseStaff extends React.Component {
       users = this.props.course.staff.map(id => {
         const user = this.props.users[id]
         return (
-          <CourseStaffMember
+          <RemoveableUserItem
             key={user.id}
-            removeCourseStaff={userId =>
-              this.props.removeCourseStaff(courseId, userId)
-            }
+            onRemove={userId => this.props.removeCourseStaff(courseId, userId)}
             {...user}
           />
         )
diff --git a/src/routes.js b/src/routes.js
index 2ddb2ed..8306474 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -19,5 +19,6 @@ routes
   .add('courseStaff', withBaseUrl('/course/:id/staff'))
   .add('createQueue', withBaseUrl('/course/:courseId/queue/create'))
   .add('userSettings', withBaseUrl('/settings'))
+  .add('adminIndex', withBaseUrl('/admin'))
 
 module.exports = routes


Test patch:
diff --git a/src/api/autocomplete.test.js b/src/api/autocomplete.test.js
new file mode 100644
index 0000000..46ccec0
--- /dev/null
+++ b/src/api/autocomplete.test.js
@@ -0,0 +1,46 @@
+/* eslint-env jest */
+const app = require('../app')
+const testutil = require('../../test/util')
+const { requestAsUser } = require('../../test/util')
+
+beforeEach(async () => {
+  await testutil.setupTestDb()
+  await testutil.populateTestDb()
+})
+afterEach(() => testutil.destroyTestDb())
+
+describe('Autocomplete API', () => {
+  it('returns all users for empty query', async () => {
+    const request = await requestAsUser(app, 'admin')
+    const res = await request.get('/api/autocomplete/users?q=')
+    expect(res.statusCode).toBe(200)
+    expect(res.body).toHaveLength(6)
+    expect(res.body[0]).toMatchObject({
+      netid: 'dev',
+      isAdmin: true,
+    })
+  })
+
+  it('returns specific user for partial netid', async () => {
+    const request = await requestAsUser(app, 'admin')
+    const res = await request.get('/api/autocomplete/users?q=225')
+    expect(res.statusCode).toBe(200)
+    expect(res.body).toHaveLength(1)
+    expect(res.body[0]).toMatchObject({
+      netid: '225staff',
+      name: '225 Staff',
+    })
+  })
+
+  it('disallows requests from course staff', async () => {
+    const request = await requestAsUser(app, '225staff')
+    const res = await request.get('/api/autocomplete/users?q=225')
+    expect(res.statusCode).toBe(403)
+  })
+
+  it('disallows requests from student', async () => {
+    const request = await requestAsUser(app, 'student')
+    const res = await request.get('/api/autocomplete/users?q=225')
+    expect(res.statusCode).toBe(403)
+  })
+})
diff --git a/src/api/users.test.js b/src/api/users.test.js
index 2bb7746..8778a6a 100644
--- a/src/api/users.test.js
+++ b/src/api/users.test.js
@@ -4,12 +4,12 @@ const app = require('../app')
 const testutil = require('../../test/util')
 const { requestAsUser } = require('../../test/util')
 
-beforeAll(async () => {
+beforeEach(async () => {
   await testutil.setupTestDb()
   await testutil.populateTestDb()
 })
 
-afterAll(async () => {
+afterEach(async () => {
   await testutil.destroyTestDb()
 })
 
@@ -38,6 +38,78 @@ describe('Users API', () => {
     })
   })
 
+  describe('GET /api/users/admins', () => {
+    test('returns correct users for admin', async () => {
+      const request = await requestAsUser(app, 'dev')
+      const res = await request.get('/api/users/admins')
+      expect(res.statusCode).toBe(200)
+      expect(res.body).toHaveLength(2)
+      expect(res.body[0].netid).toBe('dev')
+      expect(res.body[1].netid).toBe('admin')
+    })
+
+    test('returns 403 for 225staff', async () => {
+      const request = await requestAsUser(app, '225staff')
+      const res = await request.get('/api/users/admins')
+      expect(res.statusCode).toBe(403)
+    })
+
+    test('returns 403 for student', async () => {
+      const request = await requestAsUser(app, 'student')
+      const res = await request.get('/api/users/admins')
+      expect(res.statusCode).toBe(403)
+    })
+  })
+
+  describe('PUT /api/users/admins/:userId', () => {
+    test('succeeds for existing admin', async () => {
+      const request = await requestAsUser(app, 'dev')
+      // make "225staff" an admin
+      const res = await request.put('/api/users/admins/3')
+      expect(res.statusCode).toBe(201)
+      expect(res.body.isAdmin).toBe(true)
+    })
+
+    test('fails for non-admin', async () => {
+      const request = await requestAsUser(app, '241staff')
+      const res = await request.put('/api/users/admins/3')
+      expect(res.statusCode).toBe(403)
+    })
+
+    test('fails for student', async () => {
+      const request = await requestAsUser(app, 'student')
+      const res = await request.put('/api/users/admins/3')
+      expect(res.statusCode).toBe(403)
+    })
+  })
+
+  describe('DELETE /api/users/admins/:userId', () => {
+    test('succeeds for existing admin', async () => {
+      const request = await requestAsUser(app, 'dev')
+      // make "admin" not an admin
+      const res = await request.delete('/api/users/admins/2')
+      expect(res.statusCode).toBe(204)
+    })
+
+    test('fails for self', async () => {
+      const request = await requestAsUser(app, 'dev')
+      const res = await request.delete('/api/users/admins/1')
+      expect(res.statusCode).toBe(403)
+    })
+
+    test('fails for non-admin', async () => {
+      const request = await requestAsUser(app, '241staff')
+      const res = await request.delete('/api/users/admins/2')
+      expect(res.statusCode).toBe(403)
+    })
+
+    test('fails for student', async () => {
+      const request = await requestAsUser(app, 'student')
+      const res = await request.delete('/api/users/admins/2')
+      expect(res.statusCode).toBe(403)
+    })
+  })
+
   describe('GET /api/users', () => {
     test('returns all users for admin', async () => {
       const request = await requestAsUser(app, 'admin')