Repository: illinois/queue
Pull request #228 (2019-03-08 22:36:56)

Programmatic admission control
This is an implementation of the feature discussed in #226. It includes support for defining and enabling an HTTP endpoint that will determine if a question should be admitted to the queue or not. As a part of this, I build a new queue settings page where you can edit name/location/admission control/etc., as well as delete a queue.

![Queue setting screenshot](https://user-images.githubusercontent.com/1476544/54062988-32eded00-41cf-11e9-89ab-f2f9c102da40.png)

As a part of this, I revamped how the API responds with errors so that they're always valid JSON. This required updating a lot of tests and such. However, it should be more sustainable in the future.

Programmatic admission control itself currently doesn't include any tests, but they'd be nice to have.

Todo:

- [x] Write docs

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/doc/AdmissionControl.md b/doc/AdmissionControl.md
new file mode 100644
index 0000000..17a44b1
--- /dev/null
+++ b/doc/AdmissionControl.md
@@ -0,0 +1,24 @@
+# Admission Control
+
+You can define custom policies for your queues to determine if a question will be allowed into your queue. You could use this feature to do anything from enforcing a character minimum for question topics to maintaining a blacklist of users for your queue.
+
+An admission control policy is implemented as an HTTP endpoint that you define. When a new question is added to your queue, the queue application will send a `POST` request to your server with the question details. You can then respond with a flag to indicate if the question is "good" or not, as well as provide a reason to the student as to why their question was rejected.
+
+Here's an example of a response indicating that a question will be allowed:
+
+```json
+{
+  "allowed": true
+}
+```
+
+Here's an example of a response indicating that a question was not allowed because it did not meet a minimum character count:
+
+```json
+{
+  "allowed": false,
+  "reason": "Your topic did not meet the required character count"
+}
+```
+
+An example server is located in the `example-admission-control` directory of this repository. To try it out, run `npm run start:admissioncontrol` from the root of this repository. You can then add `http://localhost:4005` as the admission control URL for your locally-running queue and try adding some questions. The sample policy simply enforces that the question topic is 10 or more characters.
diff --git a/example-admission-control/api.js b/example-admission-control/api.js
new file mode 100644
index 0000000..fca0cd6
--- /dev/null
+++ b/example-admission-control/api.js
@@ -0,0 +1,24 @@
+const express = require('express')
+const bodyParser = require('body-parser')
+
+const app = express()
+const port = process.env.PORT || 4005
+
+app.use(bodyParser.json())
+
+app.post('/', (req, res) => {
+  // eslint-disable-next-line no-console
+  console.log(req.body)
+  if (req.body.topic && req.body.topic.length >= 10) {
+    res.status(200).json({ allowed: true })
+  } else {
+    res
+      .status(200)
+      .json({ allowed: false, reason: 'Topic must be at least 10 characters' })
+  }
+})
+
+app.listen(port, () => {
+  // eslint-disable-next-line no-console
+  console.log(`Example admission control API listening on port ${port}`)
+})
diff --git a/package.json b/package.json
index ed44917..f7c7156 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
     "lint": "eslint --ext js ./src",
     "fix-lint": "npm run lint -- --fix",
     "prettier": "prettier --write \"src/**/*.js\"",
-    "prettier-diff": "prettier --list-different \"src/**/*.js\""
+    "prettier-diff": "prettier --list-different \"src/**/*.js\"",
+    "start:admissioncontrol": "node example-admission-control/api.js"
   },
   "keywords": [],
   "author": "",
@@ -104,7 +105,8 @@
       "./test/setup.js"
     ],
     "testRegex": "src/.*test.js",
