diff --git a/projects/tentacles/src/python/tentacles/__main__.py b/projects/tentacles/src/python/tentacles/__main__.py
index 091b3fc..00a220b 100644
--- a/projects/tentacles/src/python/tentacles/__main__.py
+++ b/projects/tentacles/src/python/tentacles/__main__.py
@@ -11,10 +11,10 @@ import cherrypy
 import click
 from flask import Flask, request
 from tentacles.blueprints import (
+    admin_ui,
     api,
     file_ui,
     job_ui,
-    printer_ui,
     user_ui,
 )
 from tentacles.db import Db
@@ -39,8 +39,7 @@ def custom_ctx(app, wsgi_app):
         store = db_factory(app)
         token = _ctx.set(Ctx(store))
         try:
-            with store.savepoint():
-                return wsgi_app(environ, start_response)
+            return wsgi_app(environ, start_response)
         finally:
             store.close()
             _ctx.reset(token)
@@ -88,11 +87,11 @@ def make_app():
     app.before_request(user_session)
 
     # Blueprints
-    app.register_blueprint(user_ui.BLUEPRINT)
-    app.register_blueprint(printer_ui.BLUEPRINT)
-    app.register_blueprint(job_ui.BLUEPRINT)
-    app.register_blueprint(file_ui.BLUEPRINT)
+    app.register_blueprint(admin_ui.BLUEPRINT)
     app.register_blueprint(api.BLUEPRINT)
+    app.register_blueprint(file_ui.BLUEPRINT)
+    app.register_blueprint(job_ui.BLUEPRINT)
+    app.register_blueprint(user_ui.BLUEPRINT)
 
     # Shove our middleware in there
     app.wsgi_app = custom_ctx(app, app.wsgi_app)
@@ -106,6 +105,8 @@ def make_app():
 @click.option("--trace/--no-trace", "trace", default=False)
 @click.option("--config", type=Path)
 def serve(hostname: str, port: int, config: Path, trace: bool):
+    logging.addLevelName(logging.DEBUG - 5, "TRACE")
+    logging.TRACE = logging.DEBUG - 5
     logging.basicConfig(
         format="%(asctime)s %(threadName)s - %(name)s - %(levelname)s -  %(message)s",
         level=logging.INFO,
@@ -113,7 +114,7 @@ def serve(hostname: str, port: int, config: Path, trace: bool):
 
     logging.getLogger("tentacles").setLevel(logging.DEBUG)
     if trace:
-        logging.getLogger("tentacles.db").setLevel(logging.DEBUG - 1)
+        logging.getLogger("tentacles.db").setLevel(logging.TRACE)
 
     app = make_app()
 
diff --git a/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py b/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py
new file mode 100644
index 0000000..2a2727f
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/blueprints/admin_ui.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+import logging
+
+from .api import requires_admin
+
+from flask import (
+    Blueprint,
+    flash,
+    redirect,
+    render_template,
+    request,
+)
+from tentacles.globals import ctx
+
+
+log = logging.getLogger(__name__)
+BLUEPRINT = Blueprint("admin", __name__)
+
+
+@BLUEPRINT.route("/admin", methods=["GET"])
+@requires_admin
+def list_files():
+    if request.method == "POST":
+        flash("Not supported yet", category="warning")
+
+    return render_template("admin.html.j2")
+
+
+@BLUEPRINT.route("/admin/users", methods=["POST"])
+@requires_admin
+def manipulate_users():
+    uid = int(request.form.get("user_id", "-1"))
+
+    match request.form.get("action"):
+        case "approve":
+            row = ctx.db.approve_user(uid=uid)
+            ctx.db.enable_user(uid=uid)
+            ctx.db.create_email(
+                uid=uid,
+                subject="Tentacles account approved!",
+                body=render_template(
+                    "approval_email.html.j2",
+                    username=row.name,
+                    base_url=request.root_url,
+                ),
+            )
+            if row:
+                flash(f"Approved {row.name}", category="success")
+
+        case "enable":
+            row = ctx.db.enable_user(uid=uid)
+            if row:
+                flash(f"Enabled {row.name}", category="success")
+
+        case "disable":
+            row = ctx.db.disable_user(uid=uid)
+            if row:
+                flash(f"Disabled {row.name}", category="success")
+
+        case "passwdchng":
+            ctx.db.set_user_status(uid=uid, status="passwdchng")
+
+        case _:
+            print(request.form)
+            flash("Not supported yet", category="warning")
+            return render_template("admin.html.j2"), 400
+
+    return redirect("/admin")
+
+
+@BLUEPRINT.route("/admin/users", methods=["GET"])
+def get_users():
+    return redirect("/admin")
+
+
+@BLUEPRINT.route("/admin/printers")
+@requires_admin
+def printers():
+    return render_template("printers.html.j2")
+
+
+@BLUEPRINT.route("/admin/printers/add", methods=["GET"])
+@requires_admin
+def add_printer():
+    return render_template("add_printer.html.j2")
+
+
+@BLUEPRINT.route("/admin/printers", methods=["POST"])
+@requires_admin
+def handle_add_printer():
+    try:
+        assert request.form["name"]
+        assert request.form["url"]
+        assert request.form["api_key"]
+        ctx.db.try_create_printer(
+            name=request.form["name"],
+            url=request.form["url"],
+            api_key=request.form["api_key"],
+            sid=0,  # Disconnected
+        )
+        flash("Printer created")
+        return redirect("/admin/printers")
+
+    except Exception as e:
+        log.exception("Failed to create printer")
+        flash(f"Unable to create printer", category="error")
+
+    return render_template("printers.html.j2")
+
+
+@BLUEPRINT.route("/admin/files", methods=["POST"])
+@requires_admin
+def manipulate_files():
+    fid = int(request.form.get("file_id", "-1"))
+
+    match request.form.get("action"):
+        case _:
+            print(request.form)
+            flash("Not supported yet", category="warning")
+            return render_template("admin.html.j2"), 400
+
+    return redirect("/admin")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/file_ui.py b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py
index 3fa087a..660e9e8 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/file_ui.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/file_ui.py
@@ -12,6 +12,7 @@ from flask import (
     redirect,
     render_template,
     request,
+    send_file,
 )
 from tentacles.globals import ctx
 
@@ -41,6 +42,15 @@ def manipulate_files():
                 flash(resp.get("error"), category="error")
                 return render_template("files.html.j2"), code
 
+        case "download":
+            file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id")))
+            if file:
+                return send_file(
+                    file.path, as_attachment=True, download_name=file.filename
+                )
+            else:
+                flash("File not found", category="error")
+
         case "delete":
             file = ctx.db.fetch_file(uid=ctx.uid, fid=int(request.form.get("file_id")))
             if any(
diff --git a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py b/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
deleted file mode 100644
index f005f67..0000000
--- a/projects/tentacles/src/python/tentacles/blueprints/printer_ui.py
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/usr/bin/env python3
-
-import logging
-
-from .util import is_logged_in, requires_admin
-
-from flask import (
-    Blueprint,
-    flash,
-    redirect,
-    render_template,
-    request,
-)
-from tentacles.globals import ctx
-
-
-log = logging.getLogger(__name__)
-BLUEPRINT = Blueprint("printer", __name__)
-
-
-@BLUEPRINT.route("/printers")
-@requires_admin
-def printers():
-    return render_template("printers.html.j2")
-
-
-@BLUEPRINT.route("/printers/add", methods=["get", "post"])
-@requires_admin
-def add_printer():
-    if not is_logged_in():
-        return redirect("/")
-
-    elif request.method == "POST":
-        try:
-            assert request.form["name"]
-            assert request.form["url"]
-            assert request.form["api_key"]
-            ctx.db.try_create_printer(
-                name=request.form["name"],
-                url=request.form["url"],
-                api_key=request.form["api_key"],
-                sid=0,  # Disconnected
-            )
-            flash("Printer created")
-            return redirect("/printers")
-
-        except Exception as e:
-            log.exception("Failed to create printer")
-            flash(f"Unable to create printer", category="error")
-
-    return render_template("add_printer.html.j2")
-
-
-@BLUEPRINT.route("/printers/delete")
-@requires_admin
-def delete_printer():
-    return render_template("delete_printer.html.j2")
diff --git a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
index 2a7ffb3..f2fcd6e 100644
--- a/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
+++ b/projects/tentacles/src/python/tentacles/blueprints/user_ui.py
@@ -120,6 +120,18 @@ def post_register():
     return render_template("register.html.j2")
 
 
+@BLUEPRINT.route("/user/verify", methods=["GET", "POST"])
+def verify():
+    token = request.args.get("token", "")
+    row = ctx.db.try_verify_user(token=token)
+    if row:
+        flash(
+            f"Thanks for verifying your email {row.name}! You'll receive another email when your account is ready for use.",
+            category="success",
+        )
+    return redirect("/user/login")
+
+
 @BLUEPRINT.route("/user/logout")
 def logout():
     # Invalidate the user's authorization
diff --git a/projects/tentacles/src/python/tentacles/db.py b/projects/tentacles/src/python/tentacles/db.py
index dbbdbc7..d19ad1e 100644
--- a/projects/tentacles/src/python/tentacles/db.py
+++ b/projects/tentacles/src/python/tentacles/db.py
@@ -28,7 +28,7 @@ def qfn(name, f):
         # Force lazy values for convenience
         if isinstance(res, GeneratorType):
             res = list(res)
-        log.log(logging.DEBUG - 1, "%s (%r) -> %r", name, kwargs, res)
+        log.log(logging.TRACE, "%s (%r) -> %r", name, kwargs, res)
         return res
 
     _helper.__name__ = f.__name__
@@ -158,15 +158,10 @@ class Db(Queries):
         digest.update(password.encode("utf-8"))
         res = super().try_login(username=username, hash=digest.hexdigest())
         if not res:
-            return
+            res = self.fetch_user_status(sid=res.status_id)
+            raise LoginError(res.name)
 
-        uid, status = res
-        if status > 0:
-            return self.create_key(uid=uid, name="web session", ttl=ttl)
-
-        else:
-            _, status = self.fetch_user_status(status)
-            raise LoginError(status)
+        return self.create_key(uid=res.id, name="web session", ttl=ttl)
 
     def try_create_user(
         self, *, username: str, email: str, password: str, gid: int = 1, sid: int = -3
diff --git a/projects/tentacles/src/python/tentacles/schema.sql b/projects/tentacles/src/python/tentacles/schema.sql
index 373aaa3..7c8cfc0 100644
--- a/projects/tentacles/src/python/tentacles/schema.sql
+++ b/projects/tentacles/src/python/tentacles/schema.sql
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS user_statuses (
  , UNIQUE(name)
 );
 
+INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-4, 'passwdchng');
 INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-3, 'unverified');
 INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-2, 'unapproved');
 INSERT OR IGNORE INTO user_statuses (id, name) VALUES (-1, 'disabled');