-    "collectCoverage": true
+    "collectCoverage": true,
+    "testEnvironment": "node"
   },
   "husky": {
     "hooks": {
diff --git a/src/actions/axios.js b/src/actions/axios.js
index 4de6672..cdded0b 100644
--- a/src/actions/axios.js
+++ b/src/actions/axios.js
@@ -17,7 +17,10 @@ axios.interceptors.response.use(null, err => {
     if (err.response.status === 401) {
       Router.replaceRoute('login')
     } else {
-      toast.error(err.response.data || 'Something went wrong')
+      const { showErrorToast } = err.config
+      if (showErrorToast === undefined || showErrorToast === true) {
+        toast.error(err.response.data.message || 'Something went wrong')
+      }
     }
   } else {
     // Something happened while setting up the request
diff --git a/src/actions/question.js b/src/actions/question.js
index b16b1cc..7d8da3d 100644
--- a/src/actions/question.js
+++ b/src/actions/question.js
@@ -49,7 +49,8 @@ export const createQuestionSuccess = makeActionCreator(
 const createQuestionFailure = makeActionCreator(
   types.CREATE_QUESTION.FAILURE,
   'queueId',
-  'question'
+  'question',
+  'message'
 )
 
 export function createQuestion(queueId, question) {
@@ -57,11 +58,14 @@ export function createQuestion(queueId, question) {
     dispatch(createQuestionRequest(queueId, question))
 
     return axios
-      .post(`/api/queues/${queueId}/questions`, question)
+      .post(`/api/queues/${queueId}/questions`, question, {
+        showErrorToast: false,
+      })
       .then(res => dispatch(createQuestionSuccess(queueId, res.data)))
       .catch(err => {
         console.error(err)
-        dispatch(createQuestionFailure(queueId, question))
+        const { message } = err.response.data
+        return dispatch(createQuestionFailure(queueId, question, message))
       })
   }
 }
diff --git a/src/api/questions.js b/src/api/questions.js
index 8230a5e..53a83dd 100644
--- a/src/api/questions.js
+++ b/src/api/questions.js
@@ -4,6 +4,7 @@ const router = require('express').Router({
 
 const { check } = require('express-validator/check')
 const { matchedData } = require('express-validator/filter')
+const axios = require('axios')
 
 const constants = require('../constants')
 const { Course, Queue, Question, User } = require('../models/')
@@ -14,6 +15,7 @@ const {
   failIfErrors,
   canUserSeeQuestionDetailsForConfidentialQueue,
   filterConfidentialQueueQuestionsForUser,
+  ApiError,
 } = require('./util')
 const requireCourseStaffForQueue = require('../middleware/requireCourseStaffForQueue')
 const requireCourseStaffForQueueForQuestion = require('../middleware/requireCourseStaffForQueueForQuestion')
@@ -58,7 +60,7 @@ router.post(
     checkLocation,
     failIfErrors,
   ],
-  safeAsync(async (req, res, _next) => {
+  safeAsync(async (req, res, next) => {
     const data = matchedData(req)
     const {
       userAuthz,
@@ -66,21 +68,23 @@ router.post(
     } = res.locals
     // make sure queue is open
     if (!res.locals.queue.open) {
-      res.status(422).send('This queue is closed')
+      next(new ApiError(422, 'This queue is closed'))
       return
     }
 
     // First, let's check if the request is coming from course staff and
     // includes a specific netid
+    let askerUser = res.locals.userAuthn
     let askerId = res.locals.userAuthn.id
     if (data.netid) {
       if (userAuthz.isAdmin || userAuthz.staffedCourseIds.includes(courseId)) {
         // The user is allowed to do this!
         const [netid] = data.netid.split('@')
         const [user] = await User.findOrCreate({ where: { netid } })
+        askerUser = user
         askerId = user.id
       } else {
-        res.status(403).send("You don't have authorization to set a netid")
+        next(new ApiError(403, "You don't have authoriation to set a netid"))
         return
       }
     }
@@ -94,10 +98,45 @@ router.post(
       },
     })
     if (existingQuestion) {
-      res.status(422).send('You already have a question on this queue')
+      next(new ApiError(422, 'You already have a question on this queue'))
       return
     }
 
+    // If this queue has an admission control policy, let's query it to see
+    // if this question will be allowed
+    if (res.locals.queue.admissionControlEnabled) {
+      let questionAllowed = true
+      let questionAllowedReason = null
+      try {
+        const allowedResponse = await axios.post(
+          res.locals.queue.admissionControlUrl,
+          {
+            name: data.name,
+            location: data.location,
+            topic: data.topic,
+            askedBy: askerUser,
+          },
+          {
+            // Don't allow receivers to keep requests open indefinitely
+            timeout: 10000,
+          }
+        )
+        questionAllowed = allowedResponse.data.allowed
+        questionAllowedReason =
+          allowedResponse.data.reason ||
+          'No reason was given by the course admission control policy'
+      } catch (e) {
+        // TODO log or something?
+        questionAllowed = false
+        questionAllowedReason = 'Error querying admission control API'
+      }
+
+      if (!questionAllowed) {
+        next(new ApiError(422, questionAllowedReason))
+        return
+      }
+    }
+
     const question = Question.build({
       name: data.name,
       // Questions in fixed-location queues should never have a location
@@ -194,12 +233,12 @@ router.post(
     requireQueueForQuestion,
     failIfErrors,
   ],
-  safeAsync(async (req, res, _next) => {
+  safeAsync(async (req, res, next) => {
     const { queue, question } = res.locals
 
     if (question.beingAnswered) {
       // Forbid someone else from taking over this question
-      res.status(403).send('Another user is already answering this question')
+      next(new ApiError(403, 'Another user is already answering this question'))
       return
     }
 
@@ -213,9 +252,12 @@ router.post(
     })
 
     if (otherQuestions !== null) {
-      res
-        .status(403)
-        .send('You are already answering another question on this queue')
+      next(
+        new ApiError(
+          403,
+          'You are already answering another question on this queue'
+        )
+      )
       return
     }
 
@@ -292,7 +334,7 @@ router.patch(
     checkLocation,
     failIfErrors,
   ],
-  safeAsync(async (req, res, _next) => {
+  safeAsync(async (req, res, next) => {
     const { userAuthn, question, queue } = res.locals
     const data = matchedData(req)
 
@@ -303,9 +345,12 @@ router.patch(
       })
       res.status(201).send(question)
     } else {
-      res
-        .status(403)
-        .send("You don't have authorization to update this question")
+      next(
+        new ApiError(
+          403,
+          "You don't have authorization to update this question"
+        )
+      )
     }
   })
 )
@@ -316,7 +361,7 @@ router.patch(
 router.delete(
   '/:questionId',
   [requireQuestion, failIfErrors],
-  safeAsync(async (req, res, _next) => {
+  safeAsync(async (req, res, next) => {
     const { userAuthn, userAuthz, question } = res.locals
     const { queueId } = question
 
@@ -341,9 +386,12 @@ router.delete(
       })
       res.status(204).send()
     } else {
-      res
-        .status(403)
-        .send("You don't have authorization to delete this question")
+      next(
+        new ApiError(
+          403,
+          "You don't have authorization to delete this question"
+        )
+      )
     }
   })
 )
diff --git a/src/api/queues.js b/src/api/queues.js
index 06b49d9..8d6f41a 100644
--- a/src/api/queues.js
+++ b/src/api/queues.js
@@ -151,6 +151,10 @@ router.patch(
     check('messageEnabled')
       .optional({ nullable: true })
       .isBoolean(),
+    check('admissionControlEnabled')
+      .optional({ nullable: true })
+      .isBoolean(),
+    check('admissionControlUrl').optional({ nullable: true }),
     validateLocation,
     failIfErrors,
   ],
@@ -165,6 +169,14 @@ router.patch(
       message: data.message !== null ? data.message : undefined,
       messageEnabled:
         data.messageEnabled !== null ? data.messageEnabled : undefined,
+      admissionControlEnabled:
+        data.admissionControlEnabled !== null
+          ? data.admissionControlEnabled
+          : undefined,
+      admissionControlUrl:
+        data.admissionControlUrl !== null
+          ? data.admissionControlUrl
+          : undefined,
     })
 
     const updatedQueue = await Queue.scope('questionCount').findOne({
diff --git a/src/api/util.js b/src/api/util.js
index 60d0ef9..9a307fa 100644
--- a/src/api/util.js
+++ b/src/api/util.js
@@ -2,6 +2,13 @@ const { validationResult } = require('express-validator/check')
 
 const { Course, Queue, Question, User } = require('../models')
 
+class ApiError extends Error {
+  constructor(httpStatusCode, message) {
+    super(message)
+    this.httpStatusCode = httpStatusCode
+  }
+}
+
 const findPropertyInRequest = (req, property) => {
   const locations = ['params', 'body']
   const location = locations.find(loc => req[loc][property] !== undefined)
@@ -14,24 +21,24 @@ const requireModel = (model, modelName, propertyName) => async (
   next
 ) => {
   const rawModelId = findPropertyInRequest(req, propertyName)
-  if (rawModelId) {
-    const parsedModelId = Number.parseInt(rawModelId, 10)
-    if (!Number.isNaN(parsedModelId)) {
-      const entity = await model.findOne({ where: { id: parsedModelId } })
-      if (entity === null) {
-        res
-          .status(404)
-          .send(`${modelName} with ID ${parsedModelId} does not exist`)
-      } else {
-        res.locals[modelName] = entity
-        next()
-      }
-    } else {
-      res.status(400).send(`${rawModelId} could not be parsed as an id`)
-    }
-  } else {
-    res.status(422).send(`${propertyName} was not present in the request`)
+  if (!rawModelId) {
+    next(new ApiError(422, `${propertyName} was not present in the request`))
+    return
+  }
+  const parsedModelId = Number.parseInt(rawModelId, 10)
+  if (Number.isNaN(parsedModelId)) {
+    next(new ApiError(400, `${rawModelId} could not be parsed as an id`))
+    return
   }
+  const entity = await model.findOne({ where: { id: parsedModelId } })
+  if (entity === null) {
+    next(
+      new ApiError(404, `${modelName} with ID ${parsedModelId} does not exist`)
+    )
+    return
+  }
+  res.locals[modelName] = entity
+  next()
 }
 
 const requireModelForModel = (
@@ -40,22 +47,22 @@ const requireModelForModel = (
   forModelName,
   forPropertyName
 ) => async (req, res, next) => {
-  if (forModelName in res.locals) {
-    if (forPropertyName in res.locals[forModelName]) {
-      const id = res.locals[forModelName][forPropertyName]
-      const entity = await model.findOne({ where: { id } })
-      if (entity === null) {
-        res.status(404).send(`${modelName} with ID ${id} does not exist`)
-      } else {
-        res.locals[modelName] = entity
-        next()
-      }
-    } else {
-      res.status(500).send(`${forPropertyName} was not in ${forModelName}`)
-    }
-  } else {
+  if (!(forModelName in res.locals)) {
     res.status(500).send(`${forModelName} was not in locals`)
+    return
+  }
+  if (!(forPropertyName in res.locals[forModelName])) {
+    res.status(500).send(`${forPropertyName} was not in ${forModelName}`)
+    return
+  }
+  const id = res.locals[forModelName][forPropertyName]
+  const entity = await model.findOne({ where: { id } })
+  if (entity === null) {
+    next(new ApiError(404, `${modelName} with ID ${id} does not exist`))
+    return
   }
+  res.locals[modelName] = entity
+  next()
 }
 
 const canUserSeeQuestionDetailsForConfidentialQueue = (userAuthz, courseId) => {
@@ -81,11 +88,11 @@ const filterConfidentialQueueQuestionsForUser = (userId, questions) => {
 }
 
 module.exports = {
+  ApiError,
   failIfErrors(req, res, next) {
     const errors = validationResult(req)
     if (!errors.isEmpty()) {
       res.status(422).json({ errors: errors.mapped() })
-      next('route')
       return
     }
     next()
diff --git a/src/app.js b/src/app.js
index 680e34e..ad32b23 100644
--- a/src/app.js
+++ b/src/app.js
@@ -50,6 +50,18 @@ app.use(
 )
 app.use(`${baseUrl}/api/queues/:queueId/questions`, require('./api/questions'))
 
+// 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 })
+})
+app.use(`${baseUrl}/api`, (_req, res, _next) => {
+  res.status(404).json({
+    message: 'Not Found',
+  })
+})
+
 // Support for course shortcodes
 app.use(`${baseUrl}/:courseCode`, require('./middleware/courseShortcodes'))
 
diff --git a/src/components/ConfirmDeleteQueueModal.js b/src/components/ConfirmDeleteQueueModal.js
deleted file mode 100644
index 40b53b3..0000000
--- a/src/components/ConfirmDeleteQueueModal.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react'
-import PropTypes from 'prop-types'
-import ConfirmModal from './ConfirmModal'
-
-const ConfirmDeleteQueueModal = props => {
-  const fullName =
-    props.queueName && props.courseName
-      ? `${props.courseName} - ${props.queueName}`
-      : ''
-  return (
-    <ConfirmModal
-      isOpen={props.isOpen}
-      toggle={props.toggle}
-      confirm={props.confirm}
-      descText={
-        <p>
-          The queue <b>{fullName}</b> and all its open questions will be
-          deleted. This cannot be undone.
-        </p>
-      }
-      confirmText="Delete queue"
-    />
-  )
-}
-
-ConfirmDeleteQueueModal.propTypes = {
-  isOpen: PropTypes.bool.isRequired,
-  toggle: PropTypes.func.isRequired,
-  confirm: PropTypes.func.isRequired,
-  queueName: PropTypes.string,
-  courseName: PropTypes.string,
-}
-
-ConfirmDeleteQueueModal.defaultProps = {
-  queueName: null,
-  courseName: null,
-}
-
-export default ConfirmDeleteQueueModal
diff --git a/src/components/ConfirmDeleteQueueModel.jsx b/src/components/ConfirmDeleteQueueModel.jsx
new file mode 100644
index 0000000..53266a3
--- /dev/null
+++ b/src/components/ConfirmDeleteQueueModel.jsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import ConfirmModal from './ConfirmModal'
+
+const ConfirmDeleteQueueModal = props => (
+  <ConfirmModal
+    isOpen={props.isOpen}
+    toggle={props.toggle}
+    confirm={props.confirm}
+    descText="This queue will be deleted. This cannot be undone."
+    confirmText="Delete queue"
+  />
+)
+
+ConfirmDeleteQueueModal.propTypes = {
+  isOpen: PropTypes.bool.isRequired,
+  toggle: PropTypes.func.isRequired,
+  confirm: PropTypes.func.isRequired,
+}
+
+export default ConfirmDeleteQueueModal
diff --git a/src/components/NewQuestion.js b/src/components/NewQuestion.js
index 9fc4051..fff1bbd 100644
--- a/src/components/NewQuestion.js
+++ b/src/components/NewQuestion.js
@@ -1,6 +1,7 @@
 import React, { Fragment } from 'react'
 import PropTypes from 'prop-types'
 import {
+  Alert,
   Collapse,
   Card,
   CardBody,
@@ -19,6 +20,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
 import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
 
 import constants from '../constants'
+import { CREATE_QUESTION } from '../constants/ActionTypes'
 
 const fields = [
   {
@@ -48,17 +50,11 @@ export default class NewQuestion extends React.Component {
       location: '',
       fieldErrors: {},
       isOpen: !this.props.isUserCourseStaff,
+      submitInProgress: false,
     }
 
     this.handleInputChange = this.handleInputChange.bind(this)
     this.handleSubmit = this.handleSubmit.bind(this)
-    this.handleKeyPress = this.handleKeyPress.bind(this)
-  }
-
-  handleKeyPress(e) {
-    if (e.key === 'Enter') {
-      this.handleSubmit()
-    }
   }
 
   onCardHeaderClick() {
@@ -75,7 +71,8 @@ export default class NewQuestion extends React.Component {
     })
   }
 
-  handleSubmit() {
+  handleSubmit(e) {
+    e.preventDefault()
     const fieldErrors = {}
     let valid = true
 
@@ -107,14 +104,23 @@ export default class NewQuestion extends React.Component {
       location: this.state.location,
       topic: this.state.topic,
     }
-    this.props.createQuestion(this.props.queueId, question).then(() => {
-      // Clear out all fields so user can add a new question
+    // Disable the button while the request is in flight
+    this.setState({
+      submitInProgress: true,
+    })
+    this.props.createQuestion(this.props.queueId, question).then(action => {
       this.setState({
-        netid: '',
-        name: this.props.isUserCourseStaff ? '' : this.props.user.name || '',
-        location: '',
-        topic: '',
+        submitInProgress: false,
       })
+      if (action.type === CREATE_QUESTION.SUCCESS) {
+        // Clear out all fields so user can add a new question
+        this.setState({
+          netid: '',
+          name: this.props.isUserCourseStaff ? '' : this.props.user.name || '',
+          location: '',
+          topic: '',
+        })
+      }
     })
   }
 
@@ -168,17 +174,16 @@ export default class NewQuestion extends React.Component {
               <Form onSubmit={this.handleSubmit} autoComplete="off">
                 {isUserCourseStaff && (
                   <FormGroup row>
-                    <Label for="netid" sm={2}>
+                    <Label for="netid" sm={2} md={3}>
                       Net ID
                     </Label>
-                    <Col sm={10}>
+                    <Col sm={10} md={9}>
                       <Input
                         name="netid"
                         id="netid"
                         placeholder="Enter a Net ID (optional)"
                         value={this.state.netid}
                         onChange={this.handleInputChange}
-                        onKeyDown={this.handleKeyPress}
                       />
                       <FormText color="muted">
                         This allows you to add a question on behalf of a
@@ -188,17 +193,16 @@ export default class NewQuestion extends React.Component {
                   </FormGroup>
                 )}
                 <FormGroup row>
-                  <Label for="name" sm={2}>
+                  <Label for="name" sm={2} md={3}>
                     Name
                   </Label>
-                  <Col sm={10}>
+                  <Col sm={10} md={9}>
                     <Input
                       name="name"
                       id="name"
                       placeholder={namePlaceholder}
                       value={this.state.name}
                       onChange={this.handleInputChange}
-                      onKeyDown={this.handleKeyPress}
                       invalid={isInvalid(this.state.fieldErrors.name)}
                     />
                     <FormFeedback>{this.state.fieldErrors.name}</FormFeedback>
@@ -206,27 +210,26 @@ export default class NewQuestion extends React.Component {
                   </Col>
                 </FormGroup>
                 <FormGroup row>
-                  <Label for="topic" sm={2}>
+                  <Label for="topic" sm={2} md={3}>
                     Topic
                   </Label>
-                  <Col sm={10}>
+                  <Col sm={10} md={9}>
                     <Input
                       name="topic"
                       id="topic"
                       placeholder={topicPlaceholder}
                       value={this.state.topic}
                       onChange={this.handleInputChange}
-                      onKeyDown={this.handleKeyPress}
                       invalid={isInvalid(this.state.fieldErrors.topic)}
                     />
                     <FormFeedback>{this.state.fieldErrors.topic}</FormFeedback>
                   </Col>
                 </FormGroup>
                 <FormGroup row>
-                  <Label for="location" sm={2}>
+                  <Label for="location" sm={2} md={3}>
                     Location
                   </Label>
-                  <Col sm={10}>
+                  <Col sm={10} md={9}>
                     <Input
                       name="location"
                       id="location"
@@ -234,7 +237,6 @@ export default class NewQuestion extends React.Component {
                       value={queueLocation}
                       disabled={fixedLocation}
                       onChange={this.handleInputChange}
-                      onKeyDown={this.handleKeyPress}
                       invalid={isInvalid(this.state.fieldErrors.location)}
                     />
                     <FormFeedback>
@@ -245,11 +247,18 @@ export default class NewQuestion extends React.Component {
                     )}
                   </Col>
                 </FormGroup>
+                <Alert
+                  color="danger"
+                  fade={false}
+                  isOpen={!!this.props.questionError}
+                >
+                  {this.props.questionError}
+                </Alert>
                 <Button
                   block
                   color="primary"
-                  type="button"
-                  onClick={this.handleSubmit}
+                  type="submit"
+                  disabled={this.state.submitInProgress}
                 >
                   Add to queue
                 </Button>
@@ -278,6 +287,7 @@ NewQuestion.defaultProps = {
     location: '',
     fixedLocation: false,
   },
+  questionError: null,
 }
 
 NewQuestion.propTypes = {
@@ -290,5 +300,6 @@ NewQuestion.propTypes = {
     name: PropTypes.string,
   }).isRequired,
   createQuestion: PropTypes.func.isRequired,
+  questionError: PropTypes.string,
   isUserCourseStaff: PropTypes.bool.isRequired,
 }
diff --git a/src/components/QueueCard.js b/src/components/QueueCard.js
index 6fce955..2f44b9b 100644
--- a/src/components/QueueCard.js
+++ b/src/components/QueueCard.js
@@ -8,16 +8,10 @@ import {
   faEyeSlash,
 } from '@fortawesome/free-solid-svg-icons'
 
+import { Router } from '../routes'
 import ShowForCourseStaff from './ShowForCourseStaff'
 
-const QueueCard = ({
-  queue,
-  courseName,
-  open,
-  onDelete,
-  onUpdate,
-  ...rest
-}) => {
+const QueueCard = ({ queue, courseName, open, ...rest }) => {
   const { name: queueName, location, questionCount, isConfidential } = queue
 
   const questionCountText = `${questionCount} Question${
@@ -25,20 +19,10 @@ const QueueCard = ({
   }`
   const locationText = location || 'No location specified'
 
-  const handleDelete = e => {
+  const handleSettings = e => {
     e.stopPropagation()
     e.preventDefault()
-    onDelete()
-  }
-
-  const handleUpdate = e => {
-    e.stopPropagation()
-    e.preventDefault()
-    onUpdate()
-  }
-
-  const preventKeyBubbleUp = e => {
-    e.stopPropagation()
+    Router.pushRoute('queueSettings', { id: queue.id })
   }
 
   const title = courseName || queueName
@@ -60,25 +44,13 @@ const QueueCard = ({
           <div>
             <ShowForCourseStaff courseId={queue.courseId}>
               <Button
-                color="danger"
-                size="sm"
-                outline
-                onClick={handleDelete}
-                onKeyPress={preventKeyBubbleUp}
-              >
-                Delete
-              </Button>
-            </ShowForCourseStaff>
-            <ShowForCourseStaff courseId={queue.courseId}>
-              <Button
-                color="primary"
+                color="secondary"
                 size="sm"
-                className="mr-0 ml-1"
                 outline
-                onClick={handleUpdate}
-                onKeyPress={preventKeyBubbleUp}
+                onClick={handleSettings}
+                onKeyPress={handleSettings}
               >
-                Edit
+                Settings
               </Button>
             </ShowForCourseStaff>
           </div>
@@ -131,8 +103,6 @@ QueueCard.propTypes = {
   }).isRequired,
   courseName: PropTypes.string,
   open: PropTypes.bool,
-  onUpdate: PropTypes.func.isRequired,
-  onDelete: PropTypes.func.isRequired,
 }
 
 export default QueueCard
diff --git a/src/components/QueueCardList.js b/src/components/QueueCardList.js
index d79d6e4..814c566 100644
--- a/src/components/QueueCardList.js
+++ b/src/components/QueueCardList.js
@@ -5,78 +5,17 @@ import { Col, Card, CardBody } from 'reactstrap'
 import { Router } from '../routes'
 
 import QueueCard from './QueueCard'
-import QueueEdit from './QueueEdit'
-import ConfirmDeleteQueueModal from './ConfirmDeleteQueueModal'
-
-/* eslint-disable react/prefer-stateless-function */
-class QueueCardList extends React.Component {
-  constructor(props) {
-    super(props)
-
-    this.state = {
-      showDeleteQueueModal: false,
-      showEditQueueModal: false,
-      pendingDeleteQueue: null,
-      pendingEditQueueId: null,
-    }
-  }
-
-  editQueue(queueId) {
-    this.setState({
-      showEditQueueModal: true,
-      pendingEditQueueId: queueId,
-    })
-  }
-
-  submitQueueEdit(attributes) {
-    const { pendingEditQueueId } = this.state
-    this.props.updateQueue(pendingEditQueueId, attributes).then(() => {
-      this.setState({
-        showEditQueueModal: false,
-        pendingEditQueueId: null,
-      })
-    })
-  }
-
-  queueEditCancel() {
-    this.setState({
-      showEditQueueModal: false,
-      pendingEditQueueId: null,
-    })
-  }
-
-  deleteQueue(courseId, queueId) {
-    this.setState({
-      showDeleteQueueModal: true,
-      pendingDeleteQueue: { courseId, queueId },
-    })
-  }
-
-  confirmDeleteQueue() {
-    const { courseId, queueId } = this.state.pendingDeleteQueue
-    this.props.deleteQueue(courseId, queueId).then(() => {
-      this.setState({
-        showDeleteQueueModal: false,
-        pendingDeleteQueue: null,
-      })
-    })
-  }
 
-  toggleDeleteModal() {
-    this.setState(prevState => ({
-      showDeleteQueueModal: !prevState.showDeleteQueueModal,
-    }))
-  }
-
-  queueSorter(l, r) {
-    const queueL = this.props.queues[l]
-    const queueR = this.props.queues[r]
-    const courseNameL = this.props.courses[queueL.courseId].name.toLowerCase()
-    const courseNameR = this.props.courses[queueR.courseId].name.toLowerCase()
+const QueueCardList = props => {
+  const queueSorter = (l, r) => {
+    const queueL = props.queues[l]
+    const queueR = props.queues[r]
+    const courseNameL = props.courses[queueL.courseId].name.toLowerCase()
+    const courseNameR = props.courses[queueR.courseId].name.toLowerCase()
 
     if (courseNameL === courseNameR) {
-      const queueNameL = this.props.queues[l].name.toLowerCase()
-      const queueNameR = this.props.queues[r].name.toLowerCase()
+      const queueNameL = props.queues[l].name.toLowerCase()
+      const queueNameR = props.queues[r].name.toLowerCase()
 
       if (queueNameL < queueNameR) {
         return -1
@@ -94,106 +33,80 @@ class QueueCardList extends React.Component {
     return 1
   }
 
-  render() {
-    const CardCol = ({ children, ...rest }) => (
-      <Col
-        xs={{ size: 12 }}
-        md={{ size: 6 }}
-        lg={{ size: 4 }}
-        className="mb-3"
-        {...rest}
-      >
-        {children}
-      </Col>
-    )
-
-    let queues
-    if (this.props.queueIds && this.props.queueIds.length > 0) {
-      const handleQueueClick = id => {
-        Router.pushRoute('queue', { id })
-      }
+  // eslint-disable-next-line react/prop-types
+  const CardCol = ({ children, ...rest }) => (
+    <Col
+      xs={{ size: 12 }}
+      md={{ size: 6 }}
+      lg={{ size: 4 }}
+      className="mb-3"
+      {...rest}
+    >
+      {children}
+    </Col>
+  )
+
+  let queues
+  if (props.queueIds && props.queueIds.length > 0) {
+    const handleQueueClick = id => {
+      Router.pushRoute('queue', { id })
+    }
 
-      const handleQueueKeyPress = (e, id) => {
-        if (e.key === 'Enter' || e.key === ' ') {
-          handleQueueClick(id)
-        }
+    const handleQueueKeyPress = (e, id) => {
+      if (e.key === 'Enter' || e.key === ' ') {
+        handleQueueClick(id)
       }
-
-      this.props.queueIds.sort(this.queueSorter.bind(this))
-
-      queues = this.props.queueIds.map(queueId => {
-        const queue = this.props.queues[queueId]
-        const courseName = this.props.courses[queue.courseId].name
-        return (
-          <CardCol key={queue.id}>
-            <QueueCard
-              queue={queue}
-              open={this.props.openQueue}
-              courseName={this.props.showCourseName ? courseName : null}
-              onClick={() => handleQueueClick(queue.id)}
-              onDelete={() => this.deleteQueue(queue.courseId, queue.id)}
-              onUpdate={() => this.editQueue(queue.id)}
-              onKeyPress={e => handleQueueKeyPress(e, queue.id)}
-              tabIndex="0"
-            />
-          </CardCol>
-        )
-      })
-    } else {
-      queues = (
-        <Col>
-          <Card className="bg-light">
-            <CardBody className="text-center">
-              There aren&apos;t any {this.props.openQueue ? 'open' : 'closed'}{' '}
-              queues right now
-            </CardBody>
-          </Card>
-        </Col>
-      )
     }
 
-    const { pendingDeleteQueue } = this.state
-    return (
-      <Fragment>
-        {queues}
-        <ConfirmDeleteQueueModal
-          queueName={
-            pendingDeleteQueue && this.props.queues[pendingDeleteQueue.queueId]
-              ? this.props.queues[pendingDeleteQueue.queueId].name
-              : null
-          }
-          courseName={
-            pendingDeleteQueue &&
-            this.props.courses[pendingDeleteQueue.courseId]
-              ? this.props.courses[pendingDeleteQueue.courseId].name
-              : null
-          }
-          isOpen={this.state.showDeleteQueueModal}
-          toggle={() => this.toggleDeleteModal()}
-          confirm={() => this.confirmDeleteQueue()}
-        />
-        <QueueEdit
-          queue={this.props.queues[this.state.pendingEditQueueId]}
-          isOpen={this.state.showEditQueueModal}
-          onSubmitQueueEdit={attributes => this.submitQueueEdit(attributes)}
-          onCancel={() => this.queueEditCancel()}
-        />
-        <style global jsx>{`
-          .row.equal-height {
-            display: flex;
-            flex-wrap: wrap;
-          }
-          .row.equal-height > [class*='col-'] {
-            display: flex;
-            flex-direction: column;
-          }
-          .row.equal-height .card {
-            flex: 1;
-          }
-        `}</style>
-      </Fragment>
+    props.queueIds.sort(queueSorter)
+
+    queues = props.queueIds.map(queueId => {
+      const queue = props.queues[queueId]
+      const courseName = props.courses[queue.courseId].name
+      return (
+        <CardCol key={queue.id}>
+          <QueueCard
+            queue={queue}
+            open={props.openQueue}
+            courseName={props.showCourseName ? courseName : null}
+            onClick={() => handleQueueClick(queue.id)}
+            onKeyPress={e => handleQueueKeyPress(e, queue.id)}
+            tabIndex="0"
+          />
+        </CardCol>
+      )
+    })
+  } else {
+    queues = (
+      <Col>
+        <Card className="bg-light">
+          <CardBody className="text-center">
+            There aren&apos;t any {props.openQueue ? 'open' : 'closed'} queues
+            right now
+          </CardBody>
+        </Card>
+      </Col>
     )
   }
+
+  return (
+    <Fragment>
+      {queues}
+      <style global jsx>{`
+        .row.equal-height {
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .row.equal-height > [class*='col-'] {
+          display: flex;
+          flex-direction: column;
+        }
+        .row.equal-height .card {
+          flex: 1;
+        }
+      `}</style>
+    </Fragment>
+  )
 }
 
 QueueCardList.defaultProps = {
@@ -219,8 +132,6 @@ QueueCardList.propTypes = {
   ),
   showCourseName: PropTypes.bool,
   openQueue: PropTypes.bool,
-  updateQueue: PropTypes.func.isRequired,
-  deleteQueue: PropTypes.func.isRequired,
 }
 
 export default QueueCardList
diff --git a/src/components/QueueEdit.js b/src/components/QueueEdit.js
deleted file mode 100644
index 0429c5a..0000000
--- a/src/components/QueueEdit.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import React from 'react'
-import PropTypes from 'prop-types'
-import {
-  Col,
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-  Form,
-  FormGroup,
-  Input,
-  Label,
-  Button,
-  FormFeedback,
-} from 'reactstrap'
-
-const fields = [
-  {
-    name: 'name',
-    minLength: 1,
-  },
-  {
-    name: 'location',
-    minLength: 1,
-  },
-]
-
-const isInvalid = error => error !== undefined && error !== ''
-
-class QueueEdit extends React.Component {
-  constructor(props) {
-    super(props)
-
-    this.state = {
-      name: '',
-      location: '',
-      fixedLocation: false,
-      isFieldValid: {},
-    }
-
-    this.handleInputChange = this.handleInputChange.bind(this)
-    this.handleSubmitEdit = this.handleSubmitEdit.bind(this)
-    this.handleCancel = this.handleCancel.bind(this)
-    this.handleModalExit = this.handleModalExit.bind(this)
-  }
-
-  componentWillReceiveProps(nextProps) {
-    if (nextProps.queue) {
-      this.setState({
-        name: nextProps.queue.name,
-        location: nextProps.queue.location,
-        fixedLocation: nextProps.queue.fixedLocation,
-        isFieldValid: {},
-      })
-    }
-  }
-
-  handleInputChange(event) {
-    this.setState({
-      [event.target.name]: event.target.value,
-    })
-  }
-
-  handleCancel() {
-    this.props.onCancel()
-  }
-
-  handleSubmitEdit() {
-    const isFieldValid = {}
-    let valid = true
-
-    fields.forEach(({ name, minLength }) => {
-      // Handle fixed location queue
-      if (name === 'location' && !this.props.queue.fixedLocation) return
-
-      if (!this.state[name]) {
-        isFieldValid[name] = 'This field is required!'
-        valid = false
-      } else if (this.state[name].length < minLength) {
-        isFieldValid[
-          name
-        ] = `This field has a minimum length of ${minLength} characters`
-        valid = false
-      }
-    })
-
-    this.setState({
-      isFieldValid,
-    })
-    if (!valid) return
-
-    const attributes = {
-      name: this.state.name,
-      location: this.state.location,
-    }
-    this.props.onSubmitQueueEdit(attributes)
-  }
-
-  handleModalExit() {
-    this.setState({
-      name: '',
-      location: '',
-      isFieldValid: {},
-    })
-  }
-
-  render() {
-    return (
-      <Modal isOpen={this.props.isOpen} onClosed={this.handleModalExit}>
-        <ModalHeader>Edit Queue</ModalHeader>
-        <ModalBody>
-          <Form autoComplete="off">
-            <FormGroup row>
-              <Label for="name" sm={4}>
-                Name
-              </Label>
-              <Col sm={8}>
-                <Input
-                  type="text"
-                  name="name"
-                  id="name"
-                  value={this.state.name}
-                  onChange={this.handleInputChange}
-                  invalid={isInvalid(this.state.isFieldValid.name)}
-                />
-                <FormFeedback>{this.state.isFieldValid.name}</FormFeedback>
-              </Col>
-            </FormGroup>
-            <FormGroup row>
-              <Label for="location" sm={4}>
-                Location
-              </Label>
-              <Col sm={8}>
-                <Input
-                  type="text"
-                  name="location"
-                  id="location"
-                  value={this.state.location}
-                  onChange={this.handleInputChange}
-                  invalid={isInvalid(this.state.isFieldValid.location)}
-                />
-                <FormFeedback>{this.state.isFieldValid.location}</FormFeedback>
-              </Col>
-            </FormGroup>
-          </Form>
-        </ModalBody>
-        <ModalFooter>
-          <Button color="light" onClick={this.handleCancel}>
-            Cancel
-          </Button>
-          <Button color="primary" onClick={this.handleSubmitEdit}>
-            Update
-          </Button>
-        </ModalFooter>
-      </Modal>
-    )
-  }
-}
-
-QueueEdit.propTypes = {
-  queue: PropTypes.shape({
-    name: PropTypes.string,
-    location: PropTypes.string,
-    fixedLocation: PropTypes.bool,
-  }),
-  isOpen: PropTypes.bool.isRequired,
-  onCancel: PropTypes.func.isRequired,
-  onSubmitQueueEdit: PropTypes.func.isRequired,
-}
-
-QueueEdit.defaultProps = {
-  queue: null,
-}
-
-export default QueueEdit
diff --git a/src/components/queueSettings/AdmissionControlPanel.jsx b/src/components/queueSettings/AdmissionControlPanel.jsx
new file mode 100644
index 0000000..04e1882
--- /dev/null
+++ b/src/components/queueSettings/AdmissionControlPanel.jsx
@@ -0,0 +1,96 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import {
+  Card,
+  CardHeader,
+  CardTitle,
+  CardBody,
+  CustomInput,
+  Button,
+  Input,
+  Form,
+  FormGroup,
+  Label,
+  Col,
+} from 'reactstrap'
+import { useBoolean, useInput } from 'react-hanger'
+
+const AdmissionControlPanel = ({ queue, updateQueue }) => {
+  const admissionControlUrl = queue.admissionControlUrl || ''
+  const enabledToggle = useBoolean(queue.admissionControlEnabled)
+  const admissionControlUrlInput = useInput(admissionControlUrl)
+  const changed =
+    enabledToggle.value !== queue.admissionControlEnabled ||
+    admissionControlUrlInput.value !== admissionControlUrl
+
+  const onSubmit = e => {
+    e.preventDefault()
+    updateQueue({
+      admissionControlEnabled: enabledToggle.value,
+      admissionControlUrl: admissionControlUrlInput.value,
+    })
+  }
+
+  return (
+    <Card className="mb-3">
+      <CardHeader>
+        <CardTitle tag="h5" className="mb-0">
+          Admission control
+        </CardTitle>
+      </CardHeader>
+      <CardBody>
+        <p>
+          You can set a programmatic admission control policy for your queue.
+          This allows you to disallow questions based on topic, rate-limit
+          students on the queue, or even maintain a blacklist of queue users.
+          For more information, check out the{' '}
+          <a href="https://github.com/illinois/queue/blob/master/doc/AdmissionControl.md">
+            admission control docs.
+          </a>
+        </p>
+        <Form autoComplete="off" onSubmit={onSubmit}>
+          <FormGroup row>
+            <Label for="enable" sm={3}>
+              Enable custom policy
+            </Label>
+            <Col sm={9} className="d-flex align-items-center">
+              <CustomInput
+                id="enable"
+                type="switch"
+                className="mr-3 d-inline-block"
+                onChange={enabledToggle.toggle}
+                checked={enabledToggle.value}
+              />
+            </Col>
+          </FormGroup>
+          <FormGroup row>
+            <Label for="url" sm={3}>
+              Policy API URL
+            </Label>
+            <Col sm={9}>
+              <Input
+                id="url"
+                type="url"
+                {...admissionControlUrlInput.bindToInput}
+                disabled={!enabledToggle.value}
+              />
+            </Col>
+          </FormGroup>
+          <Button disabled={!changed} color="primary" type="submit">
+            Update
+          </Button>
+        </Form>
+      </CardBody>
+    </Card>
+  )
+}
+
+AdmissionControlPanel.propTypes = {
+  queue: PropTypes.shape({
+    admissionControlUrl: PropTypes.string,
+    admissionControlEnabled: PropTypes.bool,
+  }).isRequired,
+  updateQueue: PropTypes.func.isRequired,
+}
+
+export default AdmissionControlPanel
diff --git a/src/components/queueSettings/DangerPanel.jsx b/src/components/queueSettings/DangerPanel.jsx
new file mode 100644
index 0000000..a56cdc4
--- /dev/null
+++ b/src/components/queueSettings/DangerPanel.jsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import {
+  Card,
+  CardHeader,
+  CardTitle,
+  Button,
+  ListGroup,
+  ListGroupItem,
+} from 'reactstrap'
+import { useBoolean } from 'react-hanger'
+import ConfirmDeleteQueueModal from '../ConfirmDeleteQueueModel'
+
+const DangerPanel = props => {
+  const showConfirmModal = useBoolean(false)
+  return (
+    <>
+      <Card className="border border-danger">
+        <CardHeader className="bg-danger text-white">
+          <CardTitle tag="h5" className="mb-0">
+            Danger zone
+          </CardTitle>
+          <small>Here be dragons</small>
+        </CardHeader>
+        <ListGroup>
+          <ListGroupItem className="d-flex align-items-sm-center flex-column flex-sm-row">
+            <div className="mr-auto pr-3">
+              <strong>Delete this queue</strong>
+              <p className="mb-0">
+                Deleting a queue is permanent; please be sure you want to do
+                this.
+              </p>
+            </div>
+            <Button
+              outline
+              color="danger"
+              className="mt-3 mt-sm-0"
+              onClick={showConfirmModal.setTrue}
+            >
+              Delete&nbsp;queue
+            </Button>
+          </ListGroupItem>
+        </ListGroup>
+      </Card>
+      <ConfirmDeleteQueueModal
+        toggle={showConfirmModal.toggle}
+        isOpen={showConfirmModal.value}
+        confirm={() => props.deleteQueue()}
+      />
+    </>
+  )
+}
+
+DangerPanel.propTypes = {
+  deleteQueue: PropTypes.func.isRequired,
+}
+
+export default DangerPanel
diff --git a/src/components/queueSettings/GeneralPanel.jsx b/src/components/queueSettings/GeneralPanel.jsx
new file mode 100644
index 0000000..63ba3a2
--- /dev/null
+++ b/src/components/queueSettings/GeneralPanel.jsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import {
+  Card,
+  CardHeader,
+  CardTitle,
+  CardBody,
+  Button,
+  Input,
+  Form,
+  FormGroup,
+  Label,
+  Col,
+} from 'reactstrap'
+import { useInput } from 'react-hanger'
+
+const GeneralPanel = ({ queue, updateQueue }) => {
+  const name = useInput(queue.name)
+  const location = useInput(queue.location)
+  const changed = name.value !== queue.name || location.value !== queue.location
+
+  const onSubmit = e => {
+    e.preventDefault()
+    updateQueue({
+      name: name.value,
+      location: location.value,
+    })
+  }
+
+  return (
+    <Card className="mb-3">
+      <CardHeader>
+        <CardTitle tag="h5" className="mb-0">
+          General
+        </CardTitle>
+      </CardHeader>
+      <CardBody>
+        <Form autoComplete="off" onSubmit={onSubmit}>
+          <FormGroup row>
+            <Label for="name" sm={3}>
+              Name
+            </Label>
+            <Col sm={9}>
+              <Input id="name" {...name.bindToInput} />
+            </Col>
+          </FormGroup>
+          <FormGroup row>
+            <Label for="location" sm={3}>
+              Location
+            </Label>
+            <Col sm={9}>
+              <Input id="location" {...location.bindToInput} />
+            </Col>
+          </FormGroup>
+          <Button disabled={!changed} color="primary" type="submit">
+            Update
+          </Button>
+        </Form>
+      </CardBody>
+    </Card>
+  )
+}
+
+GeneralPanel.propTypes = {
+  queue: PropTypes.shape({
+    admissionControlUrl: PropTypes.string,
+    admissionControlEnabled: PropTypes.bool,
+  }).isRequired,
+  updateQueue: PropTypes.func.isRequired,
+}
+
+export default GeneralPanel
diff --git a/src/containers/NewQuestionContainer.js b/src/containers/NewQuestionContainer.js
index 25a3cc5..7d850a8 100644
--- a/src/containers/NewQuestionContainer.js
+++ b/src/containers/NewQuestionContainer.js
@@ -9,6 +9,7 @@ function mapStateToProps(state, ownProps) {
     user: state.user.user,
     queueId: ownProps.queueId,
     queue: state.queues.queues[ownProps.queueId],
+    questionError: state.questions.error,
     isUserCourseStaff:
       isUserCourseStaffForQueue(state, ownProps) ||
       isUserAdmin(state, ownProps),
diff --git a/src/middleware/requireAdmin.js b/src/middleware/requireAdmin.js
index 5491b87..57210f5 100644
--- a/src/middleware/requireAdmin.js
+++ b/src/middleware/requireAdmin.js
@@ -1,6 +1,8 @@
+const { ApiError } = require('../api/util')
+
 module.exports = (req, res, next) => {
   if (!res.locals.userAuthz.isAdmin) {
-    res.status(403).send("You don't have authorization to do this")
+    next(new ApiError(403, "You don't have authorization to do that"))
     return
   }
   next()
diff --git a/src/middleware/requireCourseStaff.js b/src/middleware/requireCourseStaff.js
index 3b58fa4..cda538c 100644
--- a/src/middleware/requireCourseStaff.js
+++ b/src/middleware/requireCourseStaff.js
@@ -1,16 +1,18 @@
+const { ApiError } = require('../api/util')
+
 module.exports = (req, res, next) => {
   if (!req.params.courseId) {
     // Missing courseId in request
-    res.status(500).send('Invalid course ID')
+    next(new ApiError(400, 'Invalid course ID'))
   } else if (res.locals.userAuthz.isAdmin) {
     // Admins can do anything!
     next()
   } else {
     const courseId = Number.parseInt(req.params.courseId, 10)
     if (Number.isNaN(courseId)) {
-      res.status(500).send('Invalid course ID')
+      next(new ApiError(400, 'Invalid course ID'))
     } else if (res.locals.userAuthz.staffedCourseIds.indexOf(courseId) === -1) {
-      res.status(403).send("You don't have authorization to do this")
+      next(new ApiError(403, "You don't have authorization to do that"))
     } else {
       next()
     }
diff --git a/src/middleware/requireCourseStaffForQueue.js b/src/middleware/requireCourseStaffForQueue.js
index ce08b6d..b6af4d7 100644
--- a/src/middleware/requireCourseStaffForQueue.js
+++ b/src/middleware/requireCourseStaffForQueue.js
@@ -1,41 +1,44 @@
 const { Course, Queue } = require('../models')
+const { ApiError } = require('../api/util')
 
 module.exports = async (req, res, next) => {
   if (res.locals.userAuthz.isAdmin) {
     // Admins can do anything course staff can!
     next()
-  } else if (!req.params.queueId) {
-    res.status(500).send('Invalid queue ID')
-  } else {
-    const queueId = Number.parseInt(req.params.queueId, 10)
-    if (Number.isNaN(queueId)) {
-      res.status(500).send('Invalid queue ID')
-      return
-    }
+    return
+  }
 
-    const course = await Course.findOne({
-      attributes: ['id'],
-      include: [
-        {
-          model: Queue,
-          attributes: [],
-          where: { id: queueId },
-        },
-      ],
-      raw: true,
-    })
+  if (!req.params.queueId) {
+    next(new ApiError(400, 'Invalid queue ID'))
+    return
+  }
+
+  const queueId = Number.parseInt(req.params.queueId, 10)
+  if (Number.isNaN(queueId)) {
+    next(new ApiError(400, 'Invalid queue ID'))
+    return
+  }
 
-    if (!course) {
-      // Let's 404?
-      res.status(404).send('Course does not exist')
-    } else if (res.locals.userAuthz.isAdmin) {
-      next()
-    } else if (
-      res.locals.userAuthz.staffedCourseIds.indexOf(course.id) === -1
-    ) {
-      res.status(403).send("You don't have authorization to do this")
-    } else {
-      next()
-    }
+  const course = await Course.findOne({
+    attributes: ['id'],
+    include: [
+      {
+        model: Queue,
+        attributes: [],
+        where: { id: queueId },
+      },
+    ],
+    raw: true,
+  })
+
+  if (!course) {
+    // Let's 404?
+    next(new ApiError(404, 'Course does not exist'))
+  } else if (res.locals.userAuthz.isAdmin) {
+    next()
+  } else if (res.locals.userAuthz.staffedCourseIds.indexOf(course.id) === -1) {
+    next(new ApiError(403, "You don't have authorization to do that"))
+  } else {
+    next()
   }
 }
diff --git a/src/middleware/requireCourseStaffForQueueForQuestion.js b/src/middleware/requireCourseStaffForQueueForQuestion.js
index 64796e4..2ee9418 100644
--- a/src/middleware/requireCourseStaffForQueueForQuestion.js
+++ b/src/middleware/requireCourseStaffForQueueForQuestion.js
@@ -1,45 +1,45 @@
 const { Course, Queue, Question } = require('../models')
+const { ApiError } = require('../api/util')
 
 module.exports = async (req, res, next) => {
   if (!req.params.questionId) {
-    res.status(500).send('Invalid question ID')
-  } else {
-    const questionId = Number.parseInt(req.params.questionId, 10)
-    if (Number.isNaN(questionId)) {
-      res.status(500).send('Invalid question ID')
-      return
-    }
+    next(new ApiError(400, 'Invalid question ID'))
+    return
+  }
+
+  const questionId = Number.parseInt(req.params.questionId, 10)
+  if (Number.isNaN(questionId)) {
+    next(new ApiError(400, 'Invalid question ID'))
+    return
+  }
 
-    const question = await Question.findByPk(questionId)
-    if (!question) {
-      res.status(404).send('Question does not exist')
-      return
-    }
-    const { queueId } = question
+  const question = await Question.findByPk(questionId)
+  if (!question) {
+    next(new ApiError(404, 'Question does not exist'))
+    return
+  }
 
-    const course = await Course.findOne({
-      attributes: ['id'],
-      include: [
-        {
-          model: Queue,
-          attributes: [],
-          where: { id: queueId },
-        },
-      ],
-      raw: true,
-    })
+  const { queueId } = question
+  const course = await Course.findOne({
+    attributes: ['id'],
+    include: [
+      {
+        model: Queue,
+        attributes: [],
+        where: { id: queueId },
+      },
+    ],
+    raw: true,
+  })
 
-    if (!course) {
-      res.status(404).send('Course does not exist')
-    } else if (res.locals.userAuthz.isAdmin) {
-      // Admins can do anything course staff can!
-      next()
-    } else if (
-      res.locals.userAuthz.staffedCourseIds.indexOf(course.id) === -1
-    ) {
-      res.status(403).send("You don't have authorization to do this")
-    } else {
-      next()
-    }
+  if (!course) {
+    next(new ApiError(404, 'Course does not exist'))
+  } else if (res.locals.userAuthz.isAdmin) {
+    // Admins can do anything course staff can!
+    next()
+  } else if (res.locals.userAuthz.staffedCourseIds.indexOf(course.id) === -1) {
+    next(new ApiError(403, "You don't have authorization to do that"))
+  } else {
+    next()
   }
 }
diff --git a/src/migrations/20190308215151-queue-admission-control.js b/src/migrations/20190308215151-queue-admission-control.js
new file mode 100644
index 0000000..a7f7c3d
--- /dev/null
+++ b/src/migrations/20190308215151-queue-admission-control.js
@@ -0,0 +1,36 @@
+module.exports = {
+  up: async (queryInterface, Sequelize) => {
+    await queryInterface.sequelize.transaction(async transaction => {
+      await queryInterface.addColumn(
+        'queues',
+        'admissionControlEnabled',
+        {
+          type: Sequelize.BOOLEAN,
+          defaultValue: false,
+          after: 'isConfidential',
+        },
+        { transaction }
+      )
+      await queryInterface.addColumn(
+        'queues',
+        'admissionControlUrl',
+        {
+          type: Sequelize.TEXT,
+          after: 'admissionControlEnabled',
+        },
+        { transaction }
+      )
+    })
+  },
+
+  down: async (queryInterface, _Sequelize) => {
+    await queryInterface.sequelize.transaction(async transaction => {
+      await queryInterface.removeColumn('queues', 'admissionControlEnabled', {
+        transaction,
+      })
+      await queryInterface.removeColumn('queues', 'admissionControlUrl', {
+        transaction,
+      })
+    })
+  },
+}
diff --git a/src/models/Queue.js b/src/models/Queue.js
index c9baec4..c48ef60 100644
--- a/src/models/Queue.js
+++ b/src/models/Queue.js
@@ -21,6 +21,11 @@ module.exports = (sequelize, DataTypes) => {
         type: DataTypes.BOOLEAN,
         defaultValue: false,
       },
+      admissionControlEnabled: {
+        type: DataTypes.BOOLEAN,
+        defaultValue: false,
+      },
+      admissionControlUrl: DataTypes.TEXT,
       startTime: DataTypes.DATE,
       endTime: DataTypes.DATE,
     },
diff --git a/src/pages/course.js b/src/pages/course.js
index 98194c7..c09a65d 100644
--- a/src/pages/course.js
+++ b/src/pages/course.js
@@ -9,14 +9,13 @@ import { faPlus, faUsers } from '@fortawesome/free-solid-svg-icons'
 
 import { Link } from '../routes'
 import { fetchCourseRequest, fetchCourse } from '../actions/course'
-import { createQueue, deleteQueue, updateQueue } from '../actions/queue'
+import { createQueue } from '../actions/queue'
 import { mapObjectToArray } from '../util'
 
 import PageWithUser from '../components/PageWithUser'
 import NewQueue from '../components/NewQueue'
 import QueueCardListContainer from '../containers/QueueCardListContainer'
 import ShowForCourseStaff from '../components/ShowForCourseStaff'
-import ConfirmDeleteQueueModal from '../components/ConfirmDeleteQueueModal'
 import CourseShortCodeInfo from '../components/CourseShortCodeInfo'
 
 class Course extends React.Component {
@@ -143,13 +142,6 @@ class Course extends React.Component {
           </Row>
           <CourseShortCodeInfo course={this.props.course} />
         </Container>
-        {this.state.showDeleteQueueModal && (
-          <ConfirmDeleteQueueModal
-            isOpen={this.state.showDeleteQueueModal}
-            toggle={() => this.toggleDeleteModal()}
-            confirm={() => this.confirmDeleteQueue()}
-          />
-        )}
         <style jsx>{`
           :global(.courses-card) {
             width: 100%;
@@ -201,9 +193,6 @@ const mapStateToProps = (state, ownProps) => {
 const mapDispatchToProps = dispatch => ({
   fetchCourse: courseId => dispatch(fetchCourse(courseId)),
   createQueue: (courseId, queue) => dispatch(createQueue(courseId, queue)),
-  updateQueue: (queueId, attributes) =>
-    dispatch(updateQueue(queueId, attributes)),
-  deleteQueue: (courseId, queueId) => dispatch(deleteQueue(courseId, queueId)),
   dispatch,
 })
 
diff --git a/src/pages/index.js b/src/pages/index.js
index e6dda02..3c883c5 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -11,20 +11,15 @@ import {
   fetchCourses,
   createCourse,
 } from '../actions/course'
-import {
-  fetchQueues,
-  createQueue,
-  deleteQueue,
-  updateQueue,
-} from '../actions/queue'
+import { fetchQueues, createQueue } from '../actions/queue'
 import { mapObjectToArray } from '../util'
 
 import PageWithUser from '../components/PageWithUser'
 import NewCourse from '../components/NewCourse'
 import NewQueue from '../components/NewQueue'
 import ShowForAdmin from '../components/ShowForAdmin'
-import QueueCardListContainer from '../containers/QueueCardListContainer'
 import DevWorkshopAd from '../components/DevWorkshopAd'
+import QueueCardListContainer from '../containers/QueueCardListContainer'
 
 class Index extends React.Component {
   static async getInitialProps({ store, isServer }) {
@@ -263,9 +258,6 @@ const mapDispatchToProps = dispatch => ({
   fetchQueues: () => dispatch(fetchQueues()),
   createCourse: course => dispatch(createCourse(course)),
   createQueue: (courseId, queue) => dispatch(createQueue(courseId, queue)),
-  updateQueue: (queueId, attributes) =>
-    dispatch(updateQueue(queueId, attributes)),
-  deleteQueue: (courseId, queueId) => dispatch(deleteQueue(courseId, queueId)),
 })
 
 export default connect(
diff --git a/src/pages/queueSettings.js b/src/pages/queueSettings.js
new file mode 100644
index 0000000..75d2b26
--- /dev/null
+++ b/src/pages/queueSettings.js
@@ -0,0 +1,107 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Container } from 'reactstrap'
+import { connect } from 'react-redux'
+
+import { Router } from '../routes'
+import {
+  fetchQueue,
+  fetchQueueRequest,
+  updateQueue,
+  deleteQueue,
+} from '../actions/queue'
+import GeneralPanel from '../components/queueSettings/GeneralPanel'
+import AdmissionControlPanel from '../components/queueSettings/AdmissionControlPanel'
+import PageWithUser from '../components/PageWithUser'
+import DangerPanel from '../components/queueSettings/DangerPanel'
+
+class QueueSettings extends React.Component {
+  static async getInitialProps({ isServer, store, query }) {
+    const queueId = Number.parseInt(query.id, 10)
+    if (isServer) {
+      store.dispatch(fetchQueueRequest(queueId))
+    }
+    return {
+      queueId,
+      isFetching: isServer,
+    }
+    // Nothing to do at the moment
+  }
+
+  componentDidMount() {
+    this.props.fetchQueue(this.props.queueId).then(() => {
+      if (this.props.pageTransitionReadyToEnter) {
+        this.props.pageTransitionReadyToEnter()
+      }
+    })
+  }
+
+  updateQueue(attributes) {
+    this.props.updateQueue(this.props.queueId, attributes)
+  }
+
+  deleteQueue() {
+    const { id: queueId, courseId } = this.props.queue
+    this.props.deleteQueue(courseId, queueId).then(() => {
+      Router.replaceRoute('index')
+    })
+  }
+
+  render() {
+    if (!this.props.queue) return null
+    return (
+      <Container>
+        <h1 className="display-4">Queue Settings</h1>
+        <h2 className="mb-5">{this.props.queue.name}</h2>
+        <GeneralPanel
+          queue={this.props.queue}
+          updateQueue={attributes => this.updateQueue(attributes)}
+        />
+        <AdmissionControlPanel
+          queue={this.props.queue}
+          updateQueue={attributes => this.updateQueue(attributes)}
+        />
+        <DangerPanel deleteQueue={() => this.deleteQueue()} />
+      </Container>
+    )
+  }
+}
+
+QueueSettings.propTypes = {
+  fetchQueue: PropTypes.func.isRequired,
+  updateQueue: PropTypes.func.isRequired,
+  deleteQueue: PropTypes.func.isRequired,
+  queueId: PropTypes.number.isRequired,
+  queue: PropTypes.shape({
+    id: PropTypes.number,
+    name: PropTypes.string,
+    courseId: PropTypes.number,
+    admissionControlEnabled: PropTypes.bool,
+    admissionControlUrl: PropTypes.string,
+  }),
+  pageTransitionReadyToEnter: PropTypes.func,
+}
+
+QueueSettings.defaultProps = {
+  queue: null,
+  pageTransitionReadyToEnter: null,
+}
+
+const mapStateToProps = (state, ownProps) => ({
+  isFetching: state.queues.isFetching,
+  hasQueue: !!state.queues.queues[ownProps.queueId],
+  queue: state.queues.queues[ownProps.queueId],
+})
+
+const mapDispatchToProps = dispatch => ({
+  fetchQueue: queueId => dispatch(fetchQueue(queueId)),
+  updateQueue: (queueId, attributes) =>
+    dispatch(updateQueue(queueId, attributes)),
+  deleteQueue: (courseId, queueId) => dispatch(deleteQueue(courseId, queueId)),
+  dispatch,
+})
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(PageWithUser(QueueSettings))
diff --git a/src/reducers/courses.js b/src/reducers/courses.js
index e9e6234..aff4140 100644
--- a/src/reducers/courses.js
+++ b/src/reducers/courses.js
@@ -28,7 +28,7 @@ function normalizeCourse(course) {
 
 function addStaffToCourse(state, courseId, userId) {
   if (
-    !(courseId in state.courses) ||
+    !state.courses[courseId] ||
     state.courses[courseId].staff.indexOf(userId) !== -1
   ) {
     return state
@@ -41,7 +41,7 @@ function addStaffToCourse(state, courseId, userId) {
 
 function removeStaffFromCourse(state, courseId, userId) {
   if (
-    !(courseId in state.courses) ||
+    !state.courses[courseId] ||
     state.courses[courseId].staff.indexOf(userId) === -1
   ) {
     return state
@@ -62,9 +62,9 @@ function removeStaffFromCourse(state, courseId, userId) {
 
 function addQueueToCourse(state, courseId, queue) {
   if (
-    state.courses[courseId].queues &&
-    (!(courseId in state.courses) ||
-      state.courses[courseId].queues.indexOf(queue.id) !== -1)
+    !state.courses[courseId] ||
+    !state.courses[courseId].queues ||
+    state.courses[courseId].queues.indexOf(queue.id) !== -1
   ) {
     return state
   }
@@ -79,8 +79,8 @@ function addQueueToCourse(state, courseId, queue) {
 
 function removeQueueFromCourse(state, courseId, queueId) {
   if (
+    !state.courses[courseId] ||
     !state.courses[courseId].queues ||
-    !(courseId in state.courses) ||
     state.courses[courseId].queues.indexOf(queueId) === -1
   ) {
     return state
diff --git a/src/reducers/questions.js b/src/reducers/questions.js
index 6a52792..8ee6545 100644
--- a/src/reducers/questions.js
+++ b/src/reducers/questions.js
@@ -10,7 +10,7 @@ import {
 
 const defaultState = {
   isFetching: false,
-  error: false,
+  error: null,
   questions: {},
 }
 
@@ -27,12 +27,19 @@ const questions = (state = defaultState, action) => {
       const { question } = action
       return {
         ...state,
+        error: null,
         questions: {
           ...state.questions,
           [question.id]: question,
         },
       }
     }
+    case CREATE_QUESTION.FAILURE: {
+      return {
+        ...state,
+        error: action.message,
+      }
+    }
     case FETCH_QUEUE.REQUEST: {
       return {
         ...state,
diff --git a/src/routes.js b/src/routes.js
index 6ead0fe..2ddb2ed 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -13,6 +13,7 @@ if (baseUrl === '') {
 routes
   .add('login', withBaseUrl('/login'))
   .add('queue', withBaseUrl('/queue/:id'))
+  .add('queueSettings', withBaseUrl('/queue/:id/settings'))
   .add('createCourse', withBaseUrl('/course/create'))
   .add('course', withBaseUrl('/course/:id'))
   .add('courseStaff', withBaseUrl('/course/:id/staff'))
diff --git a/src/server.js b/src/server.js
index a29a2ed..0f71474 100644
--- a/src/server.js
+++ b/src/server.js
@@ -23,6 +23,17 @@ const handler = routes.getRequestHandler(nextApp)
 
 /* eslint-disable func-names */
 co(function*() {
+  // Do this first to catch any problems during startup
+  process.on('unhandledRejection', (err, promise) => {
+    console.error(
+      'Unhandled rejection (promise: ',
+      promise,
+      ', reason: ',
+      err,
+      ').'
+    )
+  })
+
   // Initialize the Next.js app
   yield nextApp.prepare()
 


Test patch:
diff --git a/src/api/queues.test.js b/src/api/queues.test.js
index ba48153..925946c 100644
--- a/src/api/queues.test.js
+++ b/src/api/queues.test.js
@@ -11,6 +11,10 @@ beforeEach(async () => {
 
 afterEach(() => testutil.destroyTestDb())
 
+const expectErrorMessage = res => {
+  expect(Object.keys(res.body)).toEqual(['message'])
+}
+
 describe('Queues API', () => {
   describe('GET /api/queues', () => {
     const doGetTest = async user => {
@@ -216,7 +220,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, 'student')
       const res = await request.post('/api/courses/1/queues').send(queue)
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
     })
 
     test('fails for course staff of different course', async () => {
@@ -228,7 +232,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, '241staff')
       const res = await request.post('/api/courses/1/queues').send(queue)
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
     })
   })
 
@@ -271,7 +275,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, 'student')
       const res = await request.post('/api/courses/1/queues/1/staff/4')
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
       const request2 = await requestAsUser(app, 'admin')
       const res2 = await request2.get('/api/queues/1/staff')
       expect(res2.statusCode).toBe(200)
@@ -282,7 +286,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, 'student')
       const res = await request.post('/api/courses/1/queues/1/staff/1')
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
       const res2 = await request.get('/api/queues/1/staff')
       expect(res2.statusCode).toBe(200)
       expect(res2.body).toHaveLength(0)
@@ -292,7 +296,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, '241staff')
       const res = await request.post('/api/courses/1/queues/1/staff/3')
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
       const res2 = await request.get('/api/queues/1/staff')
       expect(res2.statusCode).toBe(200)
       expect(res2.body).toHaveLength(0)
@@ -373,7 +377,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, 'student')
       const res = await request.patch('/api/queues/1').send(attributes)
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
     })
 
     test('fails for student with well-formed request with location', async () => {
@@ -381,7 +385,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, 'student')
       const res = await request.patch('/api/queues/1').send(attributes)
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
     })
 
     test('fails for course staff of different course with well-formed request no location', async () => {
@@ -389,7 +393,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, '241staff')
       const res = await request.patch('/api/queues/1').send(attributes)
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
     })
 
     test('fails for course staff of different course with well-formed request with location', async () => {
@@ -397,7 +401,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, '241staff')
       const res = await request.patch('/api/queues/1').send(attributes)
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
     })
 
     test('fails for course staff with request to fixed location queue but no location', async () => {
@@ -545,7 +549,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, '225staff')
       const res = await request.delete('/api/queues/2')
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
       const request2 = await requestAsUser(app, 'admin')
       const res2 = await request2.get('/api/queues')
       expect(res2.statusCode).toBe(200)
@@ -558,7 +562,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, 'student')
       const res = await request.delete('/api/queues/1')
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
       const request2 = await requestAsUser(app, 'admin')
       const res2 = await request2.get('/api/queues')
       expect(res2.statusCode).toBe(200)
@@ -600,7 +604,7 @@ describe('Queues API', () => {
       const request2 = await requestAsUser(app, '241staff')
       const res2 = await request2.delete('/api/queues/1/staff/3')
       expect(res2.statusCode).toBe(403)
-      expect(res2.body).toEqual({})
+      expectErrorMessage(res2)
       const request3 = await requestAsUser(app, 'admin')
       const res3 = await request3.get('/api/queues/1/staff')
       expect(res3.statusCode).toBe(200)
@@ -615,7 +619,7 @@ describe('Queues API', () => {
       const request2 = await requestAsUser(app, 'student')
       const res2 = await request2.delete('/api/queues/1/staff/3')
       expect(res2.statusCode).toBe(403)
-      expect(res2.body).toEqual({})
+      expectErrorMessage(res2)
       const request3 = await requestAsUser(app, 'admin')
       const res3 = await request3.get('/api/queues/1/staff')
       expect(res3.statusCode).toBe(200)
@@ -648,7 +652,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, '241staff')
       const res = await request.delete('/api/queues/1')
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
       const request2 = await requestAsUser(app, 'admin')
       const res2 = await request2.get('/api/queues')
       expect(res2.statusCode).toBe(200)
@@ -659,7 +663,7 @@ describe('Queues API', () => {
       const request = await requestAsUser(app, 'student')
       const res = await request.delete('/api/queues/1')
       expect(res.statusCode).toBe(403)
-      expect(res.body).toEqual({})
+      expectErrorMessage(res)
       const request2 = await requestAsUser(app, 'admin')
       const res2 = await request2.get('/api/queues')
       expect(res2.statusCode).toBe(200)
diff --git a/src/api/util.test.js b/src/api/util.test.js
index b51e92a..627386e 100644
--- a/src/api/util.test.js
+++ b/src/api/util.test.js
@@ -11,17 +11,9 @@ afterAll(async () => {
   await testutil.destroyTestDb()
 })
 
-function makeRes() {
-  const status = jest.fn()
-  const send = jest.fn()
-  status.mockReturnValue({ send })
-  const res = {
-    locals: {},
-    status,
-  }
-
-  return { res, status, send }
-}
+const makeRes = () => ({
+  locals: {},
+})
 
 describe('Testing Utils', () => {
   describe('findPropertyInRequest', () => {
@@ -57,72 +49,60 @@ describe('Testing Utils', () => {
 
     test('succeeds for a model that exists', async () => {
       const req = makeReq('1')
-      const { res, status, send } = makeRes()
+      const res = makeRes()
       const next = jest.fn()
       await util.requireCourse(req, res, next)
-      expect(next).toBeCalled()
+      expect(next).toBeCalledWith()
       expect(res.locals).toHaveProperty('course')
       expect(res.locals.course.id).toBe(1)
-      expect(status).not.toBeCalled()
-      expect(send).not.toBeCalled()
     })
 
     test('fails with 422 if model id not present in request', async () => {
       const req = makeReq(undefined)
-      const { res, status, send } = makeRes()
+      const res = makeRes()
       const next = jest.fn()
       await util.requireCourse(req, res, next)
-      expect(next).not.toBeCalled()
-      expect(status).toBeCalledWith(422)
-      expect(send).toBeCalled()
+      testutil.expectNextCalledWithApiError(next, 422)
     })
 
     test('fails with 404 if model does not exist', async () => {
       const req = makeReq('50')
-      const { res, status, send } = makeRes()
+      const res = makeRes()
       const next = jest.fn()
       await util.requireCourse(req, res, next)
-      expect(next).not.toBeCalled()
-      expect(status).toBeCalledWith(404)
-      expect(send).toBeCalled()
+      testutil.expectNextCalledWithApiError(next, 404)
     })
 
     test('fails with 400 if model id is not an integer', async () => {
       const req = makeReq('hello')
-      const { res, status, send } = makeRes()
+      const res = makeRes()
       const next = jest.fn()
       await util.requireCourse(req, res, next)
-      expect(next).not.toBeCalled()
-      expect(status).toBeCalledWith(400)
-      expect(send).toBeCalled()
+      testutil.expectNextCalledWithApiError(next, 400)
     })
   })
 
   describe('requireModelForModel', () => {
     test('succeeds for a model that exists', async () => {
-      const { res, status, send } = makeRes()
+      const res = makeRes()
       res.locals.question = {
         queueId: 1,
       }
       const next = jest.fn()
       await util.requireQueueForQuestion(null, res, next)
-      expect(next).toBeCalled()
-      expect(status).not.toBeCalled()
-      expect(send).not.toBeCalled()
+      expect(next).toHaveBeenCalledWith()
       expect(res.locals).toHaveProperty('queue')
       expect(res.locals.queue.id).toBe(1)
     })
 
     test('fails with 404 if model does not exist', async () => {
-      const { res, status, send } = makeRes()
+      const res = makeRes()
       res.locals.question = {
         queueId: 12345,
       }
       const next = jest.fn()
       await util.requireQueueForQuestion(null, res, next)
-      expect(next).not.toBeCalled()
-      expect(status).toBeCalledWith(404)
-      expect(send).toBeCalled()
+      testutil.expectNextCalledWithApiError(next, 404)
     })
   })
 
diff --git a/src/components/NewQuestion.test.js b/src/components/NewQuestion.test.js
index 0b55be6..e302bad 100644
--- a/src/components/NewQuestion.test.js
+++ b/src/components/NewQuestion.test.js
@@ -1,7 +1,7 @@
 /* eslint-env jest */
 import React from 'react'
 import { shallow } from 'enzyme'
-import { Input, Button } from 'reactstrap'
+import { Input, Form } from 'reactstrap'
 import NewQuestion from './NewQuestion'
 
 const makeProps = (queueId, userName = null) => ({
@@ -41,8 +41,8 @@ describe('<NewQuestion />', () => {
     doInputChange(wrapper, 'name', 'My Name')
     doInputChange(wrapper, 'location', 'Right here')
     doInputChange(wrapper, 'topic', 'MP1')
-    const submitButton = wrapper.find(Button)
-    submitButton.simulate('click')
+    const form = wrapper.find(Form)
+    form.simulate('submit', { preventDefault() {} })
     expect(props.createQuestion).toBeCalledWith(50, {
       name: 'My Name',
       location: 'Right here',
@@ -55,8 +55,8 @@ describe('<NewQuestion />', () => {
     const wrapper = shallow(<NewQuestion {...props} />)
     doInputChange(wrapper, 'location', 'Right here')
     doInputChange(wrapper, 'topic', 'MP1')
-    const submitButton = wrapper.find(Button)
-    submitButton.simulate('click')
+    const form = wrapper.find(Form)
+    form.simulate('submit', { preventDefault() {} })
     expect(props.createQuestion).not.toBeCalled()
   })
 
@@ -65,8 +65,8 @@ describe('<NewQuestion />', () => {
     const wrapper = shallow(<NewQuestion {...props} />)
     doInputChange(wrapper, 'name', 'My Name')
     doInputChange(wrapper, 'topic', 'MP1')
-    const submitButton = wrapper.find(Button)
-    submitButton.simulate('click')
+    const form = wrapper.find(Form)
+    form.simulate('submit', { preventDefault() {} })
     expect(props.createQuestion).not.toBeCalled()
   })
 
@@ -75,8 +75,8 @@ describe('<NewQuestion />', () => {
     const wrapper = shallow(<NewQuestion {...props} />)
     doInputChange(wrapper, 'name', 'My Name')
     doInputChange(wrapper, 'location', 'Right here')
-    const submitButton = wrapper.find(Button)
-    submitButton.simulate('click')
+    const form = wrapper.find(Form)
+    form.simulate('submit', { preventDefault() {} })
     expect(props.createQuestion).not.toBeCalled()
   })
 
diff --git a/src/middleware/requireAdmin.test.js b/src/middleware/requireAdmin.test.js
index 573a5df..8583978 100644
--- a/src/middleware/requireAdmin.test.js
+++ b/src/middleware/requireAdmin.test.js
@@ -1,36 +1,27 @@
 /* eslint-env jest */
+const testutil = require('../../test/util')
 const requireAdmin = require('./requireAdmin')
 
-function makeRes(isAdmin) {
-  const status = jest.fn()
-  const send = jest.fn()
-  status.mockReturnValue({ send })
-  const res = {
-    locals: {
-      userAuthz: {
-        isAdmin,
-      },
+const makeRes = isAdmin => ({
+  locals: {
+    userAuthz: {
+      isAdmin,
     },
-    status,
-  }
-
-  return { res, status, send }
-}
+  },
+})
 
 describe('requireAdmin middleware', () => {
   test('responds with 403 for non-admin user', () => {
-    const { res, status, send } = makeRes(false)
+    const res = makeRes(false)
     const next = jest.fn()
     requireAdmin(null, res, next)
-    expect(status).toBeCalledWith(403)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 403)
   })
 
   test('proceeds for admin user', () => {
-    const { res } = makeRes(true)
+    const res = makeRes(true)
     const next = jest.fn()
     requireAdmin(null, res, next)
-    expect(next).toBeCalled()
+    expect(next).toBeCalledWith()
   })
 })
diff --git a/src/middleware/requireCourseStaff.test.js b/src/middleware/requireCourseStaff.test.js
index 8aa3d58..691fd8b 100644
--- a/src/middleware/requireCourseStaff.test.js
+++ b/src/middleware/requireCourseStaff.test.js
@@ -1,4 +1,5 @@
 /* eslint-env jest */
+const testutil = require('../../test/util')
 const requireCourseStaff = require('./requireCourseStaff')
 
 const makeReq = courseId => ({
@@ -7,48 +8,36 @@ const makeReq = courseId => ({
   },
 })
 
-function makeRes(isCourseStaff) {
-  const status = jest.fn()
-  const send = jest.fn()
-  status.mockReturnValue({ send })
-  const res = {
-    locals: {
-      userAuthz: {
-        staffedCourseIds: isCourseStaff ? [1] : [],
-      },
+const makeRes = isCourseStaff => ({
+  locals: {
+    userAuthz: {
+      staffedCourseIds: isCourseStaff ? [1] : [],
     },
-    status,
-  }
-
-  return { res, status, send }
-}
+  },
+})
 
 describe('requireCourseStaff middleware', () => {
   test('responds with 403 for non-course staff user', () => {
     const req = makeReq('1')
-    const { res, status, send } = makeRes(false)
+    const res = makeRes(false)
     const next = jest.fn()
     requireCourseStaff(req, res, next)
-    expect(status).toBeCalledWith(403)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 403)
   })
 
   test('proceeds for coures staff user', () => {
     const req = makeReq('1')
-    const { res } = makeRes(true)
+    const res = makeRes(true)
     const next = jest.fn()
     requireCourseStaff(req, res, next)
     expect(next).toBeCalled()
   })
 
-  test('returns 500 status if queueId is invalid', async () => {
+  test('returns 400 status if queueId is invalid', async () => {
     const req = makeReq('hello')
-    const { res, status, send } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaff(req, res, next)
-    expect(status).toBeCalledWith(500)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 400)
   })
 })
diff --git a/src/middleware/requireCourseStaffForQueue.test.js b/src/middleware/requireCourseStaffForQueue.test.js
index 62cc8e7..0443f44 100644
--- a/src/middleware/requireCourseStaffForQueue.test.js
+++ b/src/middleware/requireCourseStaffForQueue.test.js
@@ -17,79 +17,61 @@ const makeReq = queueId => ({
   },
 })
 
-function makeRes(staffedCourseIds) {
-  const status = jest.fn()
-  const send = jest.fn()
-  status.mockReturnValue({ send })
-  const res = {
-    locals: {
-      userAuthz: {
-        isAdmin: false,
-        staffedCourseIds,
-      },
+const makeRes = staffedCourseIds => ({
+  locals: {
+    userAuthz: {
+      isAdmin: false,
+      staffedCourseIds,
     },
-    status,
-  }
-
-  return { res, status, send }
-}
+  },
+})
 
 describe('requireCourseStaffForQueue middleware', () => {
   test('responds with 403 for non-course staff user', async () => {
     const req = makeReq('1')
-    const { res, status, send } = makeRes([])
+    const res = makeRes([])
     const next = jest.fn()
     await requireCourseStaffForQueue(req, res, next)
-    expect(status).toBeCalledWith(403)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 403)
   })
 
   test('responds with 403 for coures staff of a different course', async () => {
     const req = makeReq('1')
-    const { res, status, send } = makeRes([2])
+    const res = makeRes([2])
     const next = jest.fn()
     await requireCourseStaffForQueue(req, res, next)
-    expect(status).toBeCalledWith(403)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 403)
   })
 
   test('proceeds for coures staff user', async () => {
     const req = makeReq('1')
-    const { res } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueue(req, res, next)
-    expect(next).toBeCalled()
+    expect(next).toBeCalledWith()
   })
 
   test('gracefully handles reference to nonexistant queue', async () => {
     const req = makeReq('69')
-    const { res, status, send } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueue(req, res, next)
-    expect(status).toBeCalledWith(404)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 404)
   })
 
   test('returns 500 status if queueId is missing', async () => {
     const req = makeReq(undefined)
-    const { res, status, send } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueue(req, res, next)
-    expect(status).toBeCalledWith(500)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 400)
   })
 
   test('returns 500 status if queueId is invalid', async () => {
     const req = makeReq('hello')
-    const { res, status, send } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueue(req, res, next)
-    expect(status).toBeCalledWith(500)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 400)
   })
 })
diff --git a/src/middleware/requireCourseStaffForQueueForQuestion.test.js b/src/middleware/requireCourseStaffForQueueForQuestion.test.js
index b45002f..477792d 100644
--- a/src/middleware/requireCourseStaffForQueueForQuestion.test.js
+++ b/src/middleware/requireCourseStaffForQueueForQuestion.test.js
@@ -15,79 +15,61 @@ const makeReq = questionId => ({
   },
 })
 
-function makeRes(staffedCourseIds) {
-  const status = jest.fn()
-  const send = jest.fn()
-  status.mockReturnValue({ send })
-  const res = {
-    locals: {
-      userAuthz: {
-        isAdmin: false,
-        staffedCourseIds,
-      },
+const makeRes = staffedCourseIds => ({
+  locals: {
+    userAuthz: {
+      isAdmin: false,
+      staffedCourseIds,
     },
-    status,
-  }
-
-  return { res, status, send }
-}
+  },
+})
 
 describe('requireCourseStaffForQueueForQuestion middleware', () => {
   test('responds with 403 for non-course staff user', async () => {
     const req = makeReq('1')
-    const { res, status, send } = makeRes([])
+    const res = makeRes([])
     const next = jest.fn()
     await requireCourseStaffForQueueForQuestion(req, res, next)
-    expect(status).toBeCalledWith(403)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 403)
   })
 
   test('responds with 403 for coures staff of a different course', async () => {
     const req = makeReq('2')
-    const { res, status, send } = makeRes([2])
+    const res = makeRes([2])
     const next = jest.fn()
     await requireCourseStaffForQueueForQuestion(req, res, next)
-    expect(status).toBeCalledWith(403)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 403)
   })
 
   test('proceeds for coures staff user', async () => {
     const req = makeReq('1')
-    const { res } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueueForQuestion(req, res, next)
-    expect(next).toBeCalled()
+    expect(next).toBeCalledWith()
   })
 
   test('gracefully handles reference to nonexistant question', async () => {
     const req = makeReq('69')
-    const { res, status, send } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueueForQuestion(req, res, next)
-    expect(status).toBeCalledWith(404)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 404)
   })
 
   test('returns 500 status if questionId is missing', async () => {
     const req = makeReq(undefined)
-    const { res, status, send } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueueForQuestion(req, res, next)
-    expect(status).toBeCalledWith(500)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 400)
   })
 
   test('returns 500 status if questionId is invalid', async () => {
     const req = makeReq('hello')
-    const { res, status, send } = makeRes([1])
+    const res = makeRes([1])
     const next = jest.fn()
     await requireCourseStaffForQueueForQuestion(req, res, next)
-    expect(status).toBeCalledWith(500)
-    expect(send).toBeCalled()
-    expect(next).not.toBeCalled()
+    testutil.expectNextCalledWithApiError(next, 400)
   })
 })
diff --git a/test/util.js b/test/util.js
index 0abf926..3dd2bc8 100644
--- a/test/util.js
+++ b/test/util.js
@@ -1,5 +1,7 @@
+/* eslint-env jest */
 const session = require('supertest-session')
 const models = require('../src/models')
+const { ApiError } = require('../src/api/util')
 
 module.exports.setupTestDb = async () => {
   await models.sequelize.sync()
@@ -110,3 +112,10 @@ module.exports.requestAsUser = async (app, user) => {
   await testSession.post('/login/dev').send({ netid: user })
   return testSession
 }
+
+module.exports.expectNextCalledWithApiError = (next, statusCode) => {
+  expect(next).toHaveBeenCalledTimes(1)
+  const arg = next.mock.calls[0][0]
+  expect(arg).toBeInstanceOf(ApiError)
+  expect(arg.httpStatusCode).toEqual(statusCode)
+}