@@ -30,6 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
  , email TEXT
  , hash TEXT
  , status_id INTEGER
+ , created_at TEXT DEFAULT (datetime('now'))
  , verification_token TEXT DEFAULT (lower(hex(randomblob(32))))
  , verified_at TEXT
  , approved_at TEXT
@@ -89,16 +91,29 @@ CREATE TABLE IF NOT EXISTS files (
 ----------------------------------------------------------------------------------------------------
 -- A job is a request for a copy of a file to be run off
 -- For simplicity, jobs also serve as scheduling records
+
+CREATE TABLE IF NOT EXISTS job_statuses (
+   id INTEGER PRIMARY KEY AUTOINCREMENT
+ , name TEXT
+ , UNIQUE(name)
+);
+
+INSERT OR IGNORE INTO job_statuses (id, name) VALUES (2, 'success');
+INSERT OR IGNORE INTO job_statuses (id, name) VALUES (1, 'running');
+INSERT OR IGNORE INTO job_statuses (id, name) VALUES (0, 'queued');
+INSERT OR IGNORE INTO job_statuses (id, name) VALUES (-1, 'cancelled');
+INSERT OR IGNORE INTO job_statuses (id, name) VALUES (-2, 'failed');
+
 CREATE TABLE IF NOT EXISTS jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT
  , user_id INTEGER NOT NULL
  , file_id INTEGER NOT NULL
+ , printer_id INTEGER
  , started_at TEXT
  , cancelled_at TEXT
  , finished_at TEXT
- , state TEXT
+ , status_id INTEGER DEFAULT (0)
  , message TEXT
- , printer_id INTEGER
  , FOREIGN KEY(user_id) REFERENCES users(id)
  , FOREIGN KEY(file_id) REFERENCES files(id)
  , FOREIGN KEY(printer_id) REFERENCES printer(id)
@@ -141,8 +156,7 @@ WHERE
 
 -- name: list-users
 SELECT
-   id
- , email
+   *
 FROM users
 ;
 
@@ -155,21 +169,60 @@ WHERE
    AND verified_at IS NULL
 ;
 
--- name: verify-user!
+-- name: try-verify-user^
 UPDATE users
 SET
    verified_at = datetime('now')
+ , verification_token = lower(hex(randomblob(32)))
 WHERE
-   id = :uid
+   verification_token = :token
+RETURNING
+   id
+ , name
 ;
 
-
--- name: set-user-status!
+-- name: enable-user^
 UPDATE users
 SET
-   status_id = :sid
+   enabled_at = datetime('now')
 WHERE
    id = :uid
+RETURNING
+   id
+ , name
+;
+
+-- name: disable-user^
+UPDATE users
+SET
+   enabled_at = NULL
+WHERE
+   id = :uid
+RETURNING
+   id
+ , name
+;
+
+-- name: approve-user^
+UPDATE users
+SET
+   approved_at = datetime('now')
+WHERE
+   id = :uid
+RETURNING
+   id
+ , name
+;
+
+-- name: set-user-status^
+UPDATE users
+SET
+   status_id = (SELECT id FROM user_statuses WHERE id = :status OR name = :status)
+WHERE
+   id = :uid
+RETURNING
+   id
+ , name
 ;
 
 ----------------------------------------------------------------------------------------------------
@@ -207,8 +260,12 @@ SELECT
  , status_id
 FROM users
 WHERE
-   (name = :username AND hash = :hash)
-   OR (email = :username AND hash = :hash)
+   ((name = :username AND hash = :hash)
+   OR (email = :username AND hash = :hash))
+   AND ((verified_at IS NOT NULL
+         AND approved_at IS NOT NULL
+         AND enabled_at IS NOT NULL)
+        OR group_id = 0)
 LIMIT 1
 ;
 
@@ -222,6 +279,17 @@ WHERE
    user_id = :uid
 ;
 
+-- name: list-nonweb-keys
+SELECT
+   id
+ , name
+ , expiration
+FROM user_keys
+WHERE
+   user_id = :uid
+   AND name NOT LIKE '%web session%'
+;
+
 -- name: fetch-key^
 SELECT
    *
@@ -232,12 +300,15 @@ WHERE
 
 -- name: try-key^
 SELECT
-   id
+   k.id
  , user_id
-FROM user_keys
+FROM user_keys k
+INNER JOIN users u
+   ON k.user_id = u.id
 WHERE
    (expiration IS NULL OR unixepoch(expiration) > unixepoch('now'))
-   AND id = :kid
+   AND k.id = :kid
+   AND u.enabled_at IS NOT NULL -- and the user is not disabled!
 ;
 
 -- name: refresh-key
@@ -336,7 +407,9 @@ RETURNING
 -- name: list-files
 SELECT
    *
-FROM files
+ , (SELECT COUNT(*) FROM jobs WHERE file_id = f.id AND status_id > 1) AS `print_successes`
+ , (SELECT COUNT(*) FROM jobs WHERE file_id = f.id AND status_id < 0) AS `print_failures`
+FROM files f
 WHERE
    user_id = :uid
 ;
@@ -385,6 +458,7 @@ WHERE
 -- name: list-jobs
 SELECT
    *
+ , (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
 FROM jobs
 WHERE
    (:uid IS NULL OR user_id = :uid)
@@ -393,6 +467,7 @@ WHERE
 -- name: list-jobs-by-file
 SELECT
    *
+ , (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
 FROM jobs
 WHERE
    file_id = :fid
@@ -422,7 +497,8 @@ LIMIT 1
 -- name: list-job-history
 SELECT
    *
-FROM jobs
+ , (SELECT name FROM job_statuses WHERE id = j.status_id) AS `status`
+FROM jobs j
 WHERE
    finished_at IS NOT NULL
    AND (:uid IS NULL OR user_id = :uid)
@@ -496,7 +572,7 @@ WHERE
 UPDATE jobs
 SET
    finished_at = datetime('now')
- , state = :state
+ , status_id = (SELECT id FROM job_statuses WHERE name = :state)
  , message = :message
 WHERE
    id = :jid
diff --git a/projects/tentacles/src/python/tentacles/templates/admin.html.j2 b/projects/tentacles/src/python/tentacles/templates/admin.html.j2
new file mode 100644
index 0000000..7e893c0
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/admin.html.j2
@@ -0,0 +1,12 @@
+{% extends "base.html.j2" %}
+{% block content %}
+<div class="row twelve columns mb-1">
+  {% include "users_list.html.j2" %}
+</div>
+<div class="row twelve columns mb-1">
+  {% include "files_admin_list.html.j2" %}
+</div>
+<div class="row twelve columns mb-1">
+  {% include "printers_list.html.j2" %}
+</div>
+{% endblock %}
diff --git a/projects/tentacles/src/python/tentacles/templates/approval_email.html.j2 b/projects/tentacles/src/python/tentacles/templates/approval_email.html.j2
new file mode 100644
index 0000000..b3f7979
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/approval_email.html.j2
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<html lang="en">
+  <head>
+    <link rel="stylesheet" href="{{ base_url }}/static/css/style.css" />
+  </head>
+  <body>
+    <nav class="container navbar">
+      <span class="logo">
+        <a class="row" href="/">
+          <img src="{{ base_url }}/static/tentacles.svg" alt="Tentacles">
+          <span class="name color-yellow">Tentacles</span>
+        </a>
+      </span>
+    </nav>
+
+    <div class="container content">
+      <div class="row">
+        <p>
+          Hello {{ username }},
+        </p>
+        <p>
+          Your account on Tentacles has been approved! You may now <a href="{{ base_url }}login">log in</a> and start printing!
+        </p>
+      </div>
+    </div>
+  </body>
+  <footer>
+
+  </footer>
+</html>
diff --git a/projects/tentacles/src/python/tentacles/templates/base.html.j2 b/projects/tentacles/src/python/tentacles/templates/base.html.j2
index f5ddd14..4b293a7 100644
--- a/projects/tentacles/src/python/tentacles/templates/base.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/base.html.j2
@@ -33,7 +33,7 @@
       <li><a class="twelve columns button slide" href="/jobs">Jobs</a></li>
       <li><a class="twelve columns button slide" href="/files">Files</a></li>
       {% if ctx.is_admin %}
-      <li><a class="twelve columns button slide" href="/printers">Printers</a></li>
+      <li><a class="twelve columns button slide" href="/admin">Administration</a></li>
       {% endif %}
       <li><a class="twelve columns button slide" href="/user">Settings</a></li>
       <li><a class="twelve columns button slide" href="/user/logout">Log out</a></li>
diff --git a/projects/tentacles/src/python/tentacles/templates/files_admin_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/files_admin_list.html.j2
new file mode 100644
index 0000000..72dc8fb
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/files_admin_list.html.j2
@@ -0,0 +1,21 @@
+{% import "macros.html.j2" as macros %}
+<h2>Files</h2>
+{% with files = ctx.db.list_files(uid=ctx.uid) %}
+{% if files %}
+{% for file in files %}
+<div class="file row u-flex">
+  <div class="details six columns">
+    <span class="file-name">{{ file.filename }}</span>
+    <span class="file-owner">{{ ctx.db.fetch_user(uid=file.user_id).name }}</span>
+    <span class="file-sucesses">{{ file.print_successes }}</span> successes
+    <span class="file-failures">{{ file.print_failures }}</span> errors
+  </div>
+  <div class="controls u-flex u-ml-auto">
+    {{ macros.delete_file(file.id, endpoint="/admin/files") }}
+  </div>
+</div>
+{% endfor %}
+{% else %}
+  You don't have any files. Upload something!
+{% endif %}
+{% endwith %}
diff --git a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
index daed823..6861143 100644
--- a/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/files_list.html.j2
@@ -6,9 +6,12 @@
 <div class="file row u-flex">
   <div class="details six columns">
     <span class="file-name">{{ file.filename }}</span>
+    <span class="file-sucesses">{{ file.print_successes }}</span> successes
+    <span class="file-failures">{{ file.print_failures }}</span> errors
   </div>
   <div class="controls u-flex u-ml-auto">
     {{ macros.start_job(file.id) }}
+    {{ macros.download_file(file.id) }}
     {{ macros.delete_file(file.id) }}
   </div>
 </div>
diff --git a/projects/tentacles/src/python/tentacles/templates/macros.html.j2 b/projects/tentacles/src/python/tentacles/templates/macros.html.j2
index 0b6794a..8d238f8 100644
--- a/projects/tentacles/src/python/tentacles/templates/macros.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/macros.html.j2
@@ -36,15 +36,58 @@
 {{ 'queued' if (not job.finished_at and not job.printer_id and not job.cancelled_at) else
    'running' if (not job.finished_at and job.printer_id and not job.cancelled_at) else
    'cancelling' if (not job.finished_at and job.cancelled_at) else
-  job.state }}
+  job.status }}
 {% endmacro %}
 
 {# #################################################################################################### #}
 {# File CRUD #}
-{% macro delete_file(id) %}
-<form class="inline" method="post" action="/files">
+{% macro delete_file(id, endpoint="/files") %}
+<form class="inline" method="post" action="{{ endpoint }}">
   <input type="hidden" name="action" value="delete" />
   <input type="hidden" name="file_id" value="{{ id }}" />
   <input id="submit" type="submit" value="Delete"/>
 </form>
 {% endmacro %}
+
+{% macro download_file(id, endpoint="/files") %}
+<form class="inline" method="post" action="{{ endpoint }}">
+  <input type="hidden" name="action" value="download" />
+  <input type="hidden" name="file_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Download"/>
+</form>
+{% endmacro %}
+
+{# #################################################################################################### #}
+{# User CRUD #}
+
+{% macro approve_user(id) %}
+<form class="inline" method="post" action="/admin/users">
+  <input type="hidden" name="action" value="approve" />
+  <input type="hidden" name="user_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Approve"/>
+</form>
+{% endmacro %}
+
+{% macro enable_user(id) %}
+<form class="inline" method="post" action="/admin/users">
+  <input type="hidden" name="action" value="enable" />
+  <input type="hidden" name="user_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Enable"/>
+</form>
+{% endmacro %}
+
+{% macro lock_user(id) %}
+<form class="inline" method="post" action="/admin/users">
+  <input type="hidden" name="action" value="lock" />
+  <input type="hidden" name="user_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Lock"/>
+</form>
+{% endmacro %}
+
+{% macro passwdchng_user(id) %}
+<form class="inline" method="post" action="/admin/users">
+  <input type="hidden" name="action" value="passwdchng" />
+  <input type="hidden" name="user_id" value="{{ id }}" />
+  <input id="submit" type="submit" value="Password change"/>
+</form>
+{% endmacro %}
diff --git a/projects/tentacles/src/python/tentacles/templates/user.html.j2 b/projects/tentacles/src/python/tentacles/templates/user.html.j2
index 709dbce..8f7aab3 100644
--- a/projects/tentacles/src/python/tentacles/templates/user.html.j2
+++ b/projects/tentacles/src/python/tentacles/templates/user.html.j2
@@ -3,14 +3,14 @@
 <h1>User settings</h1>
 <div class="row twelve columns keys">
   <h2>API keys</h2>
-  {% with keys = ctx.db.list_keys(ctx.uid) %}
+  {% with keys = ctx.db.list_nonweb_keys(ctx.uid) %}
   {% if keys %}
   {% for id, name, exp in keys %}
     <div class="row key u-flex">
       <div class="details six columns">
         <span class="key-name">{{ name or 'anonymous' }}</span>
         <span class="key-key">{{ id[:10] }}...</span>
-        <span class="key-expiration u-ml-auto">{{ 'Expires in ' if exp else ''}}{{ exp - datetime.now() if exp else 'Never expires' }}</span>
+        <span class="key-expiration u-ml-auto">{{ 'Expires in ' if exp else ''}}{{ datetime.fromisoformat(exp) - datetime.now() if exp else 'Never expires' }}</span>
       </div>
       <div class="controls u-flex u-ml-auto">
         <form class="inline" method="none" class="ml-auto">
diff --git a/projects/tentacles/src/python/tentacles/templates/users_list.html.j2 b/projects/tentacles/src/python/tentacles/templates/users_list.html.j2
new file mode 100644
index 0000000..644b345
--- /dev/null
+++ b/projects/tentacles/src/python/tentacles/templates/users_list.html.j2
@@ -0,0 +1,23 @@
+{% import "macros.html.j2" as macros %}
+<h2>Users</h2>
+{% with users = ctx.db.list_users(uid=ctx.uid) %}
+{% for user in users %}
+<div class="user row u-flex">
+  <div class="details six columns">
+    <span class="user-name">{{ user.name }}</span>
+    <span class="user-email">{{ user.email }}</span>
+    <span class="user-group">{{ user.group }}</span>
+    <span class="user-status">{{ user.status }}</span>
+  </div>
+  <div class="controls u-flex u-ml-auto">
+    {% if not user.approved_at %}{{ macros.approve_user(user.id) }}{% endif %}
+    {% if not user.enabled_at %}
+      {{ macros.enable_user(user.id) }}
+    {% else %}
+      {{ macros.lock_user(user.id) }}
+    {% endif %}
+    {{ macros.passwdchng_user(user.id) }}
+  </div>
+</div>
+{% endfor %}
+{% endwith %}
diff --git a/projects/tentacles/src/python/tentacles/workers.py b/projects/tentacles/src/python/tentacles/workers.py
index cdf0da4..13344e9 100644
--- a/projects/tentacles/src/python/tentacles/workers.py
+++ b/projects/tentacles/src/python/tentacles/workers.py
@@ -10,7 +10,6 @@ Mostly related to monitoring and managing Printer state.
 from contextlib import closing
 import logging
 from pathlib import Path
-from threading import Event
 from typing import Callable
 from urllib import parse as urlparse
 
@@ -42,8 +41,6 @@ class OctoRest(_OR):
 
 log = logging.getLogger(__name__)
 
-SHUTDOWN = Event()
-
 
 def poll_printers(app: App, db: Db) -> None:
     """Poll printers for their status."""
@@ -56,13 +53,14 @@ def poll_printers(app: App, db: Db) -> None:
                 log.info(f"Printer {printer.id} {printer.status} -> {status}")
             db.update_printer_status(pid=printer.id, status=status)
 
+        printer_job = {}
         try:
             client = OctoRest(url=printer.url, apikey=printer.api_key)
-            printer_job = client.job_info()
+            printer_job: dict = client.job_info()
             try:
-                printer_state = client.printer().get("state").get("flags", {})
+                printer_state: dict = client.printer().get("state").get("flags", {})
             except HTTPError:
-                printer_state = {"disconnected": True}
+                printer_state: dict = {"disconnected": True}
 
             if printer_state.get("error"):
                 # If there's a mapped job, we manually fail it so that
@@ -251,19 +249,7 @@ def send_emails(app, db: Db):
                 subject=message.subject,
                 msg=message.body,
             )
-            db.send_email(message.id)
-
-
-def once(f):
-    val = uninitialized = object()
-
-    def _helper(*args, **kwargs):
-        nonlocal val
-        if val is uninitialized:
-            val = f(*args, **kwargs)
-        return val
-
-    return _helper
+            db.send_email(eid=message.id)
 
 
 def toil(*fs):