From 8e1d94bb5d7f3c789fd2384c3d03307f6bd2e635 Mon Sep 17 00:00:00 2001
From: Patrick Jentsch <p.jentsch@uni-bielefeld.de>
Date: Fri, 2 Sep 2022 13:07:30 +0200
Subject: [PATCH] Restructure project

---
 app/__init__.py                               |   5 +-
 app/auth/forms.py                             |  77 ++-
 app/auth/routes.py                            | 147 ++--
 app/contributions/forms.py                    |  15 -
 app/corpora/forms.py                          | 119 ++--
 app/corpora/routes.py                         | 440 +++++-------
 app/corpora/tasks.py                          |  34 -
 app/daemon/job_utils.py                       |  11 +-
 app/errors/handlers.py                        |  53 +-
 app/jobs/routes.py                            | 129 ++--
 app/jobs/tasks.py                             |  27 -
 app/main/routes.py                            |  26 +-
 app/models.py                                 | 631 ++++++++++--------
 app/query_results_models.py                   |  12 +-
 app/services/forms.py                         |  39 +-
 app/services/routes.py                        | 273 +++-----
 app/services/services.yml                     |   7 +-
 app/settings/forms.py                         |  75 ++-
 app/settings/routes.py                        |  76 +--
 app/settings/tasks.py                         |  13 -
 app/static/js/App.js                          |  76 ++-
 app/static/js/JobStatusNotifier.js            |  31 -
 .../js/RessourceDisplays/CorpusDisplay.js     |  58 +-
 app/static/js/RessourceDisplays/JobDisplay.js |  75 ++-
 .../js/RessourceDisplays/RessourceDisplay.js  |  30 +-
 .../js/RessourceLists/CorpusFileList.js       | 140 ++--
 app/static/js/RessourceLists/CorpusList.js    | 132 ++--
 app/static/js/RessourceLists/JobInputList.js  |  59 +-
 app/static/js/RessourceLists/JobList.js       | 133 ++--
 app/static/js/RessourceLists/JobResultList.js |  84 ++-
 .../js/RessourceLists/QueryResultList.js      |  13 +-
 app/static/js/RessourceLists/RessourceList.js | 108 ++-
 app/static/js/RessourceLists/UserList.js      | 100 +--
 app/static/js/UploadForm.js                   | 125 ----
 app/static/js/Utils.js                        | 326 +++++++++
 app/templates/_navbar.html.j2                 |   2 +-
 app/templates/_roadmap.html.j2                |   8 +-
 app/templates/_scripts.html.j2                |  12 +-
 app/templates/_sidenav.html.j2                |   7 +-
 app/templates/_styles.html.j2                 |   1 +
 app/templates/admin/edit_user.html.j2         |   4 +-
 app/templates/admin/user.html.j2              |  40 +-
 app/templates/admin/users.html.j2             |  30 +-
 app/templates/auth/login.html.j2              |  57 +-
 app/templates/auth/register.html.j2           |  56 +-
 app/templates/auth/reset_password.html.j2     |  27 +-
 .../auth/reset_password_request.html.j2       |  25 +-
 app/templates/auth/unconfirmed.html.j2        |  21 +-
 app/templates/corpora/_breadcrumbs.html.j2    |   8 +-
 app/templates/corpora/corpus.html.j2          |  41 +-
 ...d_corpus.html.j2 => create_corpus.html.j2} |   0
 ...ile.html.j2 => create_corpus_file.html.j2} |  34 +-
 app/templates/errors/403.html.j2              |  19 -
 app/templates/errors/404.html.j2              |  19 -
 app/templates/errors/413.html.j2              |  19 -
 app/templates/errors/500.html.j2              |  19 -
 app/templates/errors/503.html.j2              |  19 -
 app/templates/errors/error.html.j2            |  10 +
 app/templates/jobs/job.html.j2                |  84 +--
 app/templates/main/dashboard.html.j2          |  81 +--
 .../services/corpus_analysis.html.j2          |  26 +-
 .../services/file_setup_pipeline.html.j2      |   2 +-
 .../services/spacy_nlp_pipeline.html.j2       |   2 +-
 .../services/tesseract_ocr_pipeline.html.j2   |  26 +-
 .../services/transkribus_htr_pipeline.html.j2 |   2 +-
 app/templates/settings/_breadcrumbs.html.j2   |   4 +-
 .../{index.html.j2 => settings.html.j2}       |  24 +-
 app/users/__init__.py                         |   2 +-
 app/users/events.py                           |  17 +-
 app/users/routes.py                           |  38 ++
 migrations/versions/9e8d7d15d950_.py          |   2 +-
 migrations/versions/f9070ff1fa4a_.py          |  31 +
 requirements.txt                              |   5 +-
 73 files changed, 2080 insertions(+), 2443 deletions(-)
 delete mode 100644 app/contributions/forms.py
 delete mode 100644 app/corpora/tasks.py
 delete mode 100644 app/jobs/tasks.py
 delete mode 100644 app/settings/tasks.py
 delete mode 100644 app/static/js/JobStatusNotifier.js
 delete mode 100644 app/static/js/UploadForm.js
 create mode 100644 app/static/js/Utils.js
 rename app/templates/corpora/{add_corpus.html.j2 => create_corpus.html.j2} (100%)
 rename app/templates/corpora/{add_corpus_file.html.j2 => create_corpus_file.html.j2} (66%)
 delete mode 100644 app/templates/errors/403.html.j2
 delete mode 100644 app/templates/errors/404.html.j2
 delete mode 100644 app/templates/errors/413.html.j2
 delete mode 100644 app/templates/errors/500.html.j2
 delete mode 100644 app/templates/errors/503.html.j2
 create mode 100644 app/templates/errors/error.html.j2
 rename app/templates/settings/{index.html.j2 => settings.html.j2} (83%)
 create mode 100644 app/users/routes.py
 create mode 100644 migrations/versions/f9070ff1fa4a_.py

diff --git a/app/__init__.py b/app/__init__.py
index de64a195..1a749ed4 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -51,9 +51,6 @@ def create_app(config: Config = Config) -> Flask:
     from .admin import bp as admin_blueprint
     app.register_blueprint(admin_blueprint, url_prefix='/admin')
 
-    from .api import bp as api_blueprint
-    app.register_blueprint(api_blueprint, url_prefix='/api')
-
     from .auth import bp as auth_blueprint
     app.register_blueprint(auth_blueprint, url_prefix='/auth')
 
@@ -70,7 +67,7 @@ def create_app(config: Config = Config) -> Flask:
     app.register_blueprint(jobs_blueprint, url_prefix='/jobs')
 
     from .main import bp as main_blueprint
-    app.register_blueprint(main_blueprint)
+    app.register_blueprint(main_blueprint, url_prefix='/')
 
     from .services import bp as services_blueprint
     app.register_blueprint(services_blueprint, url_prefix='/services')
diff --git a/app/auth/forms.py b/app/auth/forms.py
index 8d47e6b1..6917b78b 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -1,4 +1,3 @@
-from app.models import User
 from flask_wtf import FlaskForm
 from wtforms import (
     BooleanField,
@@ -7,32 +6,45 @@ from wtforms import (
     SubmitField,
     ValidationError
 )
-from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp
+from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp
+from app.models import User
 from . import USERNAME_REGEX
 
 
-class LoginForm(FlaskForm):
-    user = StringField('Email or username', validators=[DataRequired()])
-    password = PasswordField('Password', validators=[DataRequired()])
-    remember_me = BooleanField('Keep me logged in')
-    submit = SubmitField('Log In')
-
-
 class RegistrationForm(FlaskForm):
-    email = StringField('Email', validators=[DataRequired(), Email()])
-    username = StringField('Username',
+    email = StringField(
+        'Email',
+        validators=[InputRequired(), Email(), Length(max=254)]
+    )
+    username = StringField(
+        'Username',
         validators=[
             InputRequired(),
-            Length(1, 64),
+            Length(max=64),
             Regexp(
                 USERNAME_REGEX,
-                message='Usernames must have only letters, numbers, dots or underscores'  # noqa
-           )
+                message=(
+                    'Usernames must have only letters, numbers, dots or '
+                    'underscores'
+                )
+            )
        ]
     )
-    password = PasswordField('Password', validators=[DataRequired(), EqualTo('password_confirmation', message='Passwords must match')])
-    password_confirmation = PasswordField('Password confirmation', validators=[DataRequired(), EqualTo('password', message='Passwords must match')])
-    submit = SubmitField('Register')
+    password = PasswordField(
+        'Password',
+        validators=[
+            InputRequired(),
+            EqualTo('password_2', message='Passwords must match')
+        ]
+    )
+    password_2 = PasswordField(
+        'Password confirmation',
+        validators=[
+            InputRequired(),
+            EqualTo('password', message='Passwords must match')
+        ]
+    )
+    submit = SubmitField()
 
     def validate_email(self, field):
         if User.query.filter_by(email=field.data.lower()).first():
@@ -43,12 +55,31 @@ class RegistrationForm(FlaskForm):
             raise ValidationError('Username already in use')
 
 
-class ResetPasswordForm(FlaskForm):
-    password = PasswordField('New password', validators=[DataRequired(), EqualTo('password_confirmation', message='Passwords must match')])
-    password_confirmation = PasswordField('Password confirmation', validators=[DataRequired(), EqualTo('password', message='Passwords must match')])
-    submit = SubmitField('Reset Password')
+class LoginForm(FlaskForm):
+    user = StringField('Email or username', validators=[InputRequired()])
+    password = PasswordField('Password', validators=[InputRequired()])
+    remember_me = BooleanField('Keep me logged in')
+    submit = SubmitField()
 
 
 class ResetPasswordRequestForm(FlaskForm):
-    email = StringField('Email', validators=[DataRequired(), Email()])
-    submit = SubmitField('Reset Password')
+    email = StringField('Email', validators=[InputRequired(), Email()])
+    submit = SubmitField()
+
+
+class ResetPasswordForm(FlaskForm):
+    password = PasswordField(
+        'New password',
+        validators=[
+            InputRequired(),
+            EqualTo('password_2', message='Passwords must match')
+        ]
+    )
+    password_2 = PasswordField(
+        'New password confirmation',
+        validators=[
+            InputRequired(),
+            EqualTo('password', message='Passwords must match')
+        ]
+    )
+    submit = SubmitField()
diff --git a/app/auth/routes.py b/app/auth/routes.py
index 6897b088..5655d0dc 100644
--- a/app/auth/routes.py
+++ b/app/auth/routes.py
@@ -1,10 +1,5 @@
-from app import db
-from app.email import create_message, send
-from app.models import User
-from datetime import datetime
 from flask import (
     abort,
-    current_app,
     flash,
     redirect,
     render_template,
@@ -12,7 +7,9 @@ from flask import (
     url_for
 )
 from flask_login import current_user, login_user, login_required, logout_user
-from sqlalchemy import or_
+from app import db
+from app.email import create_message, send
+from app.models import User
 from . import bp
 from .forms import (
     LoginForm,
@@ -29,69 +26,32 @@ def before_request():
     unconfirmed view if user is unconfirmed.
     """
     if current_user.is_authenticated:
-        current_user.last_seen = datetime.utcnow()
+        current_user.ping()
         db.session.commit()
-        if (
-            not current_user.confirmed
-            and request.endpoint
-            and request.blueprint != 'auth'
-            and request.endpoint != 'static'
-        ):
+        if (not current_user.confirmed
+                and request.endpoint
+                and request.blueprint != 'auth'
+                and request.endpoint != 'static'):
             return redirect(url_for('auth.unconfirmed'))
 
 
-@bp.route('/login', methods=['GET', 'POST'])
-def login():
-    if current_user.is_authenticated:
-        return redirect(url_for('main.dashboard'))
-    form = LoginForm(prefix='login-form')
-    if form.validate_on_submit():
-        user = User.query.filter(
-            or_(
-                User.username == form.user.data,
-                User.email == form.user.data.lower()
-            )
-        ).first()
-        if user and user.verify_password(form.password.data):
-            login_user(user, form.remember_me.data)
-            next = request.args.get('next')
-            if next is None or not next.startswith('/'):
-                next = url_for('main.dashboard')
-            return redirect(next)
-        flash('Invalid email/username or password', category='error')
-    return render_template('auth/login.html.j2', form=form, title='Log in')
-
-
-@bp.route('/logout')
-@login_required
-def logout():
-    logout_user()
-    flash('You have been logged out')
-    return redirect(url_for('main.index'))
-
-
 @bp.route('/register', methods=['GET', 'POST'])
 def register():
     if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
     form = RegistrationForm(prefix='registration-form')
     if form.validate_on_submit():
-        user = User(
-            email=form.email.data.lower(),
-            password=form.password.data,
-            username=form.username.data
-        )
-        db.session.add(user)
-        db.session.flush(objects=[user])
-        db.session.refresh(user)
         try:
-            user.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
+            user = User.create(
+                email=form.email.data.lower(),
+                password=form.password.data,
+                username=form.username.data
+            )
+        except OSError:
             flash('Internal Server Error', category='error')
             abort(500)
-        token = user.generate_confirm_user_token()
+        flash(f'User "{user.username}" created')
+        token = user.generate_confirm_token()
         msg = create_message(
             user.email,
             'Confirm Your Account',
@@ -110,36 +70,46 @@ def register():
     )
 
 
-@bp.route('/confirm/<token>')
-@login_required
-def confirm(token):
-    if current_user.confirmed:
-        return redirect(url_for('main.dashboard'))
-    if current_user.confirm_user(token):
-        db.session.commit()
-        flash('You have confirmed your account')
+@bp.route('/login', methods=['GET', 'POST'])
+def login():
+    if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
-    else:
-        flash(
-            'The confirmation link is invalid or has expired',
-            category='error'
-        )
-        return redirect(url_for('.unconfirmed'))
+    form = LoginForm(prefix='login-form')
+    if form.validate_on_submit():
+        user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
+        if user and user.verify_password(form.password.data):
+            login_user(user, form.remember_me.data)
+            next = request.args.get('next')
+            if next is None or not next.startswith('/'):
+                next = url_for('main.dashboard')
+            flash('You have been logged in')
+            return redirect(next)
+        flash('Invalid email/username or password', category='error')
+    return render_template('auth/login.html.j2', form=form, title='Log in')
+
+
+@bp.route('/logout')
+@login_required
+def logout():
+    logout_user()
+    flash('You have been logged out')
+    return redirect(url_for('main.index'))
 
 
 @bp.route('/unconfirmed')
+@login_required
 def unconfirmed():
-    if current_user.is_anonymous:
-        return redirect(url_for('main.index'))
-    elif current_user.confirmed:
+    if current_user.confirmed:
         return redirect(url_for('main.dashboard'))
     return render_template('auth/unconfirmed.html.j2', title='Unconfirmed')
 
 
 @bp.route('/confirm')
 @login_required
-def resend_confirmation():
-    token = current_user.generate_confirm_user_token()
+def confirm_request():
+    if current_user.confirmed:
+        return redirect(url_for('main.dashboard'))
+    token = current_user.generate_confirm_token()
     msg = create_message(
         current_user.email,
         'Confirm Your Account',
@@ -149,10 +119,23 @@ def resend_confirmation():
     )
     send(msg)
     flash('A new confirmation email has been sent to you by email')
-    return redirect(url_for('auth.unconfirmed'))
+    return redirect(url_for('.unconfirmed'))
 
 
-@bp.route('/reset', methods=['GET', 'POST'])
+@bp.route('/confirm/<token>')
+@login_required
+def confirm(token):
+    if current_user.confirmed:
+        return redirect(url_for('main.dashboard'))
+    if current_user.confirm(token):
+        db.session.commit()
+        flash('You have confirmed your account')
+        return redirect(url_for('main.dashboard'))
+    flash('The confirmation link is invalid or has expired', category='error')
+    return redirect(url_for('.unconfirmed'))
+
+
+@bp.route('/reset_password', methods=['GET', 'POST'])
 def reset_password_request():
     if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
@@ -160,7 +143,7 @@ def reset_password_request():
     if form.validate_on_submit():
         user = User.query.filter_by(email=form.email.data.lower()).first()
         if user is not None:
-            token = user.generate_password_reset_token()
+            token = user.generate_reset_password_token()
             msg = create_message(
                 user.email,
                 'Reset Your Password',
@@ -170,7 +153,8 @@ def reset_password_request():
             )
             send(msg)
         flash(
-            'An email with instructions to reset your password has been sent to you'  # noqa
+            'An email with instructions to reset your password has been sent '
+            'to you'
         )
         return redirect(url_for('.login'))
     return render_template(
@@ -180,7 +164,7 @@ def reset_password_request():
     )
 
 
-@bp.route('/reset/<token>', methods=['GET', 'POST'])
+@bp.route('/reset_password/<token>', methods=['GET', 'POST'])
 def reset_password(token):
     if current_user.is_authenticated:
         return redirect(url_for('main.dashboard'))
@@ -190,8 +174,7 @@ def reset_password(token):
             db.session.commit()
             flash('Your password has been updated')
             return redirect(url_for('.login'))
-        else:
-            return redirect(url_for('main.index'))
+        return redirect(url_for('main.index'))
     return render_template(
         'auth/reset_password.html.j2',
         form=form,
diff --git a/app/contributions/forms.py b/app/contributions/forms.py
deleted file mode 100644
index 205f1740..00000000
--- a/app/contributions/forms.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from app.models import User
-from flask_wtf import FlaskForm
-from wtforms import (
-    BooleanField,
-    PasswordField,
-    StringField,
-    SubmitField,
-    ValidationError
-)
-from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp
-from . import USERNAME_REGEX
-
-
-class ContributeTesseractOCRModel(FlaskForm):
-    pass
diff --git a/app/corpora/forms.py b/app/corpora/forms.py
index 26105a13..73002edc 100644
--- a/app/corpora/forms.py
+++ b/app/corpora/forms.py
@@ -1,80 +1,67 @@
 from flask_wtf import FlaskForm
 from flask_wtf.file import FileField, FileRequired
-from werkzeug.utils import secure_filename
-from wtforms import (
-    StringField,
-    SubmitField,
-    ValidationError,
-    IntegerField
-)
-from wtforms.validators import DataRequired, InputRequired, Length
+from wtforms import StringField, SubmitField, ValidationError, IntegerField
+from wtforms.validators import InputRequired, Length
 
 
-class AddCorpusFileForm(FlaskForm):
-    '''
-    Form to add a .vrt corpus file to the current corpus.
-    '''
-    # Required fields
-    author = StringField('Author', validators=[InputRequired(), Length(1, 255)])
-    publishing_year = IntegerField('Publishing year', validators=[InputRequired()])
-    title = StringField('Title', validators=[InputRequired(), Length(1, 255)])
-    vrt = FileField('File', validators=[FileRequired()])
-    # Optional fields
-    address = StringField('Adress', validators=[Length(0, 255)])
-    booktitle = StringField('Booktitle', validators=[Length(0, 255)])
-    chapter = StringField('Chapter', validators=[Length(0, 255)])
-    editor = StringField('Editor', validators=[Length(0, 255)])
-    institution = StringField('Institution', validators=[Length(0, 255)])
-    journal = StringField('Journal', validators=[Length(0, 255)])
-    pages = StringField('Pages', validators=[Length(0, 255)])
-    publisher = StringField('Publisher', validators=[Length(0, 255)])
-    school = StringField('School', validators=[Length(0, 255)])
+class CreateCorpusForm(FlaskForm):
+    description = StringField(
+        'Description',
+        validators=[InputRequired(), Length(max=255)]
+    )
+    title = StringField('Title', validators=[InputRequired(), Length(max=32)])
+    submit = SubmitField()
+
+
+class CorpusFileBaseForm(FlaskForm):
+    author = StringField(
+        'Author',
+        validators=[InputRequired(), Length(max=255)]
+    )
+    publishing_year = IntegerField(
+        'Publishing year',
+        validators=[InputRequired()]
+    )
+    title = StringField(
+        'Title',
+        validators=[InputRequired(), Length(max=255)]
+    )
+    address = StringField('Adress', validators=[Length(max=255)])
+    booktitle = StringField('Booktitle', validators=[Length(max=255)])
+    chapter = StringField('Chapter', validators=[Length(max=255)])
+    editor = StringField('Editor', validators=[Length(max=255)])
+    institution = StringField('Institution', validators=[Length(max=255)])
+    journal = StringField('Journal', validators=[Length(max=255)])
+    pages = StringField('Pages', validators=[Length(max=255)])
+    publisher = StringField('Publisher', validators=[Length(max=255)])
+    school = StringField('School', validators=[Length(max=255)])
     submit = SubmitField()
 
+
+class CreateCorpusFileForm(CorpusFileBaseForm):
+    vrt = FileField('File', validators=[FileRequired()])
+
     def validate_vrt(self, field):
         if not field.data.filename.lower().endswith('.vrt'):
             raise ValidationError('VRT files only!')
 
-class EditCorpusFileForm(FlaskForm):
-    '''
-    Form to edit meta data of one corpus file.
-    '''
-    # Required fields
-    author = StringField('Author', validators=[InputRequired(), Length(1, 255)])
-    publishing_year = IntegerField('Publishing year', validators=[InputRequired()])
-    title = StringField('Title', validators=[InputRequired(), Length(1, 255)])
-    # Optional fields
-    address = StringField('Adress', validators=[Length(0, 255)])
-    booktitle = StringField('Booktitle', validators=[Length(0, 255)])
-    chapter = StringField('Chapter', validators=[Length(0, 255)])
-    editor = StringField('Editor', validators=[Length(0, 255)])
-    institution = StringField('Institution', validators=[Length(0, 255)])
-    journal = StringField('Journal', validators=[Length(0, 255)])
-    pages = StringField('Pages', validators=[Length(0, 255)])
-    publisher = StringField('Publisher', validators=[Length(0, 255)])
-    school = StringField('School', validators=[Length(0, 255)])
-    submit = SubmitField()
-
 
-class AddCorpusForm(FlaskForm):
-    '''
-    Form to add a a new corpus.
-    '''
-    description = StringField('Description', validators=[InputRequired(), Length(1, 255)])
-    title = StringField('Title', validators=[InputRequired(), Length(1, 32)])
-    submit = SubmitField()
+class EditCorpusFileForm(CorpusFileBaseForm):
+    def prefill(self, corpus_file):
+        ''' Pre-fill the form with data of an exististing corpus file '''
+        self.address.data = corpus_file.address
+        self.author.data = corpus_file.author
+        self.booktitle.data = corpus_file.booktitle
+        self.chapter.data = corpus_file.chapter
+        self.editor.data = corpus_file.editor
+        self.institution.data = corpus_file.institution
+        self.journal.data = corpus_file.journal
+        self.pages.data = corpus_file.pages
+        self.publisher.data = corpus_file.publisher
+        self.publishing_year.data = corpus_file.publishing_year
+        self.school.data = corpus_file.school
+        self.title.data = corpus_file.title
 
 
 class ImportCorpusForm(FlaskForm):
-    '''
-    Form to import a corpus.
-    '''
-    description = StringField('Description', validators=[InputRequired(), Length(1, 255)])
-    archive = FileField('File', validators=[FileRequired()])
-    title = StringField('Title', validators=[InputRequired(), Length(1, 32)])
-    submit = SubmitField()
-
-    def validate_archive(self, field):
-        valid_mimetypes = ['application/zip', 'application/x-zip', 'application/x-zip-compressed']
-        if field.data.mimetype not in valid_mimetypes:
-            raise ValidationError('ZIP files only!')
+    pass
diff --git a/app/corpora/routes.py b/app/corpora/routes.py
index 532f0249..36e19e2b 100644
--- a/app/corpora/routes.py
+++ b/app/corpora/routes.py
@@ -1,140 +1,44 @@
-from app import db
-from app.models import Corpus, CorpusFile, CorpusStatus
 from flask import (
     abort,
     current_app,
     flash,
-    make_response,
+    Markup,
     redirect,
     render_template,
-    url_for,
     send_from_directory
 )
 from flask_login import current_user, login_required
-from . import bp
-from . import tasks
-from .forms import (
-    AddCorpusFileForm,
-    AddCorpusForm,
-    EditCorpusFileForm,
-    ImportCorpusForm
-)
+from threading import Thread
 import os
-import shutil
-import tempfile
-import xml.etree.ElementTree as ET
+from app import db
+from app.models import Corpus, CorpusFile, CorpusStatus
+from . import bp
+from .forms import CreateCorpusFileForm, CreateCorpusForm, EditCorpusFileForm
 
 
-@bp.route('/add', methods=['GET', 'POST'])
+@bp.route('/create', methods=['GET', 'POST'])
 @login_required
-def add_corpus():
-    form = AddCorpusForm(prefix='add-corpus-form')
+def create_corpus():
+    form = CreateCorpusForm(prefix='create-corpus-form')
     if form.validate_on_submit():
-        corpus = Corpus(
-            user=current_user,
-            description=form.description.data,
-            title=form.title.data
-        )
-        db.session.add(corpus)
-        db.session.flush()
-        db.session.refresh(corpus)
         try:
-            corpus.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', category='error')
+            corpus = Corpus.create(
+                title=form.title.data,
+                description=form.description.data,
+                user=current_user
+            )
+        except OSError:
             abort(500)
         db.session.commit()
-        flash(f'Corpus "{corpus.title}" added', category='corpus')
-        return redirect(url_for('.corpus', corpus_id=corpus.id))
-    return render_template(
-        'corpora/add_corpus.html.j2',
-        form=form,
-        title='Add corpus'
-    )
-
-
-@bp.route('/import', methods=['GET', 'POST'])
-@login_required
-def import_corpus():
-    form = ImportCorpusForm(prefix='import-corpus-form')
-    if form.is_submitted():
-        if not form.validate():
-            return make_response(form.errors, 400)
-        corpus = Corpus(
-            user=current_user,
-            description=form.description.data,
-            title=form.title.data
+        message = Markup(
+            f'Corpus "<a href="{corpus.url}">{corpus.title}</a>" created'
         )
-        db.session.add(corpus)
-        db.session.flush(objects=[corpus])
-        db.session.refresh(corpus)
-        try:
-            corpus.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', category='error')
-            return make_response({'redirect_url': url_for('.import_corpus')}, 500)  # noqa
-        # Save the uploaded zip file in a temporary directory
-        tmp_dir_base = os.path.join(current_app.config['NOPAQUE_DATA_DIR'], 'tmp')  # noqa
-        with tempfile.TemporaryDirectory(dir=tmp_dir_base) as tmp_dir:
-            archive_file = os.path.join(tmp_dir, 'corpus.zip')
-            try:
-                form.archive.data.save(archive_file)
-            except OSError as e:
-                current_app.logger.error(e)
-                db.session.rollback()
-                flash('Internal Server Error1', category='error')
-                return make_response({'redirect_url': url_for('.import_corpus')}, 500)  # noqa
-            shutil.unpack_archive(archive_file, extract_dir=tmp_dir)
-            for vrt_filename in [x for x in os.listdir(tmp_dir) if x.endswith('.vrt')]:
-                vrt_file = os.path.join(tmp_dir, vrt_filename)
-                element_tree = ET.parse(vrt_file)
-                text_node = element_tree.find('text')
-                corpus_file = CorpusFile(
-                    author=text_node.get('author'),
-                    corpus=corpus,
-                    filename=vrt_filename,
-                    mimetype='application/vrt+xml',
-                    publishing_year=int(text_node.get('publishing_year')),
-                    title=text_node.get('title')
-                )
-                if 'address' not in text_node.attrib:
-                    corpus_file.address = text_node.get('address')
-                if 'booktitle' not in text_node.attrib:
-                    corpus_file.booktitle = text_node.get('booktitle')
-                if 'chapter' not in text_node.attrib:
-                    corpus_file.chapter = text_node.get('chapter')
-                if 'editor' not in text_node.attrib:
-                    corpus_file.editor = text_node.get('editor')
-                if 'institution' not in text_node.attrib:
-                    corpus_file.institution = text_node.get('institution')
-                if 'journal' not in text_node.attrib:
-                    corpus_file.journal = text_node.get('journal')
-                if 'pages' not in text_node.attrib:
-                    corpus_file.pages = text_node.get('pages')
-                if 'publisher' not in text_node.attrib:
-                    corpus_file.publisher = text_node.get('publisher')
-                if 'school' not in text_node.attrib:
-                    corpus_file.school = text_node.get('school')
-                db.session.add(corpus_file)
-                db.session.flush(objects=[corpus_file])
-                db.session.refresh(corpus)
-                try:
-                    shutil.copy2(vrt_file, corpus_file.path)
-                except Exception as e:
-                    db.session.rollback()
-                    flash('Internal Server Error2', category='error')
-                    return make_response({'redirect_url': url_for('.import_corpus')}, 500)  # noqa
-        db.session.commit()
-        flash(f'Corpus "{corpus.title}" imported', 'corpus')
-        return make_response({'redirect_url': url_for('.corpus', corpus_id=corpus.id)}, 201)
+        flash(message, 'corpus')
+        return redirect(corpus.url)
     return render_template(
-        'corpora/import_corpus.html.j2',
+        'corpora/create_corpus.html.j2',
         form=form,
-        title='Import Corpus'
+        title='Create corpus'
     )
 
 
@@ -151,6 +55,26 @@ def corpus(corpus_id):
     )
 
 
+@bp.route('/<hashid:corpus_id>', methods=['DELETE'])
+@login_required
+def delete_corpus(corpus_id):
+    def _delete_corpus(app, corpus_id):
+        with app.app_context():
+            corpus = Corpus.query.get(corpus_id)
+            corpus.delete()
+            db.session.commit()
+
+    corpus = Corpus.query.get_or_404(corpus_id)
+    if not (corpus.user == current_user or current_user.is_administrator()):
+        abort(403)
+    thread = Thread(
+        target=_delete_corpus,
+        args=(current_app._get_current_object(), corpus_id)
+    )
+    thread.start()
+    return {}, 202
+
+
 @bp.route('/<hashid:corpus_id>/analyse')
 @login_required
 def analyse_corpus(corpus_id):
@@ -162,95 +86,132 @@ def analyse_corpus(corpus_id):
     )
 
 
-@bp.route('/<hashid:corpus_id>/build')
+@bp.route('/<hashid:corpus_id>/build', methods=['POST'])
 @login_required
 def build_corpus(corpus_id):
-    corpus = Corpus.query.get_or_404(corpus_id)
-    if not (corpus.user == current_user or current_user.is_administrator()):
-        abort(403)
-    if corpus.files.all():
-        tasks.build_corpus(corpus_id)
-        flash(
-            f'Corpus "{corpus.title}" marked for building',
-            category='corpus'
-        )
-    else:
-        flash(
-            f'Can\'t build corpus "{corpus.title}": No corpus file(s)',
-            category='error'
-        )
-    return redirect(url_for('.corpus', corpus_id=corpus_id))
-
+    def _build_corpus(app, corpus_id):
+        with app.app_context():
+            corpus = Corpus.query.get(corpus_id)
+            corpus.build()
+            db.session.commit()
 
-@bp.route('/<hashid:corpus_id>/delete')
-@login_required
-def delete_corpus(corpus_id):
     corpus = Corpus.query.get_or_404(corpus_id)
     if not (corpus.user == current_user or current_user.is_administrator()):
         abort(403)
-    flash(f'Corpus "{corpus.title}" marked for deletion', 'corpus')
-    tasks.delete_corpus(corpus_id)
-    return redirect(url_for('main.dashboard'))
+    # Check if the corpus has corpus files
+    if not corpus.files.all():
+        response = {'errors': {'message': 'Corpus file(s) required'}}
+        return response, 409
+    thread = Thread(
+        target=_build_corpus,
+        args=(current_app._get_current_object(), corpus_id)
+    )
+    thread.start()
+    return {}, 202
 
 
-@bp.route('/<hashid:corpus_id>/export')
+@bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
 @login_required
-def export_corpus(corpus_id):
-    abort(503)
+def create_corpus_file(corpus_id):
     corpus = Corpus.query.get_or_404(corpus_id)
     if not (corpus.user == current_user or current_user.is_administrator()):
         abort(403)
-    return send_from_directory(
-        as_attachment=True,
-        directory=os.path.join(corpus.user.path, 'corpora'),
-        filename=corpus.archive_file,
-        mimetype='zip'
+    form = CreateCorpusFileForm(prefix='create-corpus-file-form')
+    if form.is_submitted():
+        if not form.validate():
+            response = {'errors': form.errors}
+            return response, 400
+        try:
+            corpus_file = CorpusFile.create(
+                form.vrt.data,
+                address=form.address.data,
+                author=form.author.data,
+                booktitle=form.booktitle.data,
+                chapter=form.chapter.data,
+                editor=form.editor.data,
+                institution=form.institution.data,
+                journal=form.journal.data,
+                pages=form.pages.data,
+                publisher=form.publisher.data,
+                publishing_year=form.publishing_year.data,
+                school=form.school.data,
+                title=form.title.data,
+                mimetype='application/vrt+xml',
+                corpus=corpus
+            )
+        except OSError:
+            abort(500)
+        corpus.status = CorpusStatus.UNPREPARED
+        db.session.commit()
+        message = Markup(
+            'Corpus file'
+            f'"<a href="{corpus_file.url}">{corpus_file.filename}</a>" added'
+        )
+        flash(message, category='corpus')
+        return {}, 201, {'Location': corpus.url}
+    return render_template(
+        'corpora/create_corpus_file.html.j2',
+        corpus=corpus,
+        form=form,
+        title='Add corpus file'
     )
 
 
-@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])  # noqa
+@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>',
+          methods=['GET', 'POST'])
 @login_required
 def corpus_file(corpus_id, corpus_file_id):
-    corpus_file = CorpusFile.query.filter(
-        CorpusFile.corpus_id == corpus_id,
-        CorpusFile.id == corpus_file_id
-    ).first_or_404()
-    if not (
-        corpus_file.corpus.user == current_user
-        or current_user.is_administrator()
-    ):
+    corpus_file = CorpusFile.query.get_or_404(corpus_file_id)
+    if corpus_file.corpus.id != corpus_id:
+        abort(404)
+    if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
         abort(403)
     form = EditCorpusFileForm(prefix='edit-corpus-file-form')
     if form.validate_on_submit():
-        corpus_file.address = form.address.data
-        corpus_file.author = form.author.data
-        corpus_file.booktitle = form.booktitle.data
-        corpus_file.chapter = form.chapter.data
-        corpus_file.editor = form.editor.data
-        corpus_file.institution = form.institution.data
-        corpus_file.journal = form.journal.data
-        corpus_file.pages = form.pages.data
-        corpus_file.publisher = form.publisher.data
-        corpus_file.publishing_year = form.publishing_year.data
-        corpus_file.school = form.school.data
-        corpus_file.title = form.title.data
-        corpus_file.corpus.status = CorpusStatus.UNPREPARED
+        has_changes = False
+        if corpus_file.address != form.address.data:
+            corpus_file.address = form.address.data
+            has_changes = True
+        if corpus_file.author != form.author.data:
+            corpus_file.author = form.author.data
+            has_changes = True
+        if corpus_file.booktitle != form.booktitle.data:
+            corpus_file.booktitle = form.booktitle.data
+            has_changes = True
+        if corpus_file.chapter != form.chapter.data:
+            corpus_file.chapter = form.chapter.data
+            has_changes = True
+        if corpus_file.editor != form.editor.data:
+            corpus_file.editor = form.editor.data
+            has_changes = True
+        if corpus_file.institution != form.institution.data:
+            corpus_file.institution = form.institution.data
+            has_changes = True
+        if corpus_file.journal != form.journal.data:
+            corpus_file.journal = form.journal.data
+            has_changes = True
+        if corpus_file.pages != form.pages.data:
+            corpus_file.pages = form.pages.data
+            has_changes = True
+        if corpus_file.publisher != form.publisher.data:
+            corpus_file.publisher = form.publisher.data
+            has_changes = True
+        if corpus_file.publishing_year != form.publishing_year.data:
+            corpus_file.publishing_year = form.publishing_year.data
+            has_changes = True
+        if corpus_file.school != form.school.data:
+            corpus_file.school = form.school.data
+            has_changes = True
+        if corpus_file.title != form.title.data:
+            corpus_file.title = form.title.data
+            has_changes = True
+        if has_changes:
+            corpus_file.corpus.status = CorpusStatus.UNPREPARED
         db.session.commit()
-        flash(f'Corpus file "{corpus_file.filename}" edited', category='corpus')  # noqa
-        return redirect(url_for('.corpus', corpus_id=corpus_id))
-    # If no form is submitted or valid, fill out fields with current values
-    form.address.data = corpus_file.address
-    form.author.data = corpus_file.author
-    form.booktitle.data = corpus_file.booktitle
-    form.chapter.data = corpus_file.chapter
-    form.editor.data = corpus_file.editor
-    form.institution.data = corpus_file.institution
-    form.journal.data = corpus_file.journal
-    form.pages.data = corpus_file.pages
-    form.publisher.data = corpus_file.publisher
-    form.publishing_year.data = corpus_file.publishing_year
-    form.school.data = corpus_file.school
-    form.title.data = corpus_file.title
+        message = Markup(f'Corpus file "<a href="{corpus_file.url}">{corpus_file.filename}</a>" updated')
+        flash(message, category='corpus')
+        return redirect(corpus_file.corpus.url)
+    form.prefill(corpus_file)
     return render_template(
         'corpora/corpus_file.html.j2',
         corpus=corpus_file.corpus,
@@ -260,91 +221,52 @@ def corpus_file(corpus_id, corpus_file_id):
     )
 
 
-@bp.route('/<hashid:corpus_id>/files/add', methods=['GET', 'POST'])
-@login_required
-def add_corpus_file(corpus_id):
-    corpus = Corpus.query.get_or_404(corpus_id)
-    if not (corpus.user == current_user or current_user.is_administrator()):
-        abort(403)
-    form = AddCorpusFileForm(prefix='add-corpus-file-form')
-    if form.is_submitted():
-        if not form.validate():
-            return make_response(form.errors, 400)
-        # Save the file
-        corpus_file = CorpusFile(
-            address=form.address.data,
-            author=form.author.data,
-            booktitle=form.booktitle.data,
-            chapter=form.chapter.data,
-            corpus=corpus,
-            editor=form.editor.data,
-            filename=form.vrt.data.filename,
-            institution=form.institution.data,
-            journal=form.journal.data,
-            mimetype='application/vrt+xml',
-            pages=form.pages.data,
-            publisher=form.publisher.data,
-            publishing_year=form.publishing_year.data,
-            school=form.school.data,
-            title=form.title.data
-        )
-        db.session.add(corpus_file)
-        db.session.flush(objects=[corpus_file])
-        db.session.refresh(corpus_file)
-        try:
-            form.vrt.data.save(corpus_file.path)
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', category='error')
-            return make_response({'redirect_url': url_for('.add_corpus_file', corpus_id=corpus.id)}, 500)  # noqa
-        corpus.status = CorpusStatus.UNPREPARED
-        db.session.commit()
-        flash(f'Corpus file "{corpus_file.filename}" added', category='corpus')
-        return make_response({'redirect_url': url_for('.corpus', corpus_id=corpus.id)}, 201)  # noqa
-    return render_template(
-        'corpora/add_corpus_file.html.j2',
-        corpus=corpus,
-        form=form,
-        title='Add corpus file'
-    )
-
-
-@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/delete')
+@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
 @login_required
 def delete_corpus_file(corpus_id, corpus_file_id):
-    corpus_file = CorpusFile.query.filter(
-        CorpusFile.corpus_id == corpus_id,
-        CorpusFile.id == corpus_file_id
-    ).first_or_404()
-    if not (
-        corpus_file.corpus.user == current_user
-        or current_user.is_administrator()
-    ):
+    def _delete_corpus_file(app, corpus_file_id):
+        with app.app_context():
+            corpus_file = CorpusFile.query.get(corpus_file_id)
+            corpus_file.delete()
+            db.session.commit()
+
+    corpus_file = CorpusFile.query.get_or_404(corpus_file_id)
+    if corpus_file.corpus.id != corpus_id:
+        abort(404)
+    if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
         abort(403)
-    flash(
-        f'Corpus file "{corpus_file.filename}" marked for deletion',
-        category='corpus'
+    thread = Thread(
+        target=_delete_corpus_file,
+        args=(current_app._get_current_object(), corpus_file_id)
     )
-    tasks.delete_corpus_file(corpus_file_id)
-    return redirect(url_for('.corpus', corpus_id=corpus_id))
+    thread.start()
+    return {}, 202
 
 
 @bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
 @login_required
 def download_corpus_file(corpus_id, corpus_file_id):
-    corpus_file = CorpusFile.query.filter(
-        CorpusFile.corpus_id == corpus_id,
-        CorpusFile.id == corpus_file_id
-    ).first_or_404()
-    if not (
-        corpus_file.corpus.user == current_user
-        or current_user.is_administrator()
-    ):
+    corpus_file = CorpusFile.query.get_or_404(corpus_file_id)
+    if corpus_file.corpus.id != corpus_id:
+        abort(404)
+    if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
         abort(403)
     return send_from_directory(
+        os.path.dirname(corpus_file.path),
+        os.path.basename(corpus_file.path),
         as_attachment=True,
         attachment_filename=corpus_file.filename,
-        directory=os.path.dirname(corpus_file.path),
-        filename=os.path.basename(corpus_file.path)
+        mimetype=corpus_file.mimetype
     )
+
+
+@bp.route('/import', methods=['GET', 'POST'])
+@login_required
+def import_corpus():
+    abort(503)
+
+
+@bp.route('/<hashid:corpus_id>/export')
+@login_required
+def export_corpus(corpus_id):
+    abort(503)
diff --git a/app/corpora/tasks.py b/app/corpora/tasks.py
deleted file mode 100644
index c914a25a..00000000
--- a/app/corpora/tasks.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from app import db
-from app.decorators import background
-from app.models import Corpus, CorpusFile
-
-
-@background
-def build_corpus(corpus_id, *args, **kwargs):
-    app = kwargs['app']
-    with app.app_context():
-        corpus = Corpus.query.get(corpus_id)
-        if corpus is None:
-            raise Exception(f'Corpus {corpus_id} not found')
-        corpus.build()
-        db.session.commit()
-
-
-@background
-def delete_corpus(corpus_id, *args, **kwargs):
-    with kwargs['app'].app_context():
-        corpus = Corpus.query.get(corpus_id)
-        if corpus is None:
-            raise Exception(f'Corpus {corpus_id} not found')
-        corpus.delete()
-        db.session.commit()
-
-
-@background
-def delete_corpus_file(corpus_file_id, *args, **kwargs):
-    with kwargs['app'].app_context():
-        corpus_file = CorpusFile.query.get(corpus_file_id)
-        if corpus_file is None:
-            raise Exception(f'Corpus file {corpus_file_id} not found')
-        corpus_file.delete()
-        db.session.commit()
diff --git a/app/daemon/job_utils.py b/app/daemon/job_utils.py
index 02f6bb9e..38d6c48b 100644
--- a/app/daemon/job_utils.py
+++ b/app/daemon/job_utils.py
@@ -1,4 +1,4 @@
-from app import db, docker_client
+from app import db, docker_client, hashids
 from app.models import (
     Job,
     JobResult,
@@ -89,7 +89,14 @@ def _create_job_service(job):
         input_mount = f'{input_mount_source}:{input_mount_target}:ro'
         mounts.append(input_mount)
     if job.service == 'tesseract-ocr-pipeline':
-        model = TesseractOCRModel.query.get(job.service_args['model'])
+        if isinstance(job.service_args['model'], str):
+            model_id = hashids.decode(job.service_args['model'])
+        elif isinstance(job.service_args['model'], int):
+            model_id = job.service_args['model']
+        else:
+            job.status = JobStatus.FAILED
+            return
+        model = TesseractOCRModel.query.get(model_id)
         if model is None:
             job.status = JobStatus.FAILED
             return
diff --git a/app/errors/handlers.py b/app/errors/handlers.py
index a5e49f90..cc6c9268 100644
--- a/app/errors/handlers.py
+++ b/app/errors/handlers.py
@@ -1,52 +1,11 @@
-from flask import render_template, request, jsonify
+from flask import render_template, request
+from werkzeug.exceptions import HTTPException
 from . import bp
 
 
-@bp.app_errorhandler(403)
-def forbidden(e):
+@bp.errorhandler(HTTPException)
+def generic_error_handler(e):
     if (request.accept_mimetypes.accept_json
             and not request.accept_mimetypes.accept_html):
-        response = jsonify({'error': 'forbidden'})
-        response.status_code = 403
-        return response
-    return render_template('errors/403.html.j2', title='Forbidden'), 403
-
-
-@bp.app_errorhandler(404)
-def not_found(e):
-    if (request.accept_mimetypes.accept_json
-            and not request.accept_mimetypes.accept_html):
-        response = jsonify({'error': 'not found'})
-        response.status_code = 404
-        return response
-    return render_template('errors/404.html.j2', title='Not Found'), 404
-
-
-@bp.app_errorhandler(413)
-def payload_too_large(e):
-    if (request.accept_mimetypes.accept_json
-            and not request.accept_mimetypes.accept_html):
-        response = jsonify({'error': 'payload too large'})
-        response.status_code = 413
-        return response
-    return render_template('errors/413.html.j2', title='Payload Too Large'), 413
-
-
-@bp.app_errorhandler(500)
-def internal_server_error(e):
-    if (request.accept_mimetypes.accept_json
-            and not request.accept_mimetypes.accept_html):
-        response = jsonify({'error': 'internal server error'})
-        response.status_code = 500
-        return response
-    return render_template('errors/500.html.j2', title='Internal Server Error'), 500
-
-
-@bp.app_errorhandler(503)
-def service_unavailable_error(e):
-    if (request.accept_mimetypes.accept_json
-            and not request.accept_mimetypes.accept_html):
-        response = jsonify({'error': 'service unavailable'})
-        response.status_code = 503
-        return response
-    return render_template('errors/503.html.j2', title='Service Unavailable'), 503
+        return {'errors': {'message': e.description}}, e.code
+    return render_template('errors/error.html.j2', error=e), e.code
diff --git a/app/jobs/routes.py b/app/jobs/routes.py
index 8d78aa6b..7dae80e1 100644
--- a/app/jobs/routes.py
+++ b/app/jobs/routes.py
@@ -1,17 +1,16 @@
-from app.decorators import admin_required
-from app.models import Job, JobInput, JobResult, JobStatus
 from flask import (
     abort,
-    flash,
-    redirect,
+    current_app,
     render_template,
-    send_from_directory,
-    url_for
+    send_from_directory
 )
 from flask_login import current_user, login_required
-from . import bp
-from . import tasks
+from threading import Thread
 import os
+from app import db
+from app.decorators import admin_required
+from app.models import Job, JobInput, JobResult, JobStatus
+from . import bp
 
 
 @bp.route('/<hashid:job_id>')
@@ -27,35 +26,24 @@ def job(job_id):
     )
 
 
-@bp.route('/<hashid:job_id>/delete')
+@bp.route('/<hashid:job_id>', methods=['DELETE'])
 @login_required
 def delete_job(job_id):
+    def _delete_job(app, job_id):
+        with app.app_context():
+            job = Job.query.get(job_id)
+            job.delete()
+            db.session.commit()
+
     job = Job.query.get_or_404(job_id)
     if not (job.user == current_user or current_user.is_administrator()):
         abort(403)
-    tasks.delete_job(job_id)
-    flash(f'Job "{job.title}" marked for deletion', 'job')
-    return redirect(url_for('main.dashboard'))
-
-
-@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
-@login_required
-def download_job_input(job_id, job_input_id):
-    job_input = JobInput.query.filter(
-        JobInput.job_id == job_id,
-        JobInput.id == job_input_id
-    ).first_or_404()
-    if not (
-        job_input.job.user == current_user
-        or current_user.is_administrator()
-    ):
-        abort(403)
-    return send_from_directory(
-        as_attachment=True,
-        attachment_filename=job_input.filename,
-        directory=os.path.dirname(job_input.path),
-        filename=os.path.basename(job_input.path)
+    thread = Thread(
+        target=_delete_job,
+        args=(current_app._get_current_object(), job_id)
     )
+    thread.start()
+    return {}, 202
 
 
 @bp.route('/<hashid:job_id>/log')
@@ -64,48 +52,65 @@ def download_job_input(job_id, job_input_id):
 def job_log(job_id):
     job = Job.query.get_or_404(job_id)
     if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
-        flash(
-            f'Can\'t restart job "{job.title}": Status is not "Completed/Failed"',  # noqa
-            category='error'
-        )
-    return send_from_directory(
-        attachment_filename=f'job_{job.hashid}_log.txt',
-        directory=os.path.join(job.path, 'pipeline_data'),
-        filename=os.path.join('logs', 'pyflow_log.txt')
-    )
+        response = {'errors': {'message': 'Job status is not completed or failed'}}
+        return response, 409
+    with open(os.path.join(job.path, 'pipeline_data', 'logs', 'pyflow_log.txt')) as log_file:
+        log = log_file.read()
+    return log, 200, {'Content-Type': 'text/plain; charset=utf-8'}
 
 
-@bp.route('/<hashid:job_id>/restart')
+@bp.route('/<hashid:job_id>/restart', methods=['POST'])
 @login_required
-@admin_required
-def restart(job_id):
+def restart_job(job_id):
+    def _restart_job(app, job_id):
+        with app.app_context():
+            job = Job.query.get(job_id)
+            job.restart()
+            db.session.commit()
+
     job = Job.query.get_or_404(job_id)
-    if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
-        flash(
-            f'Can\'t restart job "{job.title}": Status is not "Completed/Failed"',  # noqa
-            category='error'
-        )
-    else:
-        tasks.restart_job(job_id)
-        flash(f'Job "{job.title}" marked to get restarted', category='job')
-    return redirect(url_for('.job', job_id=job_id))
+    if not (job.user == current_user or current_user.is_administrator()):
+        abort(403)
+    if job.status == JobStatus.FAILED:
+        response = {'errors': {'message': 'Job status is not "failed"'}}
+        return response, 409
+    thread = Thread(
+        target=_restart_job,
+        args=(current_app._get_current_object(), job_id)
+    )
+    thread.start()
+    return {}, 202
+
+
+@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
+@login_required
+def download_job_input(job_id, job_input_id):
+    job_input = JobInput.query.get_or_404(job_input_id)
+    if job_input.job.id != job_id:
+        abort(404)
+    if not (job_input.job.user == current_user or current_user.is_administrator()):
+        abort(403)
+    return send_from_directory(
+        os.path.dirname(job_input.path),
+        os.path.basename(job_input.path),
+        as_attachment=True,
+        attachment_filename=job_input.filename,
+        mimetype=job_input.mimetype
+    )
 
 
 @bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download')
 @login_required
 def download_job_result(job_id, job_result_id):
-    job_result = JobResult.query.filter(
-        JobResult.job_id == job_id,
-        JobResult.id == job_result_id
-    ).first_or_404()
-    if not (
-        job_result.job.user == current_user
-        or current_user.is_administrator()
-    ):
+    job_result = JobResult.query.get_or_404(job_result_id)
+    if job_result.job.id != job_id:
+        abort(404)
+    if not (job_result.job.user == current_user or current_user.is_administrator()):
         abort(403)
     return send_from_directory(
+        os.path.dirname(job_result.path),
+        os.path.basename(job_result.path),
         as_attachment=True,
         attachment_filename=job_result.filename,
-        directory=os.path.dirname(job_result.path),
-        filename=os.path.basename(job_result.path)
+        mimetype=job_result.mimetype
     )
diff --git a/app/jobs/tasks.py b/app/jobs/tasks.py
deleted file mode 100644
index 1738b0cd..00000000
--- a/app/jobs/tasks.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from app import db
-from app.decorators import background
-from app.models import Job
-
-
-@background
-def delete_job(job_id, *args, **kwargs):
-    with kwargs['app'].app_context():
-        job = Job.query.get(job_id)
-        if job is None:
-            raise Exception(f'Job {job_id} not found')
-        job.delete()
-        db.session.commit()
-
-
-@background
-def restart_job(job_id, *args, **kwargs):
-    with kwargs['app'].app_context():
-        job = Job.query.get(job_id)
-        if job is None:
-            raise Exception(f'Job {job_id} not found')
-        try:
-            job.restart()
-        except Exception:
-            pass
-        else:
-            db.session.commit()
diff --git a/app/main/routes.py b/app/main/routes.py
index cf87f0b5..1e7665a3 100644
--- a/app/main/routes.py
+++ b/app/main/routes.py
@@ -1,30 +1,27 @@
-from app.auth.forms import LoginForm
-from app.models import User
 from flask import flash, redirect, render_template, url_for
 from flask_login import login_required, login_user
+from app.auth.forms import LoginForm
+from app.models import User
 from . import bp
 
 
-@bp.route('/', methods=['GET', 'POST'])
+@bp.route('', methods=['GET', 'POST'])
 def index():
     form = LoginForm(prefix='login-form')
     if form.validate_on_submit():
-        user = User.query.filter_by(username=form.user.data).first()
-        if user is None:
-            user = User.query.filter_by(email=form.user.data.lower()).first()
-        if user is not None and user.verify_password(form.password.data):
+        user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
+        if user and user.verify_password(form.password.data):
             login_user(user, form.remember_me.data)
+            flash('You have been logged in')
             return redirect(url_for('.dashboard'))
-        flash('Invalid email/username or password.')
+        flash('Invalid email/username or password', category='error')
+        redirect(url_for('.index'))
     return render_template('main/index.html.j2', form=form, title='nopaque')
 
 
 @bp.route('/faq')
 def faq():
-    return render_template(
-        'main/faq.html.j2',
-        title='Frequently Asked Questions'
-    )
+    return render_template('main/faq.html.j2', title='Frequently Asked Questions')
 
 
 @bp.route('/dashboard')
@@ -45,10 +42,7 @@ def news():
 
 @bp.route('/privacy_policy')
 def privacy_policy():
-    return render_template(
-        'main/privacy_policy.html.j2',
-        title='Privacy statement (GDPR)'
-    )
+    return render_template('main/privacy_policy.html.j2', title='Privacy statement (GDPR)')
 
 
 @bp.route('/terms_of_use')
diff --git a/app/models.py b/app/models.py
index f65cf84f..9ae31b1c 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,6 +1,3 @@
-from app import db, hashids, login, mail, socketio
-from app.converters.vrt import normalize_vrt_file
-from app.email import create_message
 from datetime import datetime, timedelta
 from enum import Enum, IntEnum
 from flask import current_app, url_for
@@ -9,7 +6,7 @@ from flask_login import UserMixin
 from time import sleep
 from tqdm import tqdm
 from werkzeug.security import generate_password_hash, check_password_hash
-import base64
+from werkzeug.utils import secure_filename
 import json
 import jwt
 import os
@@ -17,6 +14,9 @@ import requests
 import shutil
 import xml.etree.ElementTree as ET
 import yaml
+from app import db, hashids, login, mail, socketio
+from app.converters.vrt import normalize_vrt_file
+from app.email import create_message
 
 
 TRANSKRIBUS_HTR_MODELS = \
@@ -77,14 +77,17 @@ class FileMixin:
     '''
     creation_date = db.Column(db.DateTime, default=datetime.utcnow)
     filename = db.Column(db.String(255))
-    last_edited_date = db.Column(db.DateTime, default=datetime.utcnow)
+    last_edited_date = db.Column(db.DateTime)
     mimetype = db.Column(db.String(255))
 
-    def file_mixin_to_dict(self, backrefs=False, relationships=False):
+    def file_mixin_to_json(self, backrefs=False, relationships=False):
         return {
-            'creation_date': self.creation_date.isoformat() + 'Z',
+            'creation_date': f'{self.creation_date.isoformat()}Z',
             'filename': self.filename,
-            'last_edited_date': self.last_edited_date.isoformat() + 'Z',
+            'last_edited_date': (
+                None if self.last_edited_date is None
+                else f'{self.last_edited_date.isoformat()}Z'
+            ),
             'mimetype': self.mimetype
         }
 # endregion mixins
@@ -123,10 +126,8 @@ class ContainerColumn(db.TypeDecorator):
     def process_bind_param(self, value, dialect):
         if isinstance(value, self.container_type):
             return json.dumps(value)
-        elif (
-            isinstance(value, str)
-            and isinstance(json.loads(value), self.container_type)
-        ):
+        elif (isinstance(value, str)
+                  and isinstance(json.loads(value), self.container_type)):
             return value
         else:
             return TypeError()
@@ -145,17 +146,12 @@ class Role(HashidMixin, db.Model):
     # Primary key
     id = db.Column(db.Integer, primary_key=True)
     # Fields
-    default = db.Column(db.Boolean, default=False, index=True)
     name = db.Column(db.String(64), unique=True)
-    permissions = db.Column(db.Integer)
+    default = db.Column(db.Boolean, default=False, index=True)
+    permissions = db.Column(db.Integer, default=0)
     # Relationships
     users = db.relationship('User', backref='role', lazy='dynamic')
 
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
-        if self.permissions is None:
-            self.permissions = 0
-
     def __repr__(self):
         return f'<Role {self.name}>'
 
@@ -173,19 +169,19 @@ class Role(HashidMixin, db.Model):
     def reset_permissions(self):
         self.permissions = 0
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_role = {
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
             'id': self.hashid,
             'default': self.default,
             'name': self.name,
             'permissions': self.permissions
         }
         if relationships:
-            dict_role['users'] = {
-                x.hashid: x.to_dict(backrefs=False, relationships=True)
+            _json['users'] = {
+                x.hashid: x.to_json(relationships=True)
                 for x in self.users
             }
-        return dict_role
+        return _json
 
     @staticmethod
     def insert_defaults():
@@ -197,7 +193,8 @@ class Role(HashidMixin, db.Model):
                 Permission.ADMINISTRATE,
                 Permission.CONTRIBUTE,
                 Permission.USE_API
-            ]
+            ],
+            'System user': []
         }
         default_role_name = 'User'
         for role_name, permissions in roles.items():
@@ -212,29 +209,6 @@ class Role(HashidMixin, db.Model):
         db.session.commit()
 
 
-class Token(db.Model):
-    __tablename__ = 'tokens'
-    # Primary key
-    id = db.Column(db.Integer, primary_key=True)
-    # Foreign keys
-    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
-    # Fields
-    access_token = db.Column(db.String(64), nullable=False, index=True)
-    access_expiration = db.Column(db.DateTime, nullable=False)
-    refresh_token = db.Column(db.String(64), nullable=False, index=True)
-    refresh_expiration = db.Column(db.DateTime, nullable=False)
-
-    # def generate(self):
-    #     header = {'alg': 'HS256', 'exp': int(time.time()) + expiration}
-    #     payload = {'confirm': self.hashid}
-    #     return jwt.encode(header, payload, current_app.config['SECRET_KEY'])
-    #     self.access_token = secrets.token_urlsafe()
-    #     self.access_expiration = datetime.utcnow() + \
-    #         timedelta(minutes=current_app.config['ACCESS_TOKEN_MINUTES'])
-    #     self.refresh_token = secrets.token_urlsafe()
-    #     self.refresh_expiration = datetime.utcnow() + \
-    #         timedelta(days=current_app.config['REFRESH_TOKEN_DAYS'])
-
 class User(HashidMixin, UserMixin, db.Model):
     __tablename__ = 'users'
     # Primary key
@@ -242,19 +216,17 @@ class User(HashidMixin, UserMixin, db.Model):
     # Foreign keys
     role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
     # Fields
+    email = db.Column(db.String(254), index=True, unique=True)
+    username = db.Column(db.String(64), index=True, unique=True)
+    password_hash = db.Column(db.String(128))
     confirmed = db.Column(db.Boolean, default=False)
-    email = db.Column(db.String(254), unique=True, index=True)
-    last_seen = db.Column(db.DateTime(), default=datetime.utcnow)
     member_since = db.Column(db.DateTime(), default=datetime.utcnow)
-    password_hash = db.Column(db.String(128))
-    token = db.Column(db.String(32), index=True, unique=True)
-    token_expiration = db.Column(db.DateTime)
-    username = db.Column(db.String(64), unique=True, index=True)
     setting_dark_mode = db.Column(db.Boolean, default=False)
     setting_job_status_mail_notification_level = db.Column(
         IntEnumColumn(UserSettingJobStatusMailNotificationLevel),
         default=UserSettingJobStatusMailNotificationLevel.END
     )
+    last_seen = db.Column(db.DateTime())
     # Backrefs: role: Role
     # Relationships
     tesseract_ocr_models = db.relationship(
@@ -311,21 +283,76 @@ class User(HashidMixin, UserMixin, db.Model):
         return os.path.join(
             current_app.config.get('NOPAQUE_DATA_DIR'), 'users', str(self.id))
 
+    @staticmethod
+    def create(**kwargs):
+        user = User(**kwargs)
+        db.session.add(user)
+        db.session.flush(objects=[user])
+        db.session.refresh(user)
+        try:
+            os.mkdir(user.path)
+            os.mkdir(os.path.join(user.path, 'tesseract_ocr_models'))
+            os.mkdir(os.path.join(user.path, 'corpora'))
+            os.mkdir(os.path.join(user.path, 'jobs'))
+        except OSError as e:
+            current_app.logger.error(e)
+            db.session.rollback()
+            raise e
+        return user
+
+    @staticmethod
+    def insert_defaults():
+        nopaque_user = User.query.filter_by(username='nopaque').first()
+        system_user_role = Role.query.filter_by(name='System user').first()
+        if nopaque_user is None:
+            nopaque_user = User.create(
+                username='nopaque',
+                role=system_user_role
+            )
+            db.session.add(nopaque_user)
+        elif nopaque_user.role != system_user_role:
+            nopaque_user.role = system_user_role
+        db.session.commit()
+
+    @staticmethod
+    def reset_password(token, new_password):
+        try:
+            payload = jwt.decode(
+                token,
+                current_app.config['SECRET_KEY'],
+                algorithms=['HS256'],
+                issuer=current_app.config['SERVER_NAME'],
+                options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
+            )
+        except jwt.PyJWTError:
+            return False
+        if payload.get('purpose') != 'User.reset_password':
+            return False
+        user_hashid = payload.get('sub')
+        user_id = hashids.decode(user_hashid)
+        user = User.query.get(user_id)
+        if user is None:
+            return False
+        user.password = new_password
+        db.session.add(user)
+        return True
+
     def can(self, permission):
         return self.role.has_permission(permission)
 
-    def confirm_user(self, token):
+    def confirm(self, confirmation_token):
         try:
             payload = jwt.decode(
-                token,
+                confirmation_token,
                 current_app.config['SECRET_KEY'],
                 algorithms=['HS256'],
                 issuer=current_app.config['SERVER_NAME'],
                 options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
             )
+            current_app.logger.warning(payload)
         except jwt.PyJWTError:
             return False
-        if payload.get('purpose') != 'confirm_user':
+        if payload.get('purpose') != 'user.confirm':
             return False
         if payload.get('sub') != self.hashid:
             return False
@@ -337,132 +364,80 @@ class User(HashidMixin, UserMixin, db.Model):
         shutil.rmtree(self.path, ignore_errors=True)
         db.session.delete(self)
 
-    def generate_confirm_user_token(self, expiration=3600):
-        utc_now = datetime.utcnow()
+    def generate_confirm_token(self, expiration=3600):
+        now = datetime.utcnow()
         payload = {
-            'exp': utc_now + timedelta(seconds=expiration),
-            'iat': utc_now,
+            'exp': now + timedelta(seconds=expiration),
+            'iat': now,
             'iss': current_app.config['SERVER_NAME'],
-            'purpose': 'confirm_user',
+            'purpose': 'user.confirm',
             'sub': self.hashid
         }
-        return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
+        return jwt.encode(
+            payload,
+            current_app.config['SECRET_KEY'],
+            algorithm='HS256'
+        )
 
-    def generate_password_reset_token(self, expiration=3600):
-        utc_now = datetime.utcnow()
+    def generate_reset_password_token(self, expiration=3600):
+        now = datetime.utcnow()
         payload = {
-            'exp': utc_now + timedelta(seconds=expiration),
-            'iat': utc_now,
+            'exp': now + timedelta(seconds=expiration),
+            'iat': now,
             'iss': current_app.config['SERVER_NAME'],
-            'purpose': 'reset_password',
+            'purpose': 'User.reset_password',
             'sub': self.hashid
         }
-        return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
-
-    def get_token(self, expires_in=3600):
-        now = datetime.utcnow()
-        if self.token and self.token_expiration > now + timedelta(seconds=60):
-            return self.token
-        self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
-        self.token_expiration = now + timedelta(seconds=expires_in)
-        db.session.add(self)
-        return self.token
+        return jwt.encode(
+            payload,
+            current_app.config['SECRET_KEY'],
+            algorithm='HS256'
+        )
 
     def is_administrator(self):
         return self.can(Permission.ADMINISTRATE)
 
-    def makedirs(self):
-        os.mkdir(self.path)
-        os.mkdir(os.path.join(self.path, 'tesseract_ocr_models'))
-        os.mkdir(os.path.join(self.path, 'corpora'))
-        os.mkdir(os.path.join(self.path, 'jobs'))
+    def ping(self):
+        self.last_seen = datetime.utcnow()
 
-    def revoke_token(self):
-        self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
+    def verify_password(self, password):
+        if self.role.name == 'System user':
+            return False
+        return check_password_hash(self.password_hash, password)
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_user = {
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
             'id': self.hashid,
-            'role_id': self.role.hashid,
             'confirmed': self.confirmed,
             'email': self.email,
-            'last_seen': self.last_seen.isoformat() + 'Z',
-            'member_since': self.member_since.isoformat() + 'Z',
+            'last_seen': (
+                None if self.last_seen is None
+                else f'{self.last_seen.isoformat()}Z'
+            ),
+            'member_since': f'{self.member_since.isoformat()}Z',
             'username': self.username,
             'settings': {
                 'dark_mode': self.setting_dark_mode,
-                'job_status_mail_notification_level':
+                'job_status_mail_notification_level': \
                     self.setting_job_status_mail_notification_level.name
             }
         }
         if backrefs:
-            dict_user['role'] = self.role.to_dict(
-                backrefs=True, relationships=False)
+            _json['role'] = self.role.to_json(backrefs=True)
         if relationships:
-            dict_user['corpora'] = {
-                x.hashid: x.to_dict(backrefs=False, relationships=True)
+            _json['corpora'] = {
+                x.hashid: x.to_json(relationships=True)
                 for x in self.corpora
             }
-            dict_user['jobs'] = {
-                x.hashid: x.to_dict(backrefs=False, relationships=True)
+            _json['jobs'] = {
+                x.hashid: x.to_json(relationships=True)
                 for x in self.jobs
             }
-            dict_user['tesseract_ocr_models'] = {
-                x.hashid: x.to_dict(backrefs=False, relationships=True)
+            _json['tesseract_ocr_models'] = {
+                x.hashid: x.to_json(relationships=True)
                 for x in self.tesseract_ocr_models
             }
-        return dict_user
-
-    def verify_password(self, password):
-        return check_password_hash(self.password_hash, password)
-
-    @staticmethod
-    def check_token(token):
-        user = User.query.filter_by(token=token).first()
-        if user is None or user.token_expiration < datetime.utcnow():
-            return None
-        return user
-
-    @staticmethod
-    def insert_defaults():
-        if User.query.filter_by(username='nopaque').first() is not None:
-            return
-        user = User(username='nopaque')
-        db.session.add(user)
-        db.session.flush(objects=[user])
-        db.session.refresh(user)
-        try:
-            user.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-        db.session.commit()
-
-    @staticmethod
-    def reset_password(token, new_password):
-        try:
-            payload = jwt.decode(
-                token,
-                current_app.config['SECRET_KEY'],
-                algorithms=['HS256'],
-                issuer=current_app.config['SERVER_NAME'],
-                options={'require': ['exp', 'iat', 'iss', 'purpose', 'sub']}
-            )
-        except jwt.PyJWTError:
-            return False
-        if payload.get('purpose') != 'reset_password':
-            return False
-        user_hashid = payload.get('sub')
-        if user_hashid is None:
-            return False
-        user_id = hashids.decode(user_hashid)
-        user = User.query.get(user_id)
-        if user is None:
-            return False
-        user.password = new_password
-        db.session.add(user)
-        return True
-
+        return _json
 
 class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
     __tablename__ = 'tesseract_ocr_models'
@@ -471,15 +446,15 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
     # Foreign keys
     user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
     # Fields
-    compatible_service_versions = db.Column(ContainerColumn(list, 255))
+    title = db.Column(db.String(64))
     description = db.Column(db.String(255))
+    version = db.Column(db.String(16))
+    compatible_service_versions = db.Column(ContainerColumn(list, 255))
     publisher = db.Column(db.String(128))
     publisher_url = db.Column(db.String(512))
     publishing_url = db.Column(db.String(512))
     publishing_year = db.Column(db.Integer)
     shared = db.Column(db.Boolean, default=False)
-    title = db.Column(db.String(64))
-    version = db.Column(db.String(16))
     # Backrefs: user: User
 
     @property
@@ -490,30 +465,9 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
             str(self.id)
         )
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_tesseract_ocr_model = {
-            'id': self.hashid,
-            'user_id': self.user.hashid,
-            'compatible_service_versions': self.compatible_service_versions,
-            'description': self.description,
-            'publisher': self.publisher,
-            'publisher_url': self.publisher_url,
-            'publishing_url': self.publishing_url,
-            'publishing_year': self.publishing_year,
-            'shared': self.shared,
-            'title': self.title,
-            **self.file_mixin_to_dict()
-        }
-        if backrefs:
-            dict_tesseract_ocr_model['user'] = self.user.to_dict(
-                backrefs=True, relationships=False)
-        if relationships:
-            pass
-        return dict_tesseract_ocr_model
-
     @staticmethod
     def insert_defaults():
-        user = User.query.filter_by(username='nopaque').first()
+        nopaque_user = User.query.filter_by(username='nopaque').first()
         defaults_file = os.path.join(
             os.path.dirname(os.path.abspath(__file__)),
             'TesseractOCRModel.defaults.yml'
@@ -542,7 +496,7 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
                 publishing_year=m['publishing_year'],
                 shared=True,
                 title=m['title'],
-                user=user,
+                user=nopaque_user,
                 version=m['version']
             )
             db.session.add(model)
@@ -566,6 +520,23 @@ class TesseractOCRModel(FileMixin, HashidMixin, db.Model):
                 pbar.close()
         db.session.commit()
 
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
+            'id': self.hashid,
+            'compatible_service_versions': self.compatible_service_versions,
+            'description': self.description,
+            'publisher': self.publisher,
+            'publisher_url': self.publisher_url,
+            'publishing_url': self.publishing_url,
+            'publishing_year': self.publishing_year,
+            'shared': self.shared,
+            'title': self.title,
+            **self.file_mixin_to_json()
+        }
+        if backrefs:
+            _json['user'] = self.user.to_json(backrefs=True)
+        return _json
+
 
 class TranskribusHTRModel(HashidMixin, db.Model):
     __tablename__ = 'transkribus_htr_models'
@@ -579,23 +550,9 @@ class TranskribusHTRModel(HashidMixin, db.Model):
     transkribus_name = db.Column(db.String(64))
     # Backrefs: user: User
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_tesseract_ocr_model = {
-            'id': self.hashid,
-            'user_id': self.user.hashid,
-            'shared': self.shared,
-            'transkribus_model_id': self.transkribus_model_id,
-        }
-        if backrefs:
-            dict_tesseract_ocr_model['user'] = \
-                self.user.to_dict(backrefs=True, relationships=False)
-        if relationships:
-            pass
-        return dict_tesseract_ocr_model
-
     @staticmethod
     def insert_defaults():
-        user = User.query.filter_by(username='nopaque').first()
+        nopaque_user = User.query.filter_by(username='nopaque').first()
         # models = [
         #     m for m in TRANSKRIBUS_HTR_MODELS if True
         #     and 'creator' in m and m['creator'] == 'Transkribus Team'
@@ -610,11 +567,22 @@ class TranskribusHTRModel(HashidMixin, db.Model):
             model = TranskribusHTRModel(
                 shared=True,
                 transkribus_model_id=m['modelId'],
-                user=user,
+                user=nopaque_user,
             )
             db.session.add(model)
         db.session.commit()
 
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
+            'id': self.hashid,
+            'user_id': self.user.hashid,
+            'shared': self.shared,
+            'transkribus_model_id': self.transkribus_model_id,
+        }
+        if backrefs:
+            _json['user'] = self.user.to_json(backrefs=True)
+        return _json
+
 
 class JobInput(FileMixin, HashidMixin, db.Model):
     __tablename__ = 'job_inputs'
@@ -628,7 +596,7 @@ class JobInput(FileMixin, HashidMixin, db.Model):
         return f'<JobInput {self.filename}>'
 
     @property
-    def download_url(self):
+    def content_url(self):
         return url_for(
             'jobs.download_job_input',
             job_id=self.job.id,
@@ -643,19 +611,6 @@ class JobInput(FileMixin, HashidMixin, db.Model):
     def path(self):
         return os.path.join(self.job.path, 'inputs', str(self.id))
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_job_input = {
-            'id': self.hashid,
-            'job_id': self.job.hashid,
-            'download_url': self.download_url,
-            'url': self.url,
-            **self.file_mixin_to_dict()
-        }
-        if backrefs:
-            dict_job_input['job'] = self.job.to_dict(
-                backrefs=True, relationships=False)
-        return dict_job_input
-
     @property
     def url(self):
         return url_for(
@@ -672,6 +627,35 @@ class JobInput(FileMixin, HashidMixin, db.Model):
     def user_id(self):
         return self.job.user_id
 
+    @staticmethod
+    def create(input_file, **kwargs):
+        filename = kwargs.get('filename', input_file.filename)
+        mimetype = kwargs.get('mimetype', input_file.mimetype)
+        job_input = JobInput(
+            filename=secure_filename(filename),
+            mimetype=mimetype,
+            **kwargs
+        )
+        db.session.add(job_input)
+        db.session.flush(objects=[job_input])
+        db.session.refresh(job_input)
+        try:
+            input_file.save(job_input.path)
+        except OSError as e:
+            current_app.logger.error(e)
+            db.session.rollback()
+            raise e
+        return job_input
+
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
+            'id': self.hashid,
+            **self.file_mixin_to_json()
+        }
+        if backrefs:
+            _json['job'] = self.job.to_json(backrefs=True)
+        return _json
+
 
 class JobResult(FileMixin, HashidMixin, db.Model):
     __tablename__ = 'job_results'
@@ -702,21 +686,6 @@ class JobResult(FileMixin, HashidMixin, db.Model):
     def path(self):
         return os.path.join(self.job.path, 'results', str(self.id))
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_job_result = {
-            'id': self.hashid,
-            'job_id': self.job.hashid,
-            'description': self.description,
-            'download_url': self.download_url,
-            'url': self.url,
-            **self.file_mixin_to_dict(
-                backrefs=backrefs, relationships=relationships)
-        }
-        if backrefs:
-            dict_job_result['job'] = self.job.to_dict(
-                backrefs=True, relationships=False)
-        return dict_job_result
-
     @property
     def url(self):
         return url_for(
@@ -733,6 +702,39 @@ class JobResult(FileMixin, HashidMixin, db.Model):
     def user_id(self):
         return self.job.user_id
 
+    @staticmethod
+    def create(input_file, **kwargs):
+        filename = kwargs.get('filename', input_file.filename)
+        mimetype = kwargs.get('mimetype', input_file.mimetype)
+        job_result = JobResult(
+            filename=secure_filename(filename),
+            mimetype=mimetype,
+            **kwargs
+        )
+        db.session.add(job_result)
+        db.session.flush(objects=[job_result])
+        db.session.refresh(job_result)
+        try:
+            input_file.save(job_result.path)
+        except OSError as e:
+            current_app.logger.error(e)
+            db.session.rollback()
+            raise e
+        return job_result
+
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
+            'id': self.hashid,
+            'description': self.description,
+            **self.file_mixin_to_json(
+                backrefs=backrefs,
+                relationships=relationships
+            )
+        }
+        if backrefs:
+            _json['job'] = self.job.to_json(backrefs=True)
+        return _json
+
 
 class Job(HashidMixin, db.Model):
     '''
@@ -744,7 +746,8 @@ class Job(HashidMixin, db.Model):
     # Foreign keys
     user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
     # Fields
-    creation_date = db.Column(db.DateTime(), default=datetime.utcnow)
+    creation_date = \
+        db.Column(db.DateTime(), default=datetime.utcnow)
     description = db.Column(db.String(255))
     end_date = db.Column(db.DateTime())
     service = db.Column(db.String(64))
@@ -789,10 +792,26 @@ class Job(HashidMixin, db.Model):
     def user_hashid(self):
         return self.user.hashid
 
+    @staticmethod
+    def create(**kwargs):
+        job = Job(**kwargs)
+        db.session.add(job)
+        db.session.flush(objects=[job])
+        db.session.refresh(job)
+        try:
+            os.mkdir(job.path)
+            os.mkdir(os.path.join(job.path, 'inputs'))
+            os.mkdir(os.path.join(job.path, 'pipeline_data'))
+            os.mkdir(os.path.join(job.path, 'results'))
+        except OSError as e:
+            current_app.logger.error(e)
+            db.session.rollback()
+            raise e
+        return job
+
+
     def delete(self):
-        '''
-        Delete the job and its inputs and results from the database.
-        '''
+        ''' Delete the job and its inputs and results from the database. '''
         if self.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:  # noqa
             self.status = JobStatus.CANCELING
             db.session.commit()
@@ -803,36 +822,34 @@ class Job(HashidMixin, db.Model):
                     db.session.commit()
                 sleep(1)
                 db.session.refresh(self)
-        shutil.rmtree(self.path, ignore_errors=True)
+        try:
+            shutil.rmtree(self.path)
+        except OSError as e:
+            current_app.logger.error(e)
+            db.session.rollback()
+            raise e
         db.session.delete(self)
 
-    def makedirs(self):
-        os.mkdir(self.path)
-        os.mkdir(os.path.join(self.path, 'inputs'))
-        os.mkdir(os.path.join(self.path, 'pipeline_data'))
-        os.mkdir(os.path.join(self.path, 'results'))
-
     def restart(self):
-        '''
-        Restart a job - only if the status is complete or failed
-        '''
-
-        if self.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:  # noqa
-            raise Exception('Could not restart job: status is not "completed/failed"')  # noqa
+        ''' Restart a job - only if the status is failed '''
+        if self.status != JobStatus.FAILED:
+            raise Exception('Job status is not "failed"')
         shutil.rmtree(os.path.join(self.path, 'results'), ignore_errors=True)
-        shutil.rmtree(os.path.join(self.path, 'pyflow.data'), ignore_errors=True)  # noqa
+        shutil.rmtree(os.path.join(self.path, 'pyflow.data'), ignore_errors=True)
         for result in self.results:
             db.session.delete(result)
         self.end_date = None
         self.status = JobStatus.SUBMITTED
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_job = {
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
             'id': self.hashid,
-            'user_id': self.user.hashid,
-            'creation_date': self.creation_date.isoformat() + 'Z',
+            'creation_date': f'{self.creation_date.isoformat()}Z',
             'description': self.description,
-            'end_date': None if self.end_date is None else f'{self.end_date.isoformat()}Z',  # noqa
+            'end_date': (
+                None if self.end_date is None
+                else f'{self.end_date.isoformat()}Z'
+            ),
             'service': self.service,
             'service_args': self.service_args,
             'service_version': self.service_version,
@@ -841,18 +858,17 @@ class Job(HashidMixin, db.Model):
             'url': self.url
         }
         if backrefs:
-            dict_job['user'] = self.user.to_dict(
-                backrefs=True, relationships=False)
+            _json['user'] = self.user.to_json(backrefs=True)
         if relationships:
-            dict_job['inputs'] = {
-                x.hashid: x.to_dict(backrefs=False, relationships=True)
+            _json['inputs'] = {
+                x.hashid: x.to_json(relationships=True)
                 for x in self.inputs
             }
-            dict_job['results'] = {
-                x.hashid: x.to_dict(backrefs=False, relationships=True)
+            _json['results'] = {
+                x.hashid: x.to_json(relationships=True)
                 for x in self.results
             }
-        return dict_job
+        return _json
 
 
 class CorpusFile(FileMixin, HashidMixin, db.Model):
@@ -862,8 +878,10 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
     # Foreign keys
     corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id'))
     # Fields
-    address = db.Column(db.String(255))
     author = db.Column(db.String(255))
+    publishing_year = db.Column(db.Integer)
+    title = db.Column(db.String(255))
+    address = db.Column(db.String(255))
     booktitle = db.Column(db.String(255))
     chapter = db.Column(db.String(255))
     editor = db.Column(db.String(255))
@@ -871,9 +889,7 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
     journal = db.Column(db.String(255))
     pages = db.Column(db.String(255))
     publisher = db.Column(db.String(255))
-    publishing_year = db.Column(db.Integer)
     school = db.Column(db.String(255))
-    title = db.Column(db.String(255))
     # Backrefs: corpus: Corpus
 
     @property
@@ -919,11 +935,9 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
         db.session.delete(self)
         self.corpus.status = CorpusStatus.UNPREPARED
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_corpus_file = {
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
             'id': self.hashid,
-            'corpus_id': self.corpus.hashid,
-            'download_url': self.download_url,
             'url': self.url,
             'address': self.address,
             'author': self.author,
@@ -937,14 +951,34 @@ class CorpusFile(FileMixin, HashidMixin, db.Model):
             'publishing_year': self.publishing_year,
             'school': self.school,
             'title': self.title,
-            **self.file_mixin_to_dict(
-                backrefs=backrefs, relationships=relationships)
+            **self.file_mixin_to_json(
+                backrefs=backrefs,
+                relationships=relationships
+            )
         }
         if backrefs:
-            dict_corpus_file['corpus'] = self.corpus.to_dict(
-                backrefs=True, relationships=False)
-        return dict_corpus_file
+            _json['corpus'] = self.corpus.to_json(backrefs=True)
+        return _json
 
+    @staticmethod
+    def create(input_file, **kwargs):
+        filename = kwargs.pop('filename', input_file.filename)
+        mimetype = kwargs.pop('mimetype', input_file.mimetype)
+        corpus_file = CorpusFile(
+            filename=secure_filename(filename),
+            mimetype=mimetype,
+            **kwargs,
+        )
+        db.session.add(corpus_file)
+        db.session.flush(objects=[corpus_file])
+        db.session.refresh(corpus_file)
+        try:
+            input_file.save(corpus_file.path)
+        except OSError as e:
+            current_app.logger.error(e)
+            db.session.rollback()
+            raise e
+        return corpus_file
 
 class Corpus(HashidMixin, db.Model):
     '''
@@ -958,7 +992,7 @@ class Corpus(HashidMixin, db.Model):
     # Fields
     creation_date = db.Column(db.DateTime(), default=datetime.utcnow)
     description = db.Column(db.String(255))
-    last_edited_date = db.Column(db.DateTime(), default=datetime.utcnow)
+    last_edited_date = db.Column(db.DateTime())
     status = db.Column(
         IntEnumColumn(CorpusStatus),
         default=CorpusStatus.UNPREPARED
@@ -1000,6 +1034,24 @@ class Corpus(HashidMixin, db.Model):
     def user_hashid(self):
         return self.user.hashid
 
+    @staticmethod
+    def create(**kwargs):
+        corpus = Corpus(**kwargs)
+        db.session.add(corpus)
+        db.session.flush(objects=[corpus])
+        db.session.refresh(corpus)
+        try:
+            os.mkdir(corpus.path)
+            os.mkdir(os.path.join(corpus.path, 'files'))
+            os.mkdir(os.path.join(corpus.path, 'cwb'))
+            os.mkdir(os.path.join(corpus.path, 'cwb', 'data'))
+            os.mkdir(os.path.join(corpus.path, 'cwb', 'registry'))
+        except OSError as e:
+            current_app.logger.error(e)
+            db.session.rollback()
+            raise e
+        return corpus
+
     def build(self):
         corpus_element = ET.fromstring('<corpus>\n</corpus>')
         for corpus_file in self.files:
@@ -1011,18 +1063,21 @@ class Corpus(HashidMixin, db.Model):
                 return
             element_tree = ET.parse(normalized_vrt_path)
             text_element = element_tree.getroot()
-            text_element.set('address', corpus_file.address or 'NULL')
             text_element.set('author', corpus_file.author)
+            text_element.set('title', corpus_file.title)
+            text_element.set(
+                'publishing_year',
+                f'{corpus_file.publishing_year}'
+            )
+            text_element.set('address', corpus_file.address or 'NULL')
             text_element.set('booktitle', corpus_file.booktitle or 'NULL')
             text_element.set('chapter', corpus_file.chapter or 'NULL')
             text_element.set('editor', corpus_file.editor or 'NULL')
             text_element.set('institution', corpus_file.institution or 'NULL')
             text_element.set('journal', corpus_file.journal or 'NULL')
-            text_element.set('pages', corpus_file.pages or 'NULL')
+            text_element.set('pages', f'{corpus_file.pages}' or 'NULL')
             text_element.set('publisher', corpus_file.publisher or 'NULL')
-            text_element.set('publishing_year', str(corpus_file.publishing_year))  # noqa
             text_element.set('school', corpus_file.school or 'NULL')
-            text_element.set('title', corpus_file.title)
             text_element.tail = '\n'
             # corpus_element.insert(1, text_element)
             corpus_element.append(text_element)
@@ -1037,39 +1092,29 @@ class Corpus(HashidMixin, db.Model):
         shutil.rmtree(self.path, ignore_errors=True)
         db.session.delete(self)
 
-    def makedirs(self):
-        os.mkdir(self.path)
-        os.mkdir(os.path.join(self.path, 'files'))
-        os.mkdir(os.path.join(self.path, 'cwb'))
-        os.mkdir(os.path.join(self.path, 'cwb', 'data'))
-        os.mkdir(os.path.join(self.path, 'cwb', 'registry'))
-
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_corpus = {
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
             'id': self.hashid,
-            'user_id': self.user.hashid,
-            'analysis_url': self.analysis_url,
-            'url': self.url,
-            'creation_date': self.creation_date.isoformat() + 'Z',
+            'creation_date': f'{self.creation_date.isoformat()}Z',
             'description': self.description,
             'max_num_tokens': self.max_num_tokens,
             'num_analysis_sessions': self.num_analysis_sessions,
             'num_tokens': self.num_tokens,
             'status': self.status.name,
-            'last_edited_date': self.last_edited_date.isoformat() + 'Z',
+            'last_edited_date': (
+                None if self.last_edited_date is None
+                else f'{self.last_edited_date.isoformat()}Z'
+            ),
             'title': self.title
         }
         if backrefs:
-            dict_corpus['user'] = self.user.to_dict(
-                backrefs=True,
-                relationships=False
-            )
+            _json['user'] = self.user.to_json(backrefs=True)
         if relationships:
-            dict_corpus['files'] = {
-                x.hashid: x.to_dict(backrefs=False, relationships=True)
+            _json['files'] = {
+                x.hashid: x.to_json(relationships=True)
                 for x in self.files
             }
-        return dict_corpus
+        return _json
 # endregion models
 
 
@@ -1077,6 +1122,8 @@ class Corpus(HashidMixin, db.Model):
 # event_handlers                                                             #
 ##############################################################################
 # region event_handlers
+
+
 @db.event.listens_for(Corpus, 'after_delete')
 @db.event.listens_for(CorpusFile, 'after_delete')
 @db.event.listens_for(Job, 'after_delete')
@@ -1096,7 +1143,7 @@ def ressource_after_delete(mapper, connection, ressource):
 @db.event.listens_for(JobInput, 'after_insert')
 @db.event.listens_for(JobResult, 'after_insert')
 def ressource_after_insert_handler(mapper, connection, ressource):
-    value = ressource.to_dict(backrefs=False, relationships=False)
+    value = ressource.to_json()
     for attr in mapper.relationships:
         value[attr.key] = {}
     jsonpatch = [
@@ -1119,7 +1166,7 @@ def ressource_after_update_handler(mapper, connection, ressource):
         if not attr.load_history().has_changes():
             continue
         if isinstance(attr.value, datetime):
-            value = attr.value.isoformat() + 'Z'
+            value = f'{attr.value.isoformat()}Z'
         elif isinstance(attr.value, Enum):
             value = attr.value.name
         else:
diff --git a/app/query_results_models.py b/app/query_results_models.py
index 102d2825..132a4cc3 100644
--- a/app/query_results_models.py
+++ b/app/query_results_models.py
@@ -42,21 +42,17 @@ class QueryResult(FileMixin, HashidMixin, db.Model):
         shutil.rmtree(self.path, ignore_errors=True)
         db.session.delete(self)
 
-    def to_dict(self, backrefs=False, relationships=False):
-        dict_query_result = {
+    def to_json(self, backrefs=False, relationships=False):
+        _json = {
             'id': self.hashid,
-            'user_id': self.user.hashid,
-            'download_url': self.download_url,
-            'url': self.url,
             'corpus_title': self.query_metadata['corpus_name'],
             'description': self.description,
             'filename': self.filename,
             'query': self.query_metadata['query'],
             'query_metadata': self.query_metadata,
             'title': self.title,
-            **self.file_mixin_to_dict(
+            **self.file_mixin_to_json(
                 backrefs=backrefs, relationships=relationships)
         }
         if backrefs:
-            dict_query_result['user'] = self.user.to_dict(
-                backrefs=True, relationships=False)
+            _json['user'] = self.user.to_json(backrefs=True, relationships=False)
diff --git a/app/services/forms.py b/app/services/forms.py
index 8261d2ab..106c0f7f 100644
--- a/app/services/forms.py
+++ b/app/services/forms.py
@@ -1,4 +1,3 @@
-from app.models import TesseractOCRModel, TranskribusHTRModel
 from flask_login import current_user
 from flask_wtf import FlaskForm
 from flask_wtf.file import FileField, FileRequired
@@ -10,19 +9,26 @@ from wtforms import (
     SubmitField,
     ValidationError
 )
-from wtforms.validators import DataRequired, InputRequired, Length
+from wtforms.validators import InputRequired, Length
+from app.models import TesseractOCRModel, TranskribusHTRModel
 from . import SERVICES
 
 
-class AddJobForm(FlaskForm):
-    description = StringField('Description', validators=[InputRequired(), Length(1, 255)])
-    title = StringField('Title', validators=[InputRequired(), Length(1, 32)])
-    version = SelectField('Version', validators=[DataRequired()])
+class CreateJobBaseForm(FlaskForm):
+    description = StringField(
+        'Description',
+        validators=[InputRequired(), Length(max=255)]
+    )
+    title = StringField(
+        'Title',
+        validators=[InputRequired(), Length(max=32)]
+    )
+    version = SelectField('Version', validators=[InputRequired()])
     submit = SubmitField()
 
 
-class AddFileSetupPipelineJobForm(AddJobForm):
-    images = MultipleFileField('File(s)', validators=[DataRequired()])
+class CreateFileSetupPipelineJobForm(CreateJobBaseForm):
+    images = MultipleFileField('File(s)', validators=[InputRequired()])
 
     def validate_images(form, field):
         valid_mimetypes = ['image/jpeg', 'image/png', 'image/tiff']
@@ -39,18 +45,15 @@ class AddFileSetupPipelineJobForm(AddJobForm):
         self.version.default = service_manifest['latest_version']
 
 
-class AddTesseractOCRPipelineJobForm(AddJobForm):
+class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
     binarization = BooleanField('Binarization')
     pdf = FileField('File', validators=[FileRequired()])
-    model = SelectField('Model', validators=[DataRequired()])
+    model = SelectField('Model', validators=[InputRequired()])
 
     def validate_binarization(self, field):
         service_info = SERVICES['tesseract-ocr-pipeline']['versions'][self.version.data]
         if field.data:
-            if(
-                'methods' not in service_info
-                or 'binarization' not in service_info['methods']
-            ):
+            if not('methods' in service_info and 'binarization' in service_info['methods']):
                 raise ValidationError('Binarization is not available')
 
     def validate_pdf(self, field):
@@ -81,10 +84,10 @@ class AddTesseractOCRPipelineJobForm(AddJobForm):
         self.version.default = service_manifest['latest_version']
 
 
-class AddTranskribusHTRPipelineJobForm(AddJobForm):
+class CreateTranskribusHTRPipelineJobForm(CreateJobBaseForm):
     binarization = BooleanField('Binarization')
     pdf = FileField('File', validators=[FileRequired()])
-    model = SelectField('Model', validators=[DataRequired()])
+    model = SelectField('Model', validators=[InputRequired()])
 
     def validate_binarization(self, field):
         service_info = SERVICES['transkribus-htr-pipeline']['versions'][self.version.data]
@@ -123,10 +126,10 @@ class AddTranskribusHTRPipelineJobForm(AddJobForm):
         self.version.default = service_manifest['latest_version']
 
 
-class AddSpacyNLPPipelineJobForm(AddJobForm):
+class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
     encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True})
     txt = FileField('File', validators=[FileRequired()])
-    model = SelectField('Model', validators=[DataRequired()])
+    model = SelectField('Model', validators=[InputRequired()])
 
     def validate_encoding_detection(self, field):
         service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data]
diff --git a/app/services/routes.py b/app/services/routes.py
index 805dc9f4..9f5c81ef 100644
--- a/app/services/routes.py
+++ b/app/services/routes.py
@@ -1,3 +1,5 @@
+from flask import abort, current_app, flash, Markup, render_template, request
+from flask_login import current_user, login_required
 from app import db, hashids
 from app.models import (
     Job,
@@ -7,26 +9,13 @@ from app.models import (
     TRANSKRIBUS_HTR_MODELS,
     TranskribusHTRModel
 )
-from flask import (
-    abort,
-    current_app,
-    flash,
-    make_response,
-    render_template,
-    request,
-    url_for
-)
-from flask_login import current_user, login_required
-from werkzeug.utils import secure_filename
-from . import bp
-from . import SERVICES
+from . import bp, SERVICES
 from .forms import (
-    AddFileSetupPipelineJobForm,
-    AddTesseractOCRPipelineJobForm,
-    AddTranskribusHTRPipelineJobForm,
-    AddSpacyNLPPipelineJobForm
+    CreateFileSetupPipelineJobForm,
+    CreateTesseractOCRPipelineJobForm,
+    CreateTranskribusHTRPipelineJobForm,
+    CreateSpacyNLPPipelineJobForm
 )
-import json
 
 
 @bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
@@ -37,49 +26,32 @@ def file_setup_pipeline():
     version = request.args.get('version', service_manifest['latest_version'])
     if version not in service_manifest['versions']:
         abort(404)
-    form = AddFileSetupPipelineJobForm(prefix='add-job-form', version=version)
+    form = CreateFileSetupPipelineJobForm(prefix='create-job-form', version=version)
     if form.is_submitted():
         if not form.validate():
-            return make_response(form.errors, 400)
-        service_args = {}
-        job = Job(
-            user=current_user,
-            description=form.description.data,
-            service=service,
-            service_args=service_args,
-            service_version=form.version.data,
-            title=form.title.data
-        )
-        db.session.add(job)
-        db.session.flush(objects=[job])
-        db.session.refresh(job)
+            response = {'errors': form.errors}
+            return response, 400
         try:
-            job.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', 'error')
-            return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
-        for image_file in form.images.data:
-            job_input = JobInput(
-                filename=secure_filename(image_file.filename),
-                job=job,
-                mimetype=image_file.mimetype
+            job = Job.create(
+                title=form.title.data,
+                description=form.description.data,
+                service=service,
+                service_args={},
+                service_version=form.version.data,
+                user=current_user
             )
-            db.session.add(job_input)
-            db.session.flush(objects=[job_input])
-            db.session.refresh(job_input)
+        except OSError:
+            abort(500)
+        for input_file in form.images.data:
             try:
-                image_file.save(job_input.path)
-            except OSError as e:
-                current_app.logger.error(e)
-                db.session.rollback()
-                flash('Internal Server Error', 'error')
-                return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
+                JobInput.create(input_file, job=job)
+            except OSError:
+                abort(500)
         job.status = JobStatus.SUBMITTED
         db.session.commit()
-        flash(f'Job "{job.title}" added', 'job')
-        return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201)  # noqa
+        message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
+        flash(message, 'job')
+        return {}, 201, {'Location': job.url}
     return render_template(
         'services/file_setup_pipeline.html.j2',
         form=form,
@@ -90,58 +62,41 @@ def file_setup_pipeline():
 @bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
 @login_required
 def tesseract_ocr_pipeline():
-    service = 'tesseract-ocr-pipeline'
-    service_manifest = SERVICES[service]
+    service_name = 'tesseract-ocr-pipeline'
+    service_manifest = SERVICES[service_name]
     version = request.args.get('version', service_manifest['latest_version'])
     if version not in service_manifest['versions']:
         abort(404)
-    form = AddTesseractOCRPipelineJobForm(prefix='add-job-form', version=version)
+    form = CreateTesseractOCRPipelineJobForm(prefix='create-job-form', version=version)
     if form.is_submitted():
         if not form.validate():
-            return make_response(form.errors, 400)
-        service_args = {}
-        service_args['model'] = hashids.decode(form.model.data)
-        if form.binarization.data:
-            service_args['binarization'] = True
-        job = Job(
-            user=current_user,
-            description=form.description.data,
-            service=service,
-            service_args=service_args,
-            service_version=form.version.data,
-            title=form.title.data
-        )
-        db.session.add(job)
-        db.session.flush(objects=[job])
-        db.session.refresh(job)
+            response = {'errors': form.errors}
+            return response, 400
         try:
-            job.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', 'error')
-            return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
-        job_input = JobInput(
-            filename=secure_filename(form.pdf.data.filename),
-            job=job,
-            mimetype=form.pdf.data.mimetype
-        )
-        db.session.add(job_input)
-        db.session.flush(objects=[job_input])
-        db.session.refresh(job_input)
+            job = Job.create(
+                title=form.title.data,
+                description=form.description.data,
+                service=service_name,
+                service_args={
+                    'binarization': form.binarization.data,
+                    'model': hashids.decode(form.model.data)
+                },
+                service_version=form.version.data,
+                user=current_user
+            )
+        except OSError:
+            abort(500)
         try:
-            form.pdf.data.save(job_input.path)
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', 'error')
-            return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
+            JobInput.create(form.pdf.data, job=job)
+        except OSError:
+            abort(500)
         job.status = JobStatus.SUBMITTED
         db.session.commit()
-        flash(f'Job "{job.title}" added', 'job')
-        return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201)  # noqa
+        message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
+        flash(message, 'job')
+        return {}, 201, {'Location': job.url}
     tesseract_ocr_models = [
-        x for x in TesseractOCRModel.query.filter().all()
+        x for x in TesseractOCRModel.query.all()
         if version in x.compatible_service_versions and (x.shared == True or x.user == current_user)
     ]
     return render_template(
@@ -162,57 +117,40 @@ def transkribus_htr_pipeline():
     version = request.args.get('version', service_manifest['latest_version'])
     if version not in service_manifest['versions']:
         abort(404)
-    form = AddTranskribusHTRPipelineJobForm(prefix='add-job-form', version=version)
+    form = CreateTranskribusHTRPipelineJobForm(prefix='create-job-form', version=version)
     if form.is_submitted():
         if not form.validate():
-            return make_response(form.errors, 400)
-        service_args = {}
-        service_args['model'] = hashids.decode(form.model.data)
-        if form.binarization.data:
-            service_args['binarization'] = True
-        job = Job(
-            user=current_user,
-            description=form.description.data,
-            service=service,
-            service_args=service_args,
-            service_version=form.version.data,
-            title=form.title.data
-        )
-        db.session.add(job)
-        db.session.flush(objects=[job])
-        db.session.refresh(job)
+            response = {'errors': form.errors}
+            return response, 400
         try:
-            job.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', 'error')
-            return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
-        job_input = JobInput(
-            filename=secure_filename(form.pdf.data.filename),
-            job=job,
-            mimetype=form.pdf.data.mimetype
-        )
-        db.session.add(job_input)
-        db.session.flush(objects=[job_input])
-        db.session.refresh(job_input)
+            job = Job.create(
+                title=form.title.data,
+                description=form.description.data,
+                service=service,
+                service_args={
+                    'binarization': form.binarization.data,
+                    'model': hashids.decode(form.model.data)
+                },
+                service_version=form.version.data,
+                user=current_user
+            )
+        except OSError:
+            abort(500)
         try:
-            form.pdf.data.save(job_input.path)
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', 'error')
-            return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
+            JobInput.create(form.pdf.data, job=job)
+        except OSError:
+            abort(500)
         job.status = JobStatus.SUBMITTED
         db.session.commit()
-        flash(f'Job "{job.title}" added', 'job')
-        return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201)  # noqa
+        message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
+        flash(message, 'job')
+        return {}, 201, {'Location': job.url}
     transkribus_htr_models = [
-        x for x in TranskribusHTRModel.query.filter().all()
+        x for x in TranskribusHTRModel.query.all()
         if x.shared == True or x.user == current_user
     ]
     return render_template(
-        f'services/transkribus_htr_pipeline.html.j2',
+        'services/transkribus_htr_pipeline.html.j2',
         form=form,
         title=service_manifest['name'],
         TRANSKRIBUS_HTR_MODELS=TRANSKRIBUS_HTR_MODELS,
@@ -228,51 +166,34 @@ def spacy_nlp_pipeline():
     version = request.args.get('version', SERVICES[service]['latest_version'])
     if version not in service_manifest['versions']:
         abort(404)
-    form = AddSpacyNLPPipelineJobForm(prefix='add-job-form', version=version)
+    form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version)
     if form.is_submitted():
         if not form.validate():
-            return make_response(form.errors, 400)
-        service_args = {}
-        service_args['model'] = form.model.data
-        if form.encoding_detection.data:
-            service_args['encoding_detection'] = True
-        job = Job(
-            user=current_user,
-            description=form.description.data,
-            service=service,
-            service_args=service_args,
-            service_version=form.version.data,
-            title=form.title.data
-        )
-        db.session.add(job)
-        db.session.flush(objects=[job])
-        db.session.refresh(job)
+            response = {'errors': form.errors}
+            return response, 400
         try:
-            job.makedirs()
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', 'error')
-            return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
-        job_input = JobInput(
-            filename=secure_filename(form.txt.data.filename),
-            job=job,
-            mimetype=form.txt.data.mimetype
-        )
-        db.session.add(job_input)
-        db.session.flush(objects=[job_input])
-        db.session.refresh(job_input)
+            job = Job.create(
+                title=form.title.data,
+                description=form.description.data,
+                service=service,
+                service_args={
+                    'encoding_detection': form.encoding_detection.data,
+                    'model': form.model.data
+                },
+                service_version=form.version.data,
+                user=current_user
+            )
+        except OSError:
+            abort(500)
         try:
-            form.txt.data.save(job_input.path)
-        except OSError as e:
-            current_app.logger.error(e)
-            db.session.rollback()
-            flash('Internal Server Error', 'error')
-            return make_response({'redirect_url': url_for('.service', service=service)}, 500)  # noqa
+            JobInput.create(form.txt.data, job=job)
+        except OSError:
+            abort(500)
         job.status = JobStatus.SUBMITTED
         db.session.commit()
-        flash(f'Job "{job.title}" added', 'job')
-        return make_response({'redirect_url': url_for('jobs.job', job_id=job.id)}, 201)  # noqa
+        message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
+        flash(message, 'job')
+        return {}, 201, {'Location': job.url}
     return render_template(
         'services/spacy_nlp_pipeline.html.j2',
         form=form,
diff --git a/app/services/services.yml b/app/services/services.yml
index 2979539c..b7a49473 100644
--- a/app/services/services.yml
+++ b/app/services/services.yml
@@ -17,6 +17,11 @@ tesseract-ocr-pipeline:
         - 'binarization'
       publishing_year: 2022
       url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.0'
+    0.1.1:
+      methods:
+        - 'binarization'
+      publishing_year: 2022
+      url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.1'
 transkribus-htr-pipeline:
   name: 'Transkribus HTR Pipeline'
   publisher: 'Bielefeld University - CRC 1288 - INF'
@@ -47,4 +52,4 @@ spacy-nlp-pipeline:
         ru: 'Russian'
         zh: 'Chinese'
       publishing_year: 2022
-      url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.0'
\ No newline at end of file
+      url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.0'
diff --git a/app/settings/forms.py b/app/settings/forms.py
index cfc6918c..3bd3b5ab 100644
--- a/app/settings/forms.py
+++ b/app/settings/forms.py
@@ -1,5 +1,3 @@
-from app.auth import USERNAME_REGEX
-from app.models import User, UserSettingJobStatusMailNotificationLevel
 from flask_wtf import FlaskForm
 from wtforms import (
     BooleanField,
@@ -9,14 +7,35 @@ from wtforms import (
     SubmitField,
     ValidationError
 )
-from wtforms.validators import DataRequired, InputRequired, Email, EqualTo, Length, Regexp
+from wtforms.validators import (
+    DataRequired,
+    InputRequired,
+    Email,
+    EqualTo,
+    Length,
+    Regexp
+)
+from app.models import User, UserSettingJobStatusMailNotificationLevel
+from app.auth import USERNAME_REGEX
 
 
 class ChangePasswordForm(FlaskForm):
     password = PasswordField('Old password', validators=[DataRequired()])
-    new_password = PasswordField('New password', validators=[DataRequired(), EqualTo('new_password_confirmation', message='Passwords must match')])
-    new_password_confirmation = PasswordField('Confirm new password', validators=[DataRequired(), EqualTo('new_password', message='Passwords must match')])
-    submit = SubmitField('Submit')
+    new_password = PasswordField(
+        'New password',
+        validators=[
+            DataRequired(),
+            EqualTo('new_password_2', message='Passwords must match')
+        ]
+    )
+    new_password_2 = PasswordField(
+        'New password confirmation',
+        validators=[
+            DataRequired(),
+            EqualTo('new_password', message='Passwords must match')
+        ]
+    )
+    submit = SubmitField()
 
     def __init__(self, user, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -28,43 +47,51 @@ class ChangePasswordForm(FlaskForm):
 
 
 class EditGeneralSettingsForm(FlaskForm):
-    email = StringField('E-Mail', validators=[DataRequired(), Length(1, 254), Email()])
+    email = StringField(
+        'E-Mail',
+        validators=[InputRequired(), Length(max=254), Email()]
+    )
     username = StringField(
         'Username',
         validators=[
             InputRequired(),
-            Length(1, 64),
+            Length(max=64),
             Regexp(
                 USERNAME_REGEX,
-                message='Usernames must have only letters, numbers, dots or underscores'  # noqa
+                message=(
+                    'Usernames must have only letters, numbers, dots or '
+                    'underscores'
+                )
             )
         ]
     )
-    submit = SubmitField('Submit')
+    submit = SubmitField()
 
     def __init__(self, user, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.user = user
 
+    def prefill(self, user):
+        self.email.data = user.email
+        self.username.data = user.username
+
     def validate_email(self, field):
-        if (
-            field.data != self.user.email
-            and User.query.filter_by(email=field.data).first()
-        ):
+        if (field.data != self.user.email
+                and User.query.filter_by(email=field.data).first()):
             raise ValidationError('Email already registered')
 
     def validate_username(self, field):
-        if (
-            field.data != self.user.username
-            and User.query.filter_by(username=field.data).first()
-        ):
+        if (field.data != self.user.username
+                and User.query.filter_by(username=field.data).first()):
             raise ValidationError('Username already in use')
 
 
 class EditInterfaceSettingsForm(FlaskForm):
     dark_mode = BooleanField('Dark mode')
-    submit = SubmitField('Submit')
+    submit = SubmitField()
 
+    def prefill(self, user):
+        self.dark_mode.data = user.setting_dark_mode
 
 class EditNotificationSettingsForm(FlaskForm):
     job_status_mail_notification_level = SelectField(
@@ -72,11 +99,15 @@ class EditNotificationSettingsForm(FlaskForm):
         choices=[('', 'Choose your option')],
         validators=[DataRequired()]
     )
-    submit = SubmitField('Submit')
+    submit = SubmitField()
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.job_status_mail_notification_level.choices += [
-            (enum_member.name, enum_member.name.capitalize())
-            for enum_member in UserSettingJobStatusMailNotificationLevel
+            (x.name, x.name.capitalize())
+            for x in UserSettingJobStatusMailNotificationLevel
         ]
+
+    def prefill(self, user):
+        self.job_status_mail_notification_level.data = \
+            user.setting_job_status_mail_notification_level.name
diff --git a/app/settings/routes.py b/app/settings/routes.py
index 8d828e33..eb2636e8 100644
--- a/app/settings/routes.py
+++ b/app/settings/routes.py
@@ -1,32 +1,32 @@
 from flask import flash, redirect, render_template, url_for
-from flask_login import current_user, login_required, logout_user
-from . import bp, tasks
+from flask_login import current_user, login_required
+from app import db
+from app.models import UserSettingJobStatusMailNotificationLevel
+from . import bp
 from .forms import (
     ChangePasswordForm,
     EditGeneralSettingsForm,
     EditInterfaceSettingsForm,
     EditNotificationSettingsForm
 )
-from .. import db
-from ..models import UserSettingJobStatusMailNotificationLevel
 
 
 @bp.route('', methods=['GET', 'POST'])
 @login_required
-def index():
+def settings():
     change_password_form = ChangePasswordForm(
-        current_user._get_current_object(),
-        prefix='change_password_form'
+        current_user,
+        prefix='change-password-form'
     )
     edit_general_settings_form = EditGeneralSettingsForm(
-        current_user._get_current_object(),
-        prefix='edit_general_settings_form'
+        current_user,
+        prefix='edit-general-settings-form'
     )
     edit_interface_settings_form = EditInterfaceSettingsForm(
-        prefix='edit_interface_settings_form'
+        prefix='edit-interface-settings-form'
     )
     edit_notification_settings_form = EditNotificationSettingsForm(
-        prefix='edit_notification_settings_form'
+        prefix='edit-notification-settings-form'
     )
 
     if change_password_form.submit.data and change_password_form.validate():
@@ -34,58 +34,38 @@ def index():
         db.session.commit()
         flash('Your changes have been saved')
         return redirect(url_for('.index'))
-    if (
-        edit_general_settings_form.submit.data
-        and edit_general_settings_form.validate()
-    ):
+    if (edit_general_settings_form.submit.data
+            and edit_general_settings_form.validate()):
         current_user.email = edit_general_settings_form.email.data
         current_user.username = edit_general_settings_form.username.data
         db.session.commit()
         flash('Your changes have been saved')
-        return redirect(url_for('.index'))
-    if (
-        edit_interface_settings_form.submit.data
-        and edit_interface_settings_form.validate()
-    ):
-        current_user.setting_dark_mode = \
-            edit_interface_settings_form.dark_mode.data
+        return redirect(url_for('.settings'))
+    if (edit_interface_settings_form.submit.data
+            and edit_interface_settings_form.validate()):
+        current_user.setting_dark_mode = (
+            edit_interface_settings_form.dark_mode.data)
         db.session.commit()
         flash('Your changes have been saved')
-        return redirect(url_for('.index'))
-    if (
-        edit_notification_settings_form.submit.data
-        and edit_notification_settings_form.validate()
-    ):
-        current_user.setting_job_status_mail_notification_level = \
+        return redirect(url_for('.settings'))
+    if (edit_notification_settings_form.submit.data
+            and edit_notification_settings_form.validate()):
+        current_user.setting_job_status_mail_notification_level = (
             UserSettingJobStatusMailNotificationLevel[
                 edit_notification_settings_form.job_status_mail_notification_level.data  # noqa
             ]
+        )
         db.session.commit()
         flash('Your changes have been saved')
-        return redirect(url_for('.index'))
-    edit_general_settings_form.email.data = current_user.email
-    edit_general_settings_form.username.data = current_user.username
-    edit_interface_settings_form.dark_mode.data = \
-        current_user.setting_dark_mode
-    edit_notification_settings_form.job_status_mail_notification_level.data = \
-        current_user.setting_job_status_mail_notification_level.name
+        return redirect(url_for('.settings'))
+    edit_general_settings_form.prefill(current_user)
+    edit_interface_settings_form.prefill(current_user)
+    edit_notification_settings_form.prefill(current_user)
     return render_template(
-        'settings/index.html.j2',
+        'settings/settings.html.j2',
         change_password_form=change_password_form,
         edit_general_settings_form=edit_general_settings_form,
         edit_interface_settings_form=edit_interface_settings_form,
         edit_notification_settings_form=edit_notification_settings_form,
         title='Settings'
     )
-
-
-@bp.route('/delete')
-@login_required
-def delete():
-    """
-    View to delete current_user and all associated data.
-    """
-    tasks.delete_user(current_user.id)
-    logout_user()
-    flash('Your account has been marked for deletion')
-    return redirect(url_for('main.index'))
diff --git a/app/settings/tasks.py b/app/settings/tasks.py
deleted file mode 100644
index 2bd82ca9..00000000
--- a/app/settings/tasks.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from app import db
-from app.decorators import background
-from app.models import User
-
-
-@background
-def delete_user(user_id, *args, **kwargs):
-    with kwargs['app'].app_context():
-        user = User.query.get(user_id)
-        if user is None:
-            raise Exception(f'User {user_id} not found')
-        user.delete()
-        db.session.commit()
diff --git a/app/static/js/App.js b/app/static/js/App.js
index 27ddb0eb..e20b30f2 100644
--- a/app/static/js/App.js
+++ b/app/static/js/App.js
@@ -5,12 +5,7 @@ class App {
       users: {},
     };
     this.socket = io({transports: ['websocket'], upgrade: false});
-    this.socket.on('PATCH', (patch) => {
-      const re = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`);
-      const filteredPatch = patch.filter(operation => re.test(operation.path));
-  
-      jsonpatch.applyPatch(this.data, filteredPatch);
-    });
+    this.socket.on('PATCH', (patch) => {this.onPatch(patch);});
   }
 
   getUser(userId) {
@@ -19,14 +14,25 @@ class App {
     }
 
     this.data.promises.getUser[userId] = new Promise((resolve, reject) => {
-      this.socket.emit('GET /users/<user_id>', userId, (response) => {
-        if (response.code === 200) {
-          this.data.users[userId] = response.payload;
-          resolve(this.data.users[userId]);
-        } else {
-          reject(response);
-        }
-      });
+      fetch(`/users/${userId}?backrefs=true&relationships=true`, {headers: {Accept: 'application/json'}})
+        .then(
+          (response) => {return response.json();},
+          (response) => {
+            if (response.status === 403) {this.flash('Forbidden', 'error');}
+            if (response.status === 404) {this.flash('Not Found', 'error');}
+            reject(response);
+          }
+        )
+        .then(
+          (user) => {
+            this.data.users[userId] = user;
+            resolve(this.data.users[userId]);
+          },
+          (error) => {
+            console.error(error, 'error');
+            reject(error);
+          }
+        );
     });
 
     return this.data.promises.getUser[userId];
@@ -51,35 +57,55 @@ class App {
   }
 
   flash(message, category) {
-    let iconPrefix;
-    let toast;
-    let toastCloseActionElement;
-
+    let iconPrefix = '';
     switch (category) {
-      case 'corpus':
+      case 'corpus': {
         iconPrefix = '<i class="left material-icons">book</i>';
         break;
-      case 'error':
+      }
+      case 'error': {
         iconPrefix = '<i class="error-color-text left material-icons">error</i>';
         break;
-      case 'job':
+      }
+      case 'job': {
         iconPrefix = '<i class="left nopaque-icons">J</i>';
         break;
-      default:
+      }
+      default: {
         iconPrefix = '<i class="left material-icons">notifications</i>';
         break;
+      }
     }
-    toast = M.toast(
+    let toast = M.toast(
       {
         html: `
           <span>${iconPrefix}${message}</span>
-          <button class="btn-flat toast-action white-text" data-action="close">
+          <button class="action-button btn-flat toast-action white-text" data-action="close">
             <i class="material-icons">close</i>
           </button>
         `.trim()
       }
     );
-    toastCloseActionElement = toast.el.querySelector('.toast-action[data-action="close"]');
+    let toastCloseActionElement = toast.el.querySelector('.action-button[data-action="close"]');
     toastCloseActionElement.addEventListener('click', () => {toast.dismiss();});
   }
+  
+  onPatch(patch) {
+    // Filter Patch to only include operations on users that are initialized
+    let regExp = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`);
+    let filteredPatch = patch.filter(operation => regExp.test(operation.path));
+
+    // Handle job status updates
+    let subRegExp = new RegExp(`^/users/([A-Za-z0-9]*)/jobs/([A-Za-z0-9]*)/status$`);
+    let subFilteredPatch = filteredPatch
+      .filter((operation) => {return operation.op === 'replace';})
+      .filter((operation) => {return subRegExp.test(operation.path);});
+    for (let operation of subFilteredPatch) {
+      let [match, userId, jobId] = operation.path.match(subRegExp);
+      this.flash(`[<a href="/jobs/${jobId}">${this.data.users[userId].jobs[jobId].title}</a>] New status: <span class="job-status-text" data-job-status="${operation.value}"></span>`, 'job');
+    }
+
+    // Apply Patch
+    jsonpatch.applyPatch(this.data, filteredPatch);
+  }
 }
diff --git a/app/static/js/JobStatusNotifier.js b/app/static/js/JobStatusNotifier.js
deleted file mode 100644
index bef2e5c7..00000000
--- a/app/static/js/JobStatusNotifier.js
+++ /dev/null
@@ -1,31 +0,0 @@
-class JobStatusNotifier {
-  constructor(userId) {
-    this.userId = userId;
-    this.isInitialized = false;
-    app.subscribeUser(this.userId).then((response) => {
-      app.socket.on('PATCH', (patch) => {this.onPATCH(patch);});
-    });
-    app.getUser(this.userId).then((user) => {
-      this.isInitialized = true;
-    });
-  }
-
-  onPATCH(patch) {
-    if (!this.isInitialized) {return;}
-
-    let filteredPatch;
-    let jobId;
-    let match;
-    let operation;
-    let re;
-
-    re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/status$`);
-    filteredPatch = patch
-      .filter((operation) => {return operation.op === 'replace';})
-      .filter((operation) => {return re.test(operation.path);});
-    for (operation of filteredPatch) {
-      [match, jobId] = operation.path.match(re);
-      app.flash(`[<a href="/jobs/${jobId}">${app.data.users[this.userId].jobs[jobId].title}</a>] New status: <span class="job-status-text" data-job-status="${operation.value}"></span>`, 'job');
-    }
-  }
-}
diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/RessourceDisplays/CorpusDisplay.js
index 3fe7e96e..9bdc4800 100644
--- a/app/static/js/RessourceDisplays/CorpusDisplay.js
+++ b/app/static/js/RessourceDisplays/CorpusDisplay.js
@@ -2,11 +2,20 @@ class CorpusDisplay extends RessourceDisplay {
   constructor(displayElement) {
     super(displayElement);
     this.corpusId = displayElement.dataset.corpusId;
+    this.displayElement
+      .querySelector('.action-button[data-action="build-request"]')
+      .addEventListener('click', (event) => {
+        Utils.buildCorpusRequest(this.userId, this.corpusId);
+      });
+    this.displayElement
+      .querySelector('.action-button[data-action="delete-request"]')
+      .addEventListener('click', (event) => {
+        Utils.deleteCorpusRequest(this.userId, this.corpusId);
+      });
   }
 
   init(user) {
-    const corpus = user.corpora[this.corpusId];
-
+    let corpus = user.corpora[this.corpusId];
     this.setCreationDate(corpus.creation_date);
     this.setDescription(corpus.description);
     this.setLastEditedDate(corpus.last_edited_date);
@@ -15,20 +24,20 @@ class CorpusDisplay extends RessourceDisplay {
     this.setNumTokens(corpus.num_tokens);
   }
 
-  onPATCH(patch) {
-    if (!this.isInitialized) {return;}
-
-    let filteredPatch;
-    let operation;
-    let re;
-
-    re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`);
-    filteredPatch = patch.filter(operation => re.test(operation.path));
-
-    for (operation of filteredPatch) {
+  onPatch(patch) {
+    let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`);
+    let filteredPatch = patch.filter(operation => re.test(operation.path));
+    for (let operation of filteredPatch) {
       switch(operation.op) {
-        case 'replace':
-          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/last_edited_date$`);
+        case 'remove': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}$`);
+          if (re.test(operation.path)) {
+            window.location.href = '/dashboard#corpora';
+          }
+          break;
+        }
+        case 'replace': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/last_edited_date$`);
           if (re.test(operation.path)) {
             this.setLastEditedDate(operation.value);
             break;
@@ -44,8 +53,10 @@ class CorpusDisplay extends RessourceDisplay {
             break;
           }
           break;
-        default:
+        }
+        default: {
           break;
+        }
       }
     }
   }
@@ -66,19 +77,16 @@ class CorpusDisplay extends RessourceDisplay {
   }
 
   setStatus(status) {
-    let element;
-    let elements;
-
-    elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger')
-    for (element of elements) {
+    let elements = this.displayElement.querySelectorAll('.corpus-analyse-trigger')
+    for (let element of elements) {
       if (['BUILT', 'STARTING_ANALYSIS_SESSION', 'RUNNING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) {
         element.classList.remove('disabled');
       } else {
         element.classList.add('disabled');
       }
     }
-    elements = this.displayElement.querySelectorAll('.corpus-build-trigger');
-    for (element of elements) {
+    elements = this.displayElement.querySelectorAll('.action-button[data-action="build-request"]');
+    for (let element of elements) {
       if (['UNPREPARED', 'FAILED'].includes(status) && Object.values(app.data.users[this.userId].corpora[this.corpusId].files).length > 0) {
         element.classList.remove('disabled');
       } else {
@@ -86,11 +94,11 @@ class CorpusDisplay extends RessourceDisplay {
       }
     }
     elements = this.displayElement.querySelectorAll('.corpus-status');
-    for (element of elements) {
+    for (let element of elements) {
       element.dataset.corpusStatus = status;
     }
     elements = this.displayElement.querySelectorAll('.corpus-status-spinner');
-    for (element of elements) {
+    for (let element of elements) {
       if (['SUBMITTED', 'QUEUED', 'BUILDING', 'STARTING_ANALYSIS_SESSION', 'CANCELING_ANALYSIS_SESSION'].includes(status)) {
         element.classList.remove('hide');
       } else {
diff --git a/app/static/js/RessourceDisplays/JobDisplay.js b/app/static/js/RessourceDisplays/JobDisplay.js
index dc1bd777..d6669450 100644
--- a/app/static/js/RessourceDisplays/JobDisplay.js
+++ b/app/static/js/RessourceDisplays/JobDisplay.js
@@ -2,11 +2,25 @@ class JobDisplay extends RessourceDisplay {
   constructor(displayElement) {
     super(displayElement);
     this.jobId = this.displayElement.dataset.jobId;
+    this.displayElement
+      .querySelector('.action-button[data-action="delete-request"]')
+      .addEventListener('click', (event) => {
+        Utils.deleteJobRequest(this.userId, this.jobId);
+      });
+    this.displayElement
+      .querySelector('.action-button[data-action="get-log-request"]')
+      .addEventListener('click', (event) => {
+        Utils.getJobLogRequest(this.userId, this.jobId);
+      });
+    this.displayElement
+      .querySelector('.action-button[data-action="restart-request"]')
+      .addEventListener('click', (event) => {
+        Utils.restartJobRequest(this.userId, this.jobId);
+      });
   }
 
   init(user) {
-    const job = user.jobs[this.jobId];
-
+    let job = user.jobs[this.jobId];
     this.setCreationDate(job.creation_date);
     this.setEndDate(job.creation_date);
     this.setDescription(job.description);
@@ -17,20 +31,20 @@ class JobDisplay extends RessourceDisplay {
     this.setTitle(job.title);
   }
 
-  onPATCH(patch) {
-    if (!this.isInitialized) {return;}
-
-    let filteredPatch;
-    let operation;
-    let re;
-
-    re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`);
-    filteredPatch = patch.filter(operation => re.test(operation.path));
-
-    for (operation of filteredPatch) {
+  onPatch(patch) {
+    let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`);
+    let filteredPatch = patch.filter(operation => re.test(operation.path));
+    for (let operation of filteredPatch) {
       switch(operation.op) {
-        case 'replace':
-          re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`);
+        case 'remove': {
+          let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}$`);
+          if (re.test(operation.path)) {
+            window.location.href = '/dashboard#jobs';
+          }
+          break;
+        }
+        case 'replace': {
+          let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`);
           if (re.test(operation.path)) {
             this.setEndDate(operation.value);
             break;
@@ -41,8 +55,10 @@ class JobDisplay extends RessourceDisplay {
             break;
           }
           break;
-        default:
+        }
+        default: {
           break;
+        }
       }
     }
   }
@@ -56,15 +72,12 @@ class JobDisplay extends RessourceDisplay {
   }
 
   setStatus(status) {
-    let element;
-    let elements;
-
-    elements = this.displayElement.querySelectorAll('.job-status');
-    for (element of elements) {
+    let elements = this.displayElement.querySelectorAll('.job-status');
+    for (let element of elements) {
       element.dataset.jobStatus = status;
     }
     elements = this.displayElement.querySelectorAll('.job-status-spinner');
-    for (element of elements) {
+    for (let element of elements) {
       if (['COMPLETED', 'FAILED'].includes(status)) {
         element.classList.add('hide');
       } else {
@@ -72,19 +85,27 @@ class JobDisplay extends RessourceDisplay {
       }
     }
     elements = this.displayElement.querySelectorAll('.job-log-trigger');
-    for (element of elements) {
+    for (let element of elements) {
       if (['COMPLETED', 'FAILED'].includes(status)) {
         element.classList.remove('hide');
       } else {
         element.classList.add('hide');
       }
     }
-    elements = this.displayElement.querySelectorAll('.job-restart-trigger');
-    for (element of elements) {
+    elements = this.displayElement.querySelectorAll('.action-button[data-action="get-log-request"]');
+    for (let element of elements) {
       if (['COMPLETED', 'FAILED'].includes(status)) {
-        element.classList.remove('hide');
+        element.classList.remove('disabled');
       } else {
-        element.classList.add('hide');
+        element.classList.add('disabled');
+      }
+    }
+    elements = this.displayElement.querySelectorAll('.action-button[data-action="restart-request"]');
+    for (let element of elements) {
+      if (status === 'FAILED') {
+        element.classList.remove('disabled');
+      } else {
+        element.classList.add('disabled');
       }
     }
   }
diff --git a/app/static/js/RessourceDisplays/RessourceDisplay.js b/app/static/js/RessourceDisplays/RessourceDisplay.js
index 0fde4640..a07c2163 100644
--- a/app/static/js/RessourceDisplays/RessourceDisplay.js
+++ b/app/static/js/RessourceDisplays/RessourceDisplay.js
@@ -4,36 +4,40 @@ class RessourceDisplay {
     this.userId = this.displayElement.dataset.userId;
     this.isInitialized = false;
     if (this.userId) {
-      app.subscribeUser(this.userId).then((response) => {
-        app.socket.on('PATCH', (patch) => {this.onPATCH(patch);});
-      });
-      app.getUser(this.userId).then((user) => {
-        this.init(user);
-        this.isInitialized = true;
-      });
+      app.subscribeUser(this.userId)
+        .then((response) => {
+          app.socket.on('PATCH', (patch) => {
+            if (this.isInitialized) {this.onPatch(patch);}
+          });
+        });
+      app.getUser(this.userId)
+        .then((user) => {
+          this.init(user);
+          this.isInitialized = true;
+        });
     }
   }
 
   init(user) {throw 'Not implemented';}
 
-  onPATCH(patch) {throw 'Not implemented';}
+  onPatch(patch) {throw 'Not implemented';}
 
   setElement(element, value) {
     switch (element.tagName) {
-      case 'INPUT':
+      case 'INPUT': {
         element.value = value;
         M.updateTextFields();
         break;
-      default:
+      }
+      default: {
         element.innerText = value;
         break;
+      }
     }
   }
 
   setElements(elements, value) {
-    let element;
-
-    for (element of elements) {
+    for (let element of elements) {
       this.setElement(element, value);
     }
   }
diff --git a/app/static/js/RessourceLists/CorpusFileList.js b/app/static/js/RessourceLists/CorpusFileList.js
index 7ffd18b6..a24fcf7e 100644
--- a/app/static/js/RessourceLists/CorpusFileList.js
+++ b/app/static/js/RessourceLists/CorpusFileList.js
@@ -1,19 +1,47 @@
 class CorpusFileList extends RessourceList {
+  static autoInit() {
+    for (let corpusFileListElement of document.querySelectorAll('.corpus-file-list:not(.no-autoinit)')) {
+      new CorpusFileList(corpusFileListElement);
+    }
+  }
+
   static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search corpus file</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th>Filename</th>
+              <th>Author</th>
+              <th>Title</th>
+              <th>Publishing year</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
     item: `
-      <tr class="hoverable">
+      <tr class="clickable hoverable">
         <td><span class="filename"></span></td>
         <td><span class="author"></span></td>
         <td><span class="title"></span></td>
         <td><span class="publishing-year"></span></td>
         <td class="right-align">
-          <a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
-          <a class="action-button btn-floating tooltipped nopaque-service-color darken waves-effect waves-light" data-action="download" data-position="top" data-service="corpus-analysis" data-tooltip="View"><i class="material-icons">file_download</i></a>
-          <a class="action-button btn-floating tooltipped nopaque-service-color darken waves-effect waves-light" data-action="view" data-position="top" data-service="corpus-analysis" data-tooltip="View"><i class="material-icons">send</i></a>
+          <a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
+          <a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="download" data-service="corpus-analysis"><i class="material-icons">file_download</i></a>
+          <a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
         </td>
       </tr>
     `.trim(),
-    ressourceMapper: corpusFile => {
+    ressourceMapper: (corpusFile) => {
       return {
         'id': corpusFile.id,
         'author': corpusFile.author,
@@ -23,7 +51,7 @@ class CorpusFileList extends RessourceList {
         'title': corpusFile.title
       };
     },
-    sortValueName: 'creation-date',
+    sortArgs: ['creation-date', {order: 'desc'}],
     valueNames: [
       {data: ['id']},
       {data: ['creation-date']},
@@ -34,7 +62,6 @@ class CorpusFileList extends RessourceList {
     ]
   };
 
-
   constructor(listElement, options = {}) {
     super(listElement, {...CorpusFileList.options, ...options});
     this.corpusId = listElement.dataset.corpusId;
@@ -44,94 +71,59 @@ class CorpusFileList extends RessourceList {
     this._init(user.corpora[this.corpusId].files);
   }
 
-  onclick(event) {
-    let action;
-    let actionButtonElement;
-    let corpusFileElement;
-    let corpusFileId;
-    let deleteModal;
-    let deleteModalElement;
-    let tmp;
-
-    corpusFileElement = event.target.closest('tr[data-id]');
-    if (corpusFileElement === null) {return;}
-    corpusFileId = corpusFileElement.dataset.id;
-    actionButtonElement = event.target.closest('.action-button[data-action]');
-    action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    let corpusFileElement = event.target.closest('tr');
+    let corpusFileId = corpusFileElement.dataset.id;
     switch (action) {
-      case 'delete':
-        tmp = document.createElement('div');
-        tmp.innerHTML = `
-          <div class="modal">
-            <div class="modal-content">
-              <h4>Confirm corpus deletion</h4>
-              <p>Do you really want to delete the corpus file <b>${app.data.users[this.userId].corpora[this.corpusId].files[corpusFileId].filename}</b>? It will be permanently deleted!</p>
-            </div>
-            <div class="modal-footer">
-              <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
-              <a class="btn modal-close red waves-effect waves-light" href="/corpora/${this.corpusId}/files/${corpusFileId}/delete"><i class="material-icons left">delete</i>Delete</a>
-            </div>
-          </div>
-        `.trim();
-        deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
-        deleteModal = M.Modal.init(
-          deleteModalElement,
-          {
-            onCloseEnd: () => {
-              deleteModal.destroy();
-              deleteModalElement.remove();
-            }
-          }
-        );
-        deleteModal.open();
+      case 'delete': {
+        Utils.deleteCorpusFileRequest(this.userId, this.corpusId, corpusFileId);
         break;
-      case 'download':
+      }
+      case 'download': {
         window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}/download`;
         break;
-      case 'view':
+      }
+      case 'view': {
         window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}`;
         break;
-      default:
+      }
+      default: {
         break;
+      }
     }
   }
 
-  onPATCH(patch) {
-    if (!this.isInitialized) {return;}
-
-    let corpusFileId;
-    let filteredPatch;
-    let match;
-    let operation;
-    let re;
-    let valueName;
-
-    re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`);
-    filteredPatch = patch.filter(operation => re.test(operation.path));
-    for (operation of filteredPatch) {
+  onPatch(patch) {
+    let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`);
+    let filteredPatch = patch.filter(operation => re.test(operation.path));
+    for (let operation of filteredPatch) {
       switch(operation.op) {
-        case 'add':
-          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
-          if (re.test(operation.path)) {
-            this.add(operation.value);
-          }
+        case 'add': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
+          if (re.test(operation.path)) {this.add(operation.value);}
           break;
-        case 'remove':
-          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
+        }
+        case 'remove': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
           if (re.test(operation.path)) {
-            [match, corpusFileId] = operation.path.match(re);
+            let [match, corpusFileId] = operation.path.match(re);
             this.remove(corpusFileId);
           }
           break;
-        case 'replace':
-          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`);
+        }
+        case 'replace': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`);
           if (re.test(operation.path)) {
-            [match, corpusFileId, valueName] = operation.path.match(re);
+            let [match, corpusFileId, valueName] = operation.path.match(re);
             this.replace(corpusFileId, valueName.replace('_', '-'), operation.value);
           }
           break;
-        default:
+        }
+        default: {
           break;
+        }
       }
     }
   }
diff --git a/app/static/js/RessourceLists/CorpusList.js b/app/static/js/RessourceLists/CorpusList.js
index b2727737..0721a807 100644
--- a/app/static/js/RessourceLists/CorpusList.js
+++ b/app/static/js/RessourceLists/CorpusList.js
@@ -1,17 +1,44 @@
 class CorpusList extends RessourceList {
+  static autoInit() {
+    for (let corpusListElement of document.querySelectorAll('.corpus-list:not(.no-autoinit)')) {
+      new CorpusList(corpusListElement);
+    }
+  }
+
   static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search corpus</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th></th>
+              <th>Title and Description</th>
+              <th>Status</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
     item: `
-      <tr class="hoverable">
+      <tr class="clickable hoverable">
         <td><a class="btn-floating disabled"><i class="material-icons service-color darken" data-service="corpus-analysis">book</i></a></td>
         <td><b class="title"></b><br><i class="description"></i></td>
         <td><span class="status badge new corpus-status-color corpus-status-text" data-badge-caption=""></span></td>
         <td class="right-align">
-          <a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
-          <a class="action-button btn-floating nopaque-service-color darken tooltipped waves-effect waves-light" data-action="view" data-position="top" data-service="corpus-analysis" data-tooltip="View"><i class="material-icons">send</i></a>
+          <a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
+          <a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
         </td>
       </tr>
     `.trim(),
-    ressourceMapper: corpus => {
+    ressourceMapper: (corpus) => {
       return {
         'id': corpus.id,
         'creation-date': corpus.creation_date,
@@ -20,7 +47,7 @@ class CorpusList extends RessourceList {
         'title': corpus.title
       };
     },
-    sortValueName: 'creation-date',
+    sortArgs: ['creation-date', {order: 'desc'}],
     valueNames: [
       {data: ['id']},
       {data: ['creation-date']},
@@ -30,98 +57,63 @@ class CorpusList extends RessourceList {
     ]
   };
 
-
   constructor(listElement, options = {}) {
     super(listElement, {...CorpusList.options, ...options});
   }
 
   init(user) {
-    super._init(user.corpora);
+    this._init(user.corpora);
   }
 
-  onclick(event) {
-    let action;
-    let actionButtonElement;
-    let corpusElement;
-    let corpusId;
-    let deleteModal;
-    let deleteModalElement;
-    let tmp;
-
-    corpusElement = event.target.closest('tr[data-id]');
-    if (corpusElement === null) {return;}
-    corpusId = corpusElement.dataset.id;
-    actionButtonElement = event.target.closest('.action-button[data-action]');
-    action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    let corpusElement = event.target.closest('tr');
+    let corpusId = corpusElement.dataset.id;
     switch (action) {
-      case 'delete':
-        tmp = document.createElement('div');
-        tmp.innerHTML = `
-          <div class="modal">
-            <div class="modal-content">
-              <h4>Confirm corpus deletion</h4>
-              <p>Do you really want to delete the corpus <b>${app.data.users[this.userId].corpora[corpusId].title}</b>? All files will be permanently deleted!</p>
-            </div>
-            <div class="modal-footer">
-              <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
-              <a class="btn modal-close red waves-effect waves-light" href="/corpora/${corpusId}/delete"><i class="material-icons left">delete</i>Delete</a>
-            </div>
-          </div>
-        `.trim();
-        deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
-        deleteModal = M.Modal.init(
-          deleteModalElement,
-          {
-            onCloseEnd: () => {
-              deleteModal.destroy();
-              deleteModalElement.remove();
-            }
-          }
-        );
-        deleteModal.open();
+      case 'delete-request': {
+        Utils.deleteCorpusRequest(this.userId, corpusId);
         break;
-      case 'view':
+      }
+      case 'view': {
         window.location.href = `/corpora/${corpusId}`;
         break;
-      default:
+      }
+      default: {
         break;
+      }
     }
   }
 
-  onPATCH(patch) {
-    if (!this.isInitialized) {return;}
-
-    let corpusId;
-    let filteredPatch;
-    let match;
-    let operation;
-    let re;
-    let valueName;
-
-    re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
-    filteredPatch = patch.filter(operation => re.test(operation.path));
-    for (operation of filteredPatch) {
+  onPatch(patch) {
+    let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
+    let filteredPatch = patch.filter(operation => re.test(operation.path));
+    for (let operation of filteredPatch) {
       switch(operation.op) {
-        case 'add':
-          re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
+        case 'add': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
           if (re.test(operation.path)) {this.add(operation.value);}
           break;
-        case 'remove':
-          re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
+        }
+        case 'remove': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
           if (re.test(operation.path)) {
-            [match, corpusId] = operation.path.match(re);
+            let [match, corpusId] = operation.path.match(re);
             this.remove(corpusId);
           }
           break;
-        case 'replace':
-          re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`);
+        }
+        case 'replace': {
+          let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`);
           if (re.test(operation.path)) {
-            [match, corpusId, valueName] = operation.path.match(re);
+            let [match, corpusId, valueName] = operation.path.match(re);
             this.replace(corpusId, valueName, operation.value);
           }
           break;
-        default:
+        }
+        default: {
           break;
+        }
       }
     }
   }
diff --git a/app/static/js/RessourceLists/JobInputList.js b/app/static/js/RessourceLists/JobInputList.js
index d86ff8ca..2cd14aa9 100644
--- a/app/static/js/RessourceLists/JobInputList.js
+++ b/app/static/js/RessourceLists/JobInputList.js
@@ -1,21 +1,46 @@
 class JobInputList extends RessourceList {
+  static autoInit() {
+    for (let jobInputListElement of document.querySelectorAll('.job-input-list:not(.no-autoinit)')) {
+      new JobInputList(jobInputListElement);
+    }
+  }
+
   static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search job input</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th>Filename</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
     item: `
-      <tr class="hoverable">
+      <tr class="clickable hoverable">
         <td><span class="filename"></span></td>
         <td class="right-align">
-          <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="download" data-position="top" data-tooltip="View"><i class="material-icons">file_download</i></a>
+          <a class="action-button btn-floating waves-effect waves-light" data-action="download"><i class="material-icons">file_download</i></a>
         </td>
       </tr>
     `.trim(),
-    ressourceMapper: jobInput => {
+    ressourceMapper: (jobInput) => {
       return {
         'id': jobInput.id,
         'creation-date': jobInput.creation_date,
         'filename': jobInput.filename
       };
     },
-    sortValueName: 'creation-date',
+    sortArgs: ['filename', {order: 'asc'}],
     valueNames: [
       {data: ['id']},
       {data: ['creation-date']},
@@ -23,7 +48,6 @@ class JobInputList extends RessourceList {
     ]
   };
 
-
   constructor(listElement, options = {}) {
     super(listElement, {...JobInputList.options, ...options});
     this.jobId = listElement.dataset.jobId;
@@ -33,26 +57,21 @@ class JobInputList extends RessourceList {
     this._init(user.jobs[this.jobId].inputs);
   }
 
-  onclick(event) {
-    let jobInputElement;
-    let jobInputId;
-    let action;
-    let actionButtonElement;
-
-    jobInputElement = event.target.closest('tr[data-id]');
-    if (jobInputElement === null) {return;}
-    jobInputId = jobInputElement.dataset.id;
-    actionButtonElement = event.target.closest('.action-button[data-action]');
-    if (actionButtonElement === null) {return;}
-    action = actionButtonElement.dataset.action;
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action;
+    let jobInputElement = event.target.closest('tr');
+    let jobInputId = jobInputElement.dataset.id;
     switch (action) {
-      case 'download':
+      case 'download': {
         window.location.href = `/jobs/${this.jobId}/inputs/${jobInputId}/download`;
         break;
-      default:
+      }
+      default: {
         break;
+      }
     }
   }
 
-  onPATCH(patch) {return;}
+  onPatch(patch) {return;}
 }
diff --git a/app/static/js/RessourceLists/JobList.js b/app/static/js/RessourceLists/JobList.js
index 97895a0e..d6fa7894 100644
--- a/app/static/js/RessourceLists/JobList.js
+++ b/app/static/js/RessourceLists/JobList.js
@@ -1,17 +1,44 @@
 class JobList extends RessourceList {
+  static autoInit() {
+    for (let jobListElement of document.querySelectorAll('.job-list:not(.no-autoinit)')) {
+      new JobList(jobListElement);
+    }
+  }
+
   static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search job</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th>Service</th>
+              <th>Title and Description</th>
+              <th>Status</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
     item: `
-      <tr class="hoverable service-color lighten">
+      <tr class="clickable hoverable service-color lighten">
         <td><a class="btn-floating disabled"><i class="service-1 nopaque-icons service-color darken service-icon"></i></a></td>
         <td><b class="title"></b><br><i class="description"></i></td>
         <td><span class="status badge new job-status-color job-status-text" data-badge-caption=""></span></td>
         <td class="right-align">
-          <a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
-          <a class="service-2 action-button btn-floating nopaque-service-color darken tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
+          <a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
+          <a class="action-button btn-floating service-color darken waves-effect waves-light service-2" data-action="view"><i class="material-icons">send</i></a>
         </td>
       </tr>
     `.trim(),
-    ressourceMapper: job => {
+    ressourceMapper: (job) => {
       return {
         'id': job.id,
         'creation-date': job.creation_date,
@@ -23,7 +50,7 @@ class JobList extends RessourceList {
         'title': job.title
       };
     },
-    sortValueName: 'creation-date',
+    sortArgs: ['creation-date', {order: 'desc'}],
     valueNames: [
       {data: ['id']},
       {data: ['creation-date']},
@@ -44,91 +71,55 @@ class JobList extends RessourceList {
     this._init(user.jobs);
   }
 
-  onclick(event) {
-    let action;
-    let actionButtonElement;
-    let deleteModal;
-    let deleteModalElement;
-    let jobElement;
-    let jobId;
-    let tmp;
-
-    jobElement = event.target.closest('tr[data-id]');
-    if (jobElement === null) {return;}
-    jobId = jobElement.dataset.id;
-    actionButtonElement = event.target.closest('.action-button[data-action]');
-    action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    let jobElement = event.target.closest('tr');
+    let jobId = jobElement.dataset.id;
     switch (action) {
-      case 'delete':
-        tmp = document.createElement('div');
-        tmp.innerHTML = `
-          <div class="modal">
-            <div class="modal-content">
-              <h4>Confirm job deletion</h4>
-              <p>Do you really want to delete the job <b>${app.data.users[this.userId].jobs[jobId].title}</b>? All files will be permanently deleted!</p>
-            </div>
-            <div class="modal-footer">
-              <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
-              <a class="btn modal-close red waves-effect waves-light" href="/jobs/${jobId}/delete"><i class="material-icons left">delete</i>Delete</a>
-            </div>
-          </div>
-        `.trim();
-        deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
-        deleteModal = M.Modal.init(
-          deleteModalElement,
-          {
-            onCloseEnd: () => {
-              deleteModal.destroy();
-              deleteModalElement.remove();
-            }
-          }
-        );
-        deleteModal.open();
+      case 'delete-request': {
+        Utils.deleteJobRequest(this.userId, jobId);
         break;
-      case 'view':
+      }
+      case 'view': {
         window.location.href = `/jobs/${jobId}`;
         break;
-      default:
+      }
+      default: {
         break;
+      }
     }
   }
 
-  onPATCH(patch) {
-    if (!this.isInitialized) {return;}
-
-    let filteredPatch;
-    let jobId;
-    let match;
-    let operation;
-    let re;
-    let valueName;
-
-    re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
-    filteredPatch = patch.filter(operation => re.test(operation.path));
-    for (operation of filteredPatch) {
+  onPatch(patch) {
+    let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
+    let filteredPatch = patch.filter(operation => re.test(operation.path));
+    for (let operation of filteredPatch) {
       switch(operation.op) {
-        case 'add':
-          re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
-          if (re.test(operation.path)) {
-            this.add(operation.value);
-          }
+        case 'add': {
+          let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
+          if (re.test(operation.path)) {this.add(operation.value);}
           break;
-        case 'remove':
-          re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
+        }
+        case 'remove': {
+          let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
           if (re.test(operation.path)) {
-            [match, jobId] = operation.path.match(re);
+            let [match, jobId] = operation.path.match(re);
             this.remove(jobId);
           }
           break;
-        case 'replace':
-          re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`);
+        }
+        case 'replace': {
+          let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`);
           if (re.test(operation.path)) {
-            [match, jobId, valueName] = operation.path.match(re);
+            let [match, jobId, valueName] = operation.path.match(re);
             this.replace(jobId, valueName, operation.value);
           }
           break;
-        default:
+        }
+        default: {
           break;
+        }
       }
     }
   }
diff --git a/app/static/js/RessourceLists/JobResultList.js b/app/static/js/RessourceLists/JobResultList.js
index 16c390df..3623363a 100644
--- a/app/static/js/RessourceLists/JobResultList.js
+++ b/app/static/js/RessourceLists/JobResultList.js
@@ -1,15 +1,41 @@
 class JobResultList extends RessourceList {
+  static autoInit() {
+    for (let jobResultListElement of document.querySelectorAll('.job-result-list:not(.no-autoinit)')) {
+      new JobResultList(jobResultListElement);
+    }
+  }
+
   static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search job result</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th>Description</th>
+              <th>Filename</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
     item: `
-      <tr class="hoverable">
+      <tr class="clickable hoverable">
         <td><span class="description"></span></td>
         <td><span class="filename"></span></td>
         <td class="right-align">
-          <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="download" data-position="top" data-tooltip="View"><i class="material-icons">file_download</i></a>
+          <a class="action-button btn-floating waves-effect waves-light" data-action="download"><i class="material-icons">file_download</i></a>
         </td>
       </tr>
     `.trim(),
-    ressourceMapper: jobResult => {
+    ressourceMapper: (jobResult) => {
       return {
         'id': jobResult.id,
         'creation-date': jobResult.creation_date,
@@ -17,7 +43,7 @@ class JobResultList extends RessourceList {
         'filename': jobResult.filename
       };
     },
-    sortValueName: 'creation-date',
+    sortArgs: ['filename', {order: 'asc'}],
     valueNames: [
       {data: ['id']},
       {data: ['creation-date']},
@@ -26,7 +52,6 @@ class JobResultList extends RessourceList {
     ]
   };
 
-
   constructor(listElement, options = {}) {
     super(listElement, {...JobResultList.options, ...options});
     this.jobId = listElement.dataset.jobId;
@@ -36,46 +61,35 @@ class JobResultList extends RessourceList {
     super._init(user.jobs[this.jobId].results);
   }
 
-  onclick(event) {
-    let action;
-    let actionButtonElement;
-    let jobResultElement;
-    let jobResultId;
-
-    jobResultElement = event.target.closest('tr[data-id]');
-    if (jobResultElement === null) {return;}
-    jobResultId = jobResultElement.dataset.id;
-    actionButtonElement = event.target.closest('.action-button[data-action]');
-    if (actionButtonElement === null) {return;}
-    action = actionButtonElement.dataset.action;
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action;
+    let jobResultElement = event.target.closest('tr');
+    let jobResultId = jobResultElement.dataset.id;
     switch (action) {
-      case 'download':
+      case 'download': {
         window.location.href = `/jobs/${this.jobId}/results/${jobResultId}/download`;
         break;
-      default:
+      }
+      default: {
         break;
+      }
     }
   }
 
-  onPATCH(patch) {
-    if (!this.isInitialized) {return;}
-
-    let filteredPatch;
-    let operation;
-    let re;
-
-    re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`);
-    filteredPatch = patch.filter(operation => re.test(operation.path));
-    for (operation of filteredPatch) {
+  onPatch(patch) {
+    let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`);
+    let filteredPatch = patch.filter(operation => re.test(operation.path));
+    for (let operation of filteredPatch) {
       switch(operation.op) {
-        case 'add':
-          re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`);
-          if (re.test(operation.path)) {
-            this.add(operation.value);
-          }
+        case 'add': {
+          let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`);
+          if (re.test(operation.path)) {this.add(operation.value);}
           break;
-        default:
+        }
+        default: {
           break;
+        }
       }
     }
   }
diff --git a/app/static/js/RessourceLists/QueryResultList.js b/app/static/js/RessourceLists/QueryResultList.js
index 8d0c1329..cffc4318 100644
--- a/app/static/js/RessourceLists/QueryResultList.js
+++ b/app/static/js/RessourceLists/QueryResultList.js
@@ -1,12 +1,18 @@
 class QueryResultList extends RessourceList {
+  static autoInit() {
+    for (let queryResultListElement of document.querySelectorAll('.query-result-list:not(.no-autoinit)')) {
+      new QueryResultList(queryResultListElement);
+    }
+  }
+
   static options = {
     item: `
       <tr class="hoverable">
         <td><b class="title"></b><br><i class="description"></i><br></td>
         <td><span class="corpus-title"></span><br><span class="query"></span></td>
         <td class="right-align">
-          <a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
-          <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
+          <a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
+          <a class="action-button btn-floating waves-effect waves-light" data-action="view"><i class="material-icons">send</i></a>
         </td>
       </tr>
     `.trim(),
@@ -20,7 +26,7 @@ class QueryResultList extends RessourceList {
         'title': queryResult.title
       };
     },
-    sortValueName: 'creation-date',
+    sortArgs: ['creation-date', {order: 'desc'}],
     valueNames: [
       {data: ['id']},
       {data: ['creation-date']},
@@ -31,7 +37,6 @@ class QueryResultList extends RessourceList {
     ]
   };
 
-
   constructor(listElement, options = {}) {
     super(listElement, {...QueryResultList.options, ...options});
   }
diff --git a/app/static/js/RessourceLists/RessourceList.js b/app/static/js/RessourceLists/RessourceList.js
index 29a94663..824db3d1 100644
--- a/app/static/js/RessourceLists/RessourceList.js
+++ b/app/static/js/RessourceLists/RessourceList.js
@@ -3,45 +3,22 @@ class RessourceList {
    * This class is not meant to be used directly, instead it should be used as
    * a base class for concrete ressource list implementations.
    */
-  static autoInit() {
-    const nopaqueRessourceListElements = document.querySelectorAll('.nopaque-ressource-list[data-ressource-type]:not(.no-autoinit)');
-    let nopaqueRessourceListElement;
 
-    for (nopaqueRessourceListElement of nopaqueRessourceListElements) {
-      switch (nopaqueRessourceListElement.dataset.ressourceType) {
-        case 'Corpus':
-          new CorpusList(nopaqueRessourceListElement);
-          break;
-        case 'CorpusFile':
-          new CorpusFileList(nopaqueRessourceListElement);
-          break;
-        case 'Job':
-          new JobList(nopaqueRessourceListElement);
-          break;
-        case 'JobInput':
-          new JobInputList(nopaqueRessourceListElement);
-          break;
-        case 'JobResult':
-          new JobResultList(nopaqueRessourceListElement);
-          break;
-        case 'QueryResult':
-          new QueryResultList(nopaqueRessourceListElement);
-          break;
-        case 'User':
-          new UserList(nopaqueRessourceListElement);
-          break;
-        default:
-          break;
-      }
-    }
+  static autoInit() {
+    CorpusList.autoInit();
+    CorpusFileList.autoInit();
+    JobList.autoInit();
+    JobInputList.autoInit();
+    JobResultList.autoInit();
+    QueryResultList.autoInit();
+    UserList.autoInit();
   }
-  static options = {page: 5, pagination: {innerWindow: 4, outerWindow: 1}};
 
+  static options = {page: 5, pagination: {innerWindow: 4, outerWindow: 1}};
 
   constructor(listElement, options = {}) {
-    let i;
-
     if (!(listElement.hasAttribute('id'))) {
+      let i;
       for (i = 0; true; i++) {
         if (document.querySelector(`#ressource-list-${i}`)) {continue;}
         listElement.id = `ressource-list-${i}`;
@@ -56,9 +33,14 @@ class RessourceList {
       this.ressourceMapper = options.ressourceMapper;
       delete options.ressourceMapper;
     }
-    if ('sortValueName' in options) {
-      this.sortValueName = options.sortValueName;
-      delete options.sortValueName;
+    if ('initialHtmlGenerator' in options) {
+      this.initialHtmlGenerator = options.initialHtmlGenerator;
+      listElement.innerHTML = this.initialHtmlGenerator(listElement.id);
+      delete options.initialHtmlGenerator;
+    }
+    if ('sortArgs' in options) {
+      this.sortArgs = options.sortArgs;
+      delete options.sortArgs;
     }
     this.listjs = new List(listElement, {...RessourceList.options, ...options});
     this.listjs.list.innerHTML = `
@@ -87,50 +69,54 @@ class RessourceList {
         </td>
       </tr>
     `.trim();
-    this.listjs.list.style.cursor = 'pointer';
     this.userId = this.listjs.listContainer.dataset.userId;
-    this.listjs.list.addEventListener('click', event => this.onclick(event));
+    this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
     this.isInitialized = false;
     if (this.userId) {
-      app.subscribeUser(this.userId).then((response) => {
-        app.socket.on('PATCH', (patch) => {this.onPATCH(patch);});
-      });
-      app.getUser(this.userId).then((user) => {
-        this.init(user);
-        this.isInitialized = true;
-      });
+      app.subscribeUser(this.userId)
+        .then((response) => {
+          app.socket.on('PATCH', (patch) => {
+            if (this.isInitialized) {this.onPatch(patch);}
+          });
+        });
+      app.getUser(this.userId)
+        .then((user) => {
+          this.init(user);
+          this.isInitialized = true;
+        });
     }
   }
 
   _init(ressources) {
     this.listjs.clear();
     this.add(Object.values(ressources));
-    let emptyListElementHTML = `
-      <tr class="show-if-only-child">
-        <td colspan="100%">
-          <span class="card-title"><i class="left material-icons" style="font-size: inherit;">file_download</i>Nothing here...</span>
-          <p>No ressource available.</p>
-        </td>
-      </tr>
-    `.trim();
-    this.listjs.list.insertAdjacentHTML('afterbegin', emptyListElementHTML);
+    this.listjs.list.insertAdjacentHTML(
+      'afterbegin',
+      `
+        <tr class="show-if-only-child">
+          <td colspan="100%">
+            <span class="card-title"><i class="left material-icons" style="font-size: inherit;">file_download</i>Nothing here...</span>
+            <p>No ressource available.</p>
+          </td>
+        </tr>
+      `.trim()
+    );
   }
 
   init(user) {throw 'Not implemented';}
 
-  onclick(event) {throw 'Not implemented';}
+  onClick(event) {throw 'Not implemented';}
 
-  onPATCH(patch) {throw 'Not implemented';}
+  onPatch(patch) {throw 'Not implemented';}
 
   add(ressources) {
     let values = Array.isArray(ressources) ? ressources : [ressources];
-
     if ('ressourceMapper' in this) {
-      values = values.map(value => this.ressourceMapper(value));
+      values = values.map((value) => {return this.ressourceMapper(value);});
     }
     this.listjs.add(values, () => {
-      if ('sortValueName' in this) {
-        this.listjs.sort(this.sortValueName, {order: 'desc'});
+      if ('sortArgs' in this) {
+        this.listjs.sort(...this.sortArgs);
       }
     });
   }
@@ -140,6 +126,6 @@ class RessourceList {
   }
 
   replace(id, valueName, newValue) {
-      this.listjs.get('id', id)[0].values({[valueName]: newValue});
+    this.listjs.get('id', id)[0].values({[valueName]: newValue});
   }
 }
diff --git a/app/static/js/RessourceLists/UserList.js b/app/static/js/RessourceLists/UserList.js
index f1f7e42a..986685ba 100644
--- a/app/static/js/RessourceLists/UserList.js
+++ b/app/static/js/RessourceLists/UserList.js
@@ -1,20 +1,49 @@
 class UserList extends RessourceList {
+  static autoInit() {
+    for (let userListElement of document.querySelectorAll('.user-list:not(.no-autoinit)')) {
+      new UserList(userListElement);
+    }
+  }
+
   static options = {
+    initialHtmlGenerator: (id) => {
+      return `
+        <div class="input-field">
+          <i class="material-icons prefix">search</i>
+          <input id="${id}-search" class="search" type="search"></input>
+          <label for="${id}-search">Search user</label>
+        </div>
+        <table>
+          <thead>
+            <tr>
+              <th>Id</th>
+              <th>Username</th>
+              <th>Email</th>
+              <th>Last seen</th>
+              <th>Role</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody class="list"></tbody>
+        </table>
+        <ul class="pagination"></ul>
+      `.trim();
+    },
     item: `
-      <tr class="hoverable">
+      <tr class="clickable hoverable">
         <td><span class="id-1"></span></td>
         <td><span class="username"></span></td>
         <td><span class="email"></span></td>
         <td><span class="last-seen"></span></td>
         <td><span class="role"></span></td>
         <td class="right-align">
-          <a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
-          <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="edit" data-position="top" data-tooltip="Edit"><i class="material-icons">edit</i></a>
-          <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
+          <a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
+          <a class="action-button btn-floating waves-effect waves-light" data-action="edit"><i class="material-icons">edit</i></a>
+          <a class="action-button btn-floating waves-effect waves-light" data-action="view"><i class="material-icons">send</i></a>
         </td>
       </tr>
     `.trim(),
-    ressourceMapper: user => {
+    ressourceMapper: (user) => {
       return {
         'id': user.id,
         'id-1': user.id,
@@ -25,7 +54,7 @@ class UserList extends RessourceList {
         'role': user.role.name
       };
     },
-    sortValueName: 'member-since',
+    sortArgs: ['member-since', {order: 'desc'}],
     valueNames: [
       {data: ['id']},
       {data: ['member-since']},
@@ -37,8 +66,6 @@ class UserList extends RessourceList {
     ]
   };
 
-
-
   constructor(listElement, options = {}) {
     super(listElement, {...UserList.options, ...options});
   }
@@ -47,55 +74,28 @@ class UserList extends RessourceList {
     super._init(Object.values(users));
   }
 
-  onclick(event) {
-    let action;
-    let actionButtonElement;
-    let deleteModal;
-    let deleteModalElement;
-    let tmp;
-    let userElement;
-    let userId;
-
-    userElement = event.target.closest('tr[data-id]');
-    if (userElement === null) {return;}
-    userId = userElement.dataset.id;
-    actionButtonElement = event.target.closest('.action-button[data-action]');
-    action = (actionButtonElement === null) ? 'view' : actionButtonElement.dataset.action;
+  onClick(event) {
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    let userElement = event.target.closest('tr');
+    let userId = userElement.dataset.id;
     switch (action) {
-      case 'delete':
-        tmp = document.createElement('div');
-        tmp.innerHTML = `
-          <div class="modal">
-            <div class="modal-content">
-              <h4>Confirm user deletion</h4>
-              <p>Do you really want to delete user <b>${userId}</b>? All files will be permanently deleted!</p>
-            </div>
-            <div class="modal-footer">
-              <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
-              <a class="btn modal-close red waves-effect waves-light" href="/admin/users/${userId}/delete"><i class="material-icons left">delete</i>Delete</a>
-            </div>
-          </div>
-        `.trim();
-        deleteModalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
-        deleteModal = M.Modal.init(
-          deleteModalElement,
-          {
-            onCloseEnd: () => {
-              deleteModal.destroy();
-              deleteModalElement.remove();
-            }
-          }
-        );
-        deleteModal.open();
+      case 'delete': {
+        Utils.deleteUserRequest(userId);
+        if (userId === currentUserId) {window.location.href = '/';}
         break;
-      case 'edit':
+      }
+      case 'edit': {
         window.location.href = `/admin/users/${userId}/edit`;
         break;
-      case 'view':
+      }
+      case 'view': {
         window.location.href = `/admin/users/${userId}`;
         break;
-      default:
+      }
+      default: {
         break;
+      }
     }
   }
 }
diff --git a/app/static/js/UploadForm.js b/app/static/js/UploadForm.js
deleted file mode 100644
index 2a1df808..00000000
--- a/app/static/js/UploadForm.js
+++ /dev/null
@@ -1,125 +0,0 @@
-class UploadForm {
-  static autoInit() {
-    const nopaqueSubmitForms = document.querySelectorAll('.nopaque-upload-form');
-    let nopaqueSubmitForm;
-
-    for (nopaqueSubmitForm of nopaqueSubmitForms) {
-      new UploadForm(nopaqueSubmitForm);
-    }
-  }
-
-
-  constructor(formElement) {
-    this.formElement = formElement;
-    this.request = new XMLHttpRequest();
-
-    this.formElement.addEventListener('submit', (event) => {
-      event.preventDefault();
-      this.submit();
-    });
-  }
-
-  submit() {
-    const selectElements = this.formElement.querySelectorAll('select');
-    let abortElement;
-    let helperTextElement;
-    let helperTextElements;
-    let inputFieldElement;
-    let modal;
-    let modalElement;
-    let progressElement;
-    let selectElement;
-    let tmp;
-
-    // Check if select elements are filled out properly
-    for (selectElement of selectElements) {
-      if (selectElement.value === '') {
-        inputFieldElement = selectElement.closest('.input-field');
-        inputFieldElement.querySelector('.select-dropdown').classList.add('invalid');
-        helperTextElements = inputFieldElement.querySelectorAll('.helper-text');
-        for (helperTextElement of helperTextElements) {
-          helperTextElement.remove();
-        }
-        inputFieldElement.insertAdjacentHTML(
-          'beforeend',
-          '<span class="helper-text error-color-text">Please select an option.</span>'
-        );
-        return;
-      }
-    }
-
-    // Setup modal
-    tmp = document.createElement('div');
-    tmp.innerHTML = `
-      <div class="modal">
-        <div class="modal-content">
-          <h4><i class="material-icons left">file_upload</i>Uploading files...</h4>
-          <div class="progress">
-            <div class="determinate" style="width: 0%"></div>
-          </div>
-        </div>
-        <div class="modal-footer">
-          <a href="#!" class="btn red waves-effect waves-light abort">Cancel</a>
-        </div>
-      </div>
-    `.trim();
-    modalElement = document.querySelector('#modals').appendChild(tmp.firstChild);
-    modal = M.Modal.init(
-      modalElement,
-      {
-        dismissible: false,
-        onCloseEnd: () => {
-          modal.destroy();
-          modalElement.remove();
-        }
-      }
-    );
-    modal.open();
-
-    // Setup abort handling
-    abortElement = modalElement.querySelector('.abort');
-    abortElement.addEventListener('click', event => {this.request.abort();});
-    this.request.addEventListener('abort', event => {
-      this.request.abort();
-      modal.close();
-    });
-
-    // Setup load handling (after the request completed)
-    this.request.addEventListener('load', event => {
-      const response = JSON.parse(this.request.responseText);
-      let inputError;
-      let inputErrors;
-      let inputFieldElement;
-      let inputName;
-
-      if (this.request.status === 201) {
-        window.location.href = response.redirect_url;
-      }
-      if (this.request.status === 400) {
-        for ([inputName, inputErrors] of Object.entries(response)) {
-          inputFieldElement = this.formElement.querySelector(`input[name="${inputName}"], select[name="${inputName}"]`).closest('.input-field');
-          for (inputError of inputErrors) {
-            inputFieldElement.insertAdjacentHTML(
-              'beforeend',
-              `<span class="helper-text red-text">${inputError}</span>`
-            );
-          }
-        }
-      }
-      if (this.request.status === 500) {
-        location.reload();
-      }
-      modal.close();
-    });
-
-    // Setup progress handling
-    progressElement = modalElement.querySelector('.progress > .determinate');
-    this.request.upload.addEventListener('progress', event => {
-      const progress = Math.floor(100 * event.loaded / event.total);
-      progressElement.style.width = `${progress}%`;
-    });
-
-    this.request.open('POST', window.location.href);
-    this.request.send(new FormData(this.formElement));
-  }
-}
diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js
new file mode 100644
index 00000000..2e7cbd4c
--- /dev/null
+++ b/app/static/js/Utils.js
@@ -0,0 +1,326 @@
+class Utils {
+  static elementFromString(string) {
+    let tmpElement = document.createElement('div');
+    tmpElement.innerHTML = string.trim();
+    return tmpElement.firstChild;
+  }
+
+  static buildCorpusRequest(userId, corpusId) {
+    return new Promise((resolve, reject) => {
+      let corpus = app.data.users[userId].corpora[corpusId];
+
+      fetch(`/corpora/${corpus.id}/build`, {method: 'POST', headers: {Accept: 'application/json'}})
+        .then(
+          (response) => {
+            app.flash(`Corpus "${corpus.title}" marked for building`, 'corpus');
+            resolve(response);
+          },
+          (response) => {
+            if (response.status === 403) {app.flash('Forbidden', 'error');}
+            if (response.status === 404) {app.flash('Not Found', 'error');}
+            if (response.status === 409) {app.flash('Conflict', 'error');}
+            reject(response);
+          }
+        );
+    });
+  }
+
+  static deleteCorpusRequest(userId, corpusId) {
+    return new Promise((resolve, reject) => {
+      let corpus = app.data.users[userId].corpora[corpusId];
+
+      let modalElement = Utils.elementFromString(
+        `
+          <div class="modal">
+            <div class="modal-content">
+              <h4>Confirm job deletion</h4>
+              <p>Do you really want to delete the job <b>${corpus.title}</b>? All files will be permanently deleted!</p>
+            </div>
+            <div class="modal-footer">
+              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
+              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+            </div>
+          </div>
+        `
+      );
+      document.querySelector('#modals').appendChild(modalElement);
+      let modal = M.Modal.init(
+        modalElement,
+        {
+          dismissible: false,
+          onCloseEnd: () => {
+            modal.destroy();
+            modalElement.remove();
+          }
+        }
+      );
+      
+      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+      confirmElement.addEventListener('click', (event) => {
+        let corpusTitle = corpus.title;
+        fetch(`/corpora/${corpus.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
+          .then(
+            (response) => {
+              app.flash(`Corpus "${corpusTitle}" marked for deletion`, 'corpus');
+              resolve(response);
+            },
+            (response) => {
+              if (response.status === 403) {app.flash('Forbidden', 'error');}
+              if (response.status === 404) {app.flash('Not Found', 'error');}
+              reject(response);
+            }
+          );
+      });
+      modal.open();
+    });
+  }
+
+  static deleteCorpusFileRequest(userId, corpusId, corpusFileId) {
+    return new Promise((resolve, reject) => {
+      let corpus = app.data.users[userId].corpora[corpusId];
+      let corpusFile = corpus.files[corpusFileId];
+
+      let modalElement = Utils.elementFromString(
+        `
+          <div class="modal">
+            <div class="modal-content">
+              <h4>Confirm job deletion</h4>
+              <p>Do you really want to delete the job <b>${corpusFile.title}</b>? All files will be permanently deleted!</p>
+            </div>
+            <div class="modal-footer">
+              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
+              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+            </div>
+          </div>
+        `
+      );
+      document.querySelector('#modals').appendChild(modalElement);
+      let modal = M.Modal.init(
+        modalElement,
+        {
+          dismissible: false,
+          onCloseEnd: () => {
+            modal.destroy();
+            modalElement.remove();
+          }
+        }
+      );
+
+      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+      confirmElement.addEventListener('click', (event) => {
+        let corpusFileTitle = corpusFile.title;
+        fetch(`/corpora/${corpusId}/files/${corpusFileId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
+          .then(
+            (response) => {
+              app.flash(`Corpus file "${corpusFileTitle}" marked for deletion`, 'corpus');
+              resolve(response);
+            },
+            (response) => {
+              if (response.status === 403) {app.flash('Forbidden', 'error');}
+              if (response.status === 404) {app.flash('Not Found', 'error');}
+              reject(response);
+            }
+          );
+      });
+      modal.open();
+    });
+  }
+
+  static deleteJobRequest(userId, jobId) {
+    return new Promise((resolve, reject) => {
+      let job = app.data.users[userId].jobs[jobId];
+
+      let modalElement = Utils.elementFromString(
+        `
+          <div class="modal">
+            <div class="modal-content">
+              <h4>Confirm job deletion</h4>
+              <p>Do you really want to delete the job <b>${job.title}</b>? All files will be permanently deleted!</p>
+            </div>
+            <div class="modal-footer">
+              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
+              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+            </div>
+          </div>
+        `
+      );
+      document.querySelector('#modals').appendChild(modalElement);
+      let modal = M.Modal.init(
+        modalElement,
+        {
+          dismissible: false,
+          onCloseEnd: () => {
+            modal.destroy();
+            modalElement.remove();
+          }
+        }
+      );
+
+      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+      confirmElement.addEventListener('click', (event) => {
+        let jobTitle = job.title;
+        fetch(`/jobs/${job.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
+          .then(
+            (response) => {
+              app.flash(`Job "${jobTitle}" marked for deletion`, 'job');
+              resolve(response);
+            },
+            (response) => {
+              if (response.status === 403) {app.flash('Forbidden', 'error');}
+              if (response.status === 404) {app.flash('Not Found', 'error');}
+              reject(response);
+            }
+          );
+      });
+      modal.open();
+    });
+  }
+
+  static getJobLogRequest(userId, jobId) {
+    return new Promise((resolve, reject) => {
+      let job = app.data.users[userId].jobs[jobId];
+
+      fetch(`/jobs/${job.id}/log`, {method: 'GET', headers: {Accept: 'application/json, text/plain'}})
+        .then(
+          (response) => {
+            resolve(response);
+            return response.text();
+          },
+          (response) => {
+            if (response.status === 403) {app.flash('Forbidden', 'error');}
+            if (response.status === 404) {app.flash('Not Found', 'error');}
+            reject(response);
+          }
+        )
+        .then(
+          (text) => {
+            let modalElement = Utils.elementFromString(
+              `
+                <div class="modal">
+                  <div class="modal-content">
+                    <h4>Job logs</h4>
+                    <pre><code>${text}</code></pre>
+                  </div>
+                  <div class="modal-footer">
+                    <a class="btn modal-close waves-effect waves-light">Close</a>
+                  </div>
+                </div>
+              `
+            );
+            document.querySelector('#modals').appendChild(modalElement);
+            let modal = M.Modal.init(
+              modalElement,
+              {
+                onCloseEnd: () => {
+                  modal.destroy();
+                  modalElement.remove();
+                }
+              }
+            );
+            modal.open();
+          }
+        );
+    });
+  }
+
+  static restartJobRequest(userId, jobId) {
+    return new Promise((resolve, reject) => {
+      let job = app.data.users[userId].jobs[jobId];
+
+      let modalElement = Utils.elementFromString(
+        `
+          <div class="modal">
+            <div class="modal-content">
+              <h4>Confirm job restart</h4>
+              <p>Do you really want to restart the job <b>${job.title}</b>? All log and result files will be permanently deleted.</p>
+            </div>
+            <div class="modal-footer">
+              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
+              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Restart</a>
+            </div>
+          </div>
+        `
+      );
+      document.querySelector('#modals').appendChild(modalElement);
+      let modal = M.Modal.init(
+        modalElement,
+        {
+          dismissible: false,
+          onCloseEnd: () => {
+            modal.destroy();
+            modalElement.remove();
+          }
+        }
+      );
+
+      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+      confirmElement.addEventListener('click', (event) => {
+        let jobTitle = job.title;
+        fetch(`/jobs/${job.id}/restart`, {method: 'POST', headers: {Accept: 'application/json'}})
+          .then(
+            (response) => {
+              app.flash(`Job "${jobTitle}" restarted.`, 'job');
+              resolve(response);
+            },
+            (response) => {
+              if (response.status === 403) {app.flash('Forbidden', 'error');}
+              if (response.status === 404) {app.flash('Not Found', 'error');}
+              if (response.status === 409) {app.flash('Conflict', 'error');}
+              reject(response);
+            }
+          );
+      });
+      modal.open();
+    });
+  }
+
+  static deleteUserRequest(userId) {
+    return new Promise((resolve, reject) => {
+      let user = app.data.users[userId];
+
+      let modalElement = Utils.elementFromString(
+        `
+          <div class="modal">
+            <div class="modal-content">
+              <h4>Confirm job deletion</h4>
+              <p>Do you really want to delete the user <b>${user.username}</b>? All files will be permanently deleted!</p>
+            </div>
+            <div class="modal-footer">
+              <a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
+              <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+            </div>
+          </div>
+        `
+      );
+      document.querySelector('#modals').appendChild(modalElement);
+      let modal = M.Modal.init(
+        modalElement,
+        {
+          dismissible: false,
+          onCloseEnd: () => {
+            modal.destroy();
+            modalElement.remove();
+          }
+        }
+      );
+
+      let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+      confirmElement.addEventListener('click', (event) => {
+        let userName = user.username;
+        fetch(`/users/${user.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
+          .then(
+            (response) => {
+              app.flash(`User "${userName}" marked for deletion`);
+              resolve(response);
+            },
+            (response) => {
+              if (response.status === 403) {app.flash('Forbidden', 'error');}
+              if (response.status === 404) {app.flash('Not Found', 'error');}
+              reject(response);
+            }
+          );
+      });
+      modal.open();
+    });
+  }
+}
diff --git a/app/templates/_navbar.html.j2 b/app/templates/_navbar.html.j2
index 63631a01..e2d4db64 100644
--- a/app/templates/_navbar.html.j2
+++ b/app/templates/_navbar.html.j2
@@ -29,7 +29,7 @@
 <ul class="dropdown-content" id="nav-more-dropdown">
   <li><a href="{{ url_for('main.user_manual') }}"><i class="material-icons left">help</i>Manual</a></li>
   {% if current_user.is_authenticated %}
-  <li><a href="{{ url_for('settings.index') }}"><i class="material-icons left">settings</i>Settings</a></li>
+  <li><a href="{{ url_for('settings.settings') }}"><i class="material-icons left">settings</i>Settings</a></li>
   <li class="divider" tabindex="-1"></li>
   <li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
   {% else %}
diff --git a/app/templates/_roadmap.html.j2 b/app/templates/_roadmap.html.j2
index 3e87d607..50cc18cd 100644
--- a/app/templates/_roadmap.html.j2
+++ b/app/templates/_roadmap.html.j2
@@ -11,19 +11,19 @@
       <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
       <li class="tab"><a{%if request.path == url_for('services.spacy_nlp_pipeline') %} class="active"{% endif %} href="{{ url_for('services.spacy_nlp_pipeline') }}" target="_self">NLP</a></li>
       <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-      <li class="tab"><a{%if request.path == url_for('corpora.add_corpus') %} class="active"{% endif %} href="{{ url_for('corpora.add_corpus') }}" target="_self">Add corpus</a></li>
+      <li class="tab"><a{%if request.path == url_for('corpora.create_corpus') %} class="active"{% endif %} href="{{ url_for('corpora.create_corpus') }}" target="_self">Create corpus</a></li>
       <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
       {% if corpus %}
-      <li class="tab"><a{%if request.path == url_for('corpora.add_corpus_file', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.add_corpus_file', corpus_id=corpus.id) }}" target="_self">Add corpus file(s)</a></li>
+      <li class="tab"><a{%if request.path == url_for('corpora.create_corpus_file', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.create_corpus_file', corpus_id=corpus.id) }}" target="_self">Create corpus file(s)</a></li>
       {% else %}
-      <li class="tab disabled tooltipped" data-tooltip="Select a corpus first" target="_self"><a>Add corpus file(s)</a></li>
+      <li class="tab disabled tooltipped" data-tooltip="Select a corpus first" target="_self"><a>Create corpus file(s)</a></li>
       {% endif %}
       <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
       {% if corpus %}
       {% if corpus.files.all() %}
       <li class="tab"><a{%if request.path == url_for('corpora.analyse_corpus', corpus_id=corpus.id) %} class="active"{% endif %} href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}" target="_self">Corpus analysis</a></li>
       {% else %}
-      <li class="tab disabled tooltipped" data-tooltip="Add at least one corpus file first"><a>Corpus analysis</a></li>
+      <li class="tab disabled tooltipped" data-tooltip="Create at least one corpus file first"><a>Corpus analysis</a></li>
       {% endif %}
       {% else %}
       <li class="tab disabled tooltipped" data-tooltip="Select a corpus first"><a>Corpus analysis</a></li>
diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2
index eb1886dc..c66314fe 100644
--- a/app/templates/_scripts.html.j2
+++ b/app/templates/_scripts.html.j2
@@ -5,11 +5,14 @@
   filters='rjsmin',
   output='gen/app.%(version)s.js',
   'js/App.js',
+  'js/Utils.js',
+  'js/Forms/Form.js',
+  'js/Forms/CreateCorpusFileForm.js',
+  'js/Forms/CreateJobForm.js',
   'js/CorpusAnalysis/CQiClient.js',
   'js/CorpusAnalysis/CorpusAnalysisApp.js',
   'js/CorpusAnalysis/CorpusAnalysisConcordance.js',
   'js/CorpusAnalysis/CorpusAnalysisReader.js',
-  'js/JobStatusNotifier.js',
   'js/RessourceDisplays/RessourceDisplay.js',
   'js/RessourceDisplays/CorpusDisplay.js',
   'js/RessourceDisplays/JobDisplay.js',
@@ -20,8 +23,7 @@
   'js/RessourceLists/JobInputList.js',
   'js/RessourceLists/JobResultList.js',
   'js/RessourceLists/QueryResultList.js',
-  'js/RessourceLists/UserList.js',
-  'js/UploadForm.js'
+  'js/RessourceLists/UserList.js'
 %}
 <script src="{{ ASSET_URL }}"></script>
 {%- endassets %}
@@ -29,10 +31,10 @@
   const app = new App();
   {%- if current_user.is_authenticated %}
   const currentUserId = {{ current_user.hashid|tojson }};
-  const jobStatusNotifier = new JobStatusNotifier(currentUserId);
 
   // Initialize components for current user
   app.subscribeUser(currentUserId).catch((error) => {throw JSON.stringify(error);});
+  app.getUser(currentUserId, true, true);
   {%- endif %}
 
   // Disable all option elements with no value
@@ -53,7 +55,7 @@
     {alignment: 'right', constrainWidth: false, coverTrigger: false}
   );
   RessourceList.autoInit();
-  UploadForm.autoInit();
+  Form.autoInit();
 
   // Display flashed messages
   for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) {
diff --git a/app/templates/_sidenav.html.j2 b/app/templates/_sidenav.html.j2
index 92592a7f..b302e9ca 100644
--- a/app/templates/_sidenav.html.j2
+++ b/app/templates/_sidenav.html.j2
@@ -23,7 +23,7 @@
   <li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icon" data-service="corpus-analysis"></i>Corpus analysis</a></li>
   <li><div class="divider"></div></li>
   <li><a class="subheader">Account</a></li>
-  <li><a href="{{ url_for('settings.index') }}"><i class="material-icons">settings</i>Settings</a></li>
+  <li><a href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>Settings</a></li>
   <li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
   {% if current_user.can(Permission.ADMINISTRATE) or current_user.can(Permission.USE_API) %}
   <li><div class="divider"></div></li>
@@ -32,9 +32,6 @@
   <li><a href="{{ url_for('admin.index') }}"><i class="material-icons">admin_panel_settings</i>Administration</a></li>
   {% endif %}
   {% if current_user.can(Permission.CONTRIBUTE) %}
-  <li><a href="{{ url_for('contributions.index') }}"><i class="material-icons">new_label</i>Contribute</a></li>
-  {% endif %}
-  {% if current_user.can(Permission.USE_API) %}
-  <li><a href="{{ url_for('api.doc') }}"><i class="material-icons">api</i>API</a></li>
+  <li><a href="{{ url_for('contributions.contributions') }}"><i class="material-icons">new_label</i>Contribute</a></li>
   {% endif %}
 </ul>
diff --git a/app/templates/_styles.html.j2 b/app/templates/_styles.html.j2
index de2bff7a..2c1ea8f8 100644
--- a/app/templates/_styles.html.j2
+++ b/app/templates/_styles.html.j2
@@ -8,6 +8,7 @@
   filters='pyscss',
   output='gen/app.%(version)s.css',
   'css/colors.scss',
+  'css/helpers.scss',
   'css/style.css'
 %}
 <link href="{{ ASSET_URL }}" media="screen,projection" rel="stylesheet">
diff --git a/app/templates/admin/edit_user.html.j2 b/app/templates/admin/edit_user.html.j2
index f44ccb7c..45c27c6a 100644
--- a/app/templates/admin/edit_user.html.j2
+++ b/app/templates/admin/edit_user.html.j2
@@ -15,8 +15,8 @@
         <div class="card">
           <div class="card-content">
             <span class="card-title">General settings</span>
-            {{ wtf.render_field(edit_general_settings_form.username, data_length='64', material_icon='person') }}
-            {{ wtf.render_field(edit_general_settings_form.email, data_length='254', material_icon='email') }}
+            {{ wtf.render_field(edit_general_settings_form.username, material_icon='person') }}
+            {{ wtf.render_field(edit_general_settings_form.email, material_icon='email') }}
           </div>
           <div class="card-action">
             <div class="right-align">
diff --git a/app/templates/admin/user.html.j2 b/app/templates/admin/user.html.j2
index 9af7e4e0..b4b0e303 100644
--- a/app/templates/admin/user.html.j2
+++ b/app/templates/admin/user.html.j2
@@ -37,52 +37,20 @@
       </div>
     </div>
 
-    <div class="col s12 l6 nopaque-ressource-list" data-ressource-type="Corpus" data-user-id="{{ user.hashid }}">
+    <div class="col s12 l6">
       <h3>Corpora</h3>
       <div class="card">
         <div class="card-content">
-          <div class="input-field">
-            <i class="material-icons prefix">search</i>
-            <input id="search-corpus" class="search" type="search"></input>
-            <label for="search-corpus">Search corpus</label>
-          </div>
-          <table>
-            <thead>
-              <tr>
-                <th></th>
-                <th>Title and Description</th>
-                <th>Status</th>
-                <th></th>
-              </tr>
-            </thead>
-            <tbody class="list"></tbody>
-          </table>
-          <ul class="pagination"></ul>
+          <div class="corpus-list" data-user-id="{{ user.hashid }}"></div>
         </div>
       </div>
     </div>
 
-    <div class="col s12 l6 nopaque-ressource-list" data-ressource-type="Job" data-user-id="{{ user.hashid }}">
+    <div class="col s12 l6">
       <h3>Jobs</h3>
       <div class="card">
         <div class="card-content">
-          <div class="input-field">
-            <i class="material-icons prefix">search</i>
-            <input id="search-job" class="search" type="search"></input>
-            <label for="search-job">Search job</label>
-          </div>
-          <table>
-            <thead>
-              <tr>
-                <th>Service</th>
-                <th>Title and Description</th>
-                <th>Status</th>
-                <th></th>
-              </tr>
-            </thead>
-            <tbody class="list"></tbody>
-          </table>
-          <ul class="pagination"></ul>
+          <div class="job-list" data-user-id="{{ user.hashid }}"></div>
         </div>
       </div>
     </div>
diff --git a/app/templates/admin/users.html.j2 b/app/templates/admin/users.html.j2
index 37899866..75254b0e 100644
--- a/app/templates/admin/users.html.j2
+++ b/app/templates/admin/users.html.j2
@@ -8,28 +8,10 @@
       <h1 id="title">{{ title }}</h1>
     </div>
 
-    <div class="col s12 nopaque-ressource-list no-autoinit" data-ressource-type="User" id="users">
+    <div class="col s12">
       <div class="card">
         <div class="card-content">
-          <div class="input-field">
-            <i class="material-icons prefix">search</i>
-            <input id="search-user" class="search" type="text"></input>
-            <label for="search-user">Search user</label>
-          </div>
-          <table>
-            <thead>
-              <tr>
-                <th>Id</th>
-                <th>Username</th>
-                <th>Email</th>
-                <th>Last seen</th>
-                <th>Role</th>
-                <th></th>
-              </tr>
-            </thead>
-            <tbody class="list"></tbody>
-          </table>
-          <ul class="pagination"></ul>
+          <div class="user-list no-autoinit"></div>
         </div>
       </div>
     </div>
@@ -40,7 +22,11 @@
 {% block scripts %}
 {{ super() }}
 <script>
-  let userList = new UserList(document.querySelector('#users'));
-  userList.init({{ dict_users|tojson }});
+  for (let user of {{ json_users|tojson }}) {
+    if (user.id in app.data.users) {continue;}
+    app.data.users[user.id] = user;
+  }
+  let userList = new UserList(document.querySelector('.user-list'));
+  userList.init(app.data.users);
 </script>
 {% endblock scripts %}
diff --git a/app/templates/auth/login.html.j2 b/app/templates/auth/login.html.j2
index aea71382..213c0a5f 100644
--- a/app/templates/auth/login.html.j2
+++ b/app/templates/auth/login.html.j2
@@ -2,53 +2,30 @@
 {% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
-{% block styles %}
-{{ super() }}
-<style>
-  main {
-    background-image: url("{{ url_for('static', filename='images/parallax_lq/04_german_text_book_paper.jpg') }}");
-    background-repeat: no-repeat;
-    background-size: cover;
-  }
-</style>
-{% endblock styles %}
 
 {% block page_content %}
 <div class="container">
   <div class="row">
-    <div class="col s12 m4">
-      <div class="card medium">
-        <div class="card-content">
-          <h1 id="title">{{ title }}</h1>
-          <p>Want to boost your research and get going? nopaque is free and no download is needed. Register now!</p>
-        </div>
-        <div class="card-action right-align">
-          <a class="btn" href="{{ url_for('.register') }}"><i class="material-icons left">person_add</i>Register</a>
-        </div>
-      </div>
-    </div>
+    <div class="col s12 m8 offset-m2">
+      <h1 id="title">{{ title }}</h1>
+      <p>Want to boost your research and get going? Nopaque is free and no download is needed. <a href="{{ url_for('.register') }}">Register now</a>!</p>
 
-    <div class="col s12 m8">
-      <div class="card medium">
-        <form method="POST">
-          <div class="card-content">
-            {{ form.hidden_tag() }}
-            {{ wtf.render_field(form.user, material_icon='person') }}
-            {{ wtf.render_field(form.password, material_icon='vpn_key') }}
-            <div class="row" style="margin-bottom: 0;">
-              <div class="col s6 left-align">
-                <a href="{{ url_for('.reset_password_request') }}">Forgot your password?</a>
-              </div>
-              <div class="col s6 right-align">
-                {{ wtf.render_field(form.remember_me) }}
-              </div>
+      <form method="POST">
+        <div class="card-panel">
+          {{ form.hidden_tag() }}
+          {{ wtf.render_field(form.user, material_icon='person') }}
+          {{ wtf.render_field(form.password, material_icon='vpn_key') }}
+          <div class="row">
+            <div class="col s6 left-align">
+              <a href="{{ url_for('.reset_password_request') }}">Forgot your password?</a>
+            </div>
+            <div class="col s6 right-align">
+              {{ wtf.render_field(form.remember_me) }}
             </div>
           </div>
-          <div class="card-action right-align">
-            {{ wtf.render_field(form.submit, material_icon='send') }}
-          </div>
-        </form>
-      </div>
+          {{ wtf.render_field(form.submit, material_icon='send', class_='width-100') }}
+        </div>
+      </form>
     </div>
   </div>
 </div>
diff --git a/app/templates/auth/register.html.j2 b/app/templates/auth/register.html.j2
index 5c195e56..69a01912 100644
--- a/app/templates/auth/register.html.j2
+++ b/app/templates/auth/register.html.j2
@@ -2,47 +2,31 @@
 {% from "auth/_breadcrumbs.html.j2" import breadcrumbs with context %}
 {% import "materialize/wtf.html.j2" as wtf %}
 
-{% block styles %}
-{{ super() }}
-<style>
-  main {
-    background-image: url("{{ url_for('static', filename='images/parallax_lq/02_concept_document_focus_letter.jpg') }}");
-    background-repeat: no-repeat;
-    background-size: cover;
-  }
-</style>
-{% endblock styles %}
 
 {% block page_content %}
 <div class="container">
   <div class="row">
-    <div class="col s12 m4">
-      <div class="card medium">
-        <div class="card-content">
-          <h1 id="title">{{ title }}</h1>
-          <p>Simply enter a username and password to receive your registration email. After that you can start right away.</p>
-          <p>It goes without saying that the <a href="{{ url_for('main.privacy_policy') }}">General Data Protection Regulation</a> applies, only necessary data is stored.</p>
-          <p>Please also read our <a href="{{ url_for('main.terms_of_use') }}">terms of use</a> before signing up for nopaque!</p>
-        </div>
-      </div>
-    </div>
+    <div class="col s12 m8 offset-m2">
+      <h1 id="title">{{ title }}</h1>
+      <p>
+        Simply enter a username and password to receive your registration email.
+        After that you can start right away. It goes without saying that the
+        <a href="{{ url_for('main.privacy_policy') }}">General Data Protection Regulation</a>
+        applies, only necessary data is stored. Please also read our
+        <a href="{{ url_for('main.terms_of_use') }}">terms of use</a> before
+        signing up for nopaque!
+      </p>
 
-    <div class="col s12 m8">
-      <div class="card medium">
-        <form method="POST">
-          <div class="card-content">
-            {{ form.hidden_tag() }}
-            {{ wtf.render_field(form.username, material_icon='person') }}
-            {{ wtf.render_field(form.password, material_icon='vpn_key') }}
-            {{ wtf.render_field(form.password_confirmation, material_icon='vpn_key') }}
-            {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }}
-          </div>
-          <div class="card-action right-align">
-            {{ wtf.render_field(form.submit, material_icon='send') }}
-          </div>
-        </form>
-      </div>
-    </div>
+      <form method="POST">
+        <div class="card-panel">
+          {{ form.hidden_tag() }}
+          {{ wtf.render_field(form.username, material_icon='person') }}
+          {{ wtf.render_field(form.password, material_icon='vpn_key') }}
+          {{ wtf.render_field(form.password_2, material_icon='vpn_key') }}
+          {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }}
+          {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }}
+        </div>
+      </form>
   </div>
 </div>
 {% endblock page_content %}
diff --git a/app/templates/auth/reset_password.html.j2 b/app/templates/auth/reset_password.html.j2
index a0f4c32b..06f11059 100644
--- a/app/templates/auth/reset_password.html.j2
+++ b/app/templates/auth/reset_password.html.j2
@@ -5,27 +5,18 @@
 {% block page_content %}
 <div class="container">
   <div class="row">
-    <div class="col s12">
+    <div class="col s12 m8 offset-m2">
       <h1 id="title">{{ title }}</h1>
-    </div>
-
-    <div class="col s12 m4">
       <p>Enter a new password and confirm it! After that, the entered password is your new one!</p>
-    </div>
 
-    <div class="col s12 m8">
-      <div class="card">
-        <form method="POST">
-          <div class="card-content">
-            {{ form.hidden_tag() }}
-            {{ wtf.render_field(form.password) }}
-            {{ wtf.render_field(form.password_confirmation) }}
-          </div>
-          <div class="card-action right-align">
-            {{ wtf.render_field(form.submit, material_icon='send') }}
-          </div>
-        </form>
-      </div>
+      <form method="POST">
+        <div class="card-panel">
+          {{ form.hidden_tag() }}
+          {{ wtf.render_field(form.password) }}
+          {{ wtf.render_field(form.password_2) }}
+          {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }}
+        </div>
+      </form>
     </div>
   </div>
 </div>
diff --git a/app/templates/auth/reset_password_request.html.j2 b/app/templates/auth/reset_password_request.html.j2
index b91cd59b..a94d18da 100644
--- a/app/templates/auth/reset_password_request.html.j2
+++ b/app/templates/auth/reset_password_request.html.j2
@@ -5,26 +5,17 @@
 {% block page_content %}
 <div class="container">
   <div class="row">
-    <div class="col s12">
+    <div class="col s12 m8 offset-m2">
       <h1 id="title">{{ title }}</h1>
-    </div>
-
-    <div class="col s12 m4">
       <p>After entering your email address you will receive instructions on how to reset your password.</p>
-    </div>
 
-    <div class="col s12 m8">
-      <div class="card">
-        <form method="POST">
-          <div class="card-content">
-            {{ form.hidden_tag() }}
-            {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }}
-          </div>
-          <div class="card-action right-align">
-            {{ wtf.render_field(form.submit, material_icon='send') }}
-          </div>
-        </form>
-      </div>
+      <form method="POST">
+        <div class="card-panel">
+          {{ form.hidden_tag() }}
+          {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }}
+          {{ wtf.render_field(form.submit, class_='width-100', material_icon='send') }}
+        </div>
+      </form>
     </div>
   </div>
 </div>
diff --git a/app/templates/auth/unconfirmed.html.j2 b/app/templates/auth/unconfirmed.html.j2
index db9bf8c4..26384ecc 100644
--- a/app/templates/auth/unconfirmed.html.j2
+++ b/app/templates/auth/unconfirmed.html.j2
@@ -6,20 +6,13 @@
   <div class="row">
     <div class="col s12">
       <h1 id="title">{{ title }}</h1>
-    </div>
-
-    <div class="col s12">
-      <div class="card">
-        <div class="card-content">
-          <span class="card-title">Hello, {{ current_user.username }}!</span>
-          <p><b>You have not confirmed your account yet.</b></p>
-          <p>Before you can access this site you need to confirm your account. Check your inbox, you should have received an email with a confirmation link.</p>
-          <p>Need another confirmation email? Click the button below!</p>
-        </div>
-        <div class="card-action right-align">
-          <a class="btn" href="{{ url_for('.resend_confirmation') }}">Resend confirmation mail</a>
-        </div>
-      </div>
+      <p>Hello, <b>{{ current_user.username }}</b>.</p>
+      <p>
+        You have not confirmed your account yet. Before you can access this
+        site you need to confirm your account. Check your inbox, you should
+        have received an email with a confirmation link.
+      </p>
+      <p>Need another confirmation email? <a href="{{ url_for('.confirm_request') }}">Get a new one</a>.</p>
     </div>
   </div>
 </div>
diff --git a/app/templates/corpora/_breadcrumbs.html.j2 b/app/templates/corpora/_breadcrumbs.html.j2
index d91bc8c3..af6d2b78 100644
--- a/app/templates/corpora/_breadcrumbs.html.j2
+++ b/app/templates/corpora/_breadcrumbs.html.j2
@@ -2,8 +2,8 @@
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
 <li class="tab"><a href="{{ url_for('main.dashboard', _anchor='jobs') }}" target="_self">My corpora</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('.add_corpus') %}
-<li class="tab"><a class="active" href="{{ url_for('.add_corpus') }}" target="_self">{{ title }}</a></li>
+{% if request.path == url_for('.create_corpus') %}
+<li class="tab"><a class="active" href="{{ url_for('.create_corpus') }}" target="_self">{{ title }}</a></li>
 {% elif request.path == url_for('.import_corpus') %}
 <li class="tab"><a class="active" href="{{ url_for('.import_corpus') }}" target="_self">{{ title }}</a></li>
 {% elif request.path == url_for('.corpus', corpus_id=corpus.id) %}
@@ -12,12 +12,12 @@
 <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
 <li class="tab"><a class="active" href="{{ url_for('.analyse_corpus', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li>
-{% elif request.path == url_for('.add_corpus_file', corpus_id=corpus.id) %}
+{% elif request.path == url_for('.create_corpus_file', corpus_id=corpus.id) %}
 <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
 <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id, _anchor='files') }}" target="_self">Corpus files</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-<li class="tab"><a class="active" href="{{ url_for('.add_corpus_file', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li>
+<li class="tab"><a class="active" href="{{ url_for('.create_corpus_file', corpus_id=corpus.id) }}" target="_self">{{ title }}</a></li>
 {% elif request.path == url_for('.corpus_file', corpus_file_id=corpus_file.id, corpus_id=corpus.id) %}
 <li class="tab"><a href="{{ url_for('.corpus', corpus_id=corpus.id) }}" target="_self">{{ corpus.title }}</a></li>
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2
index 177d83b4..d64b7084 100644
--- a/app/templates/corpora/corpus.html.j2
+++ b/app/templates/corpora/corpus.html.j2
@@ -65,38 +65,21 @@
         </div>
         <div class="card-action right-align">
           <a class="btn corpus-analyse-trigger disabled waves-effect waves-light" href="{{ url_for('corpora.analyse_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">search</i>Analyze</a>
-          <a class="btn corpus-build-trigger disabled waves-effect waves-light" href="{{ url_for('corpora.build_corpus', corpus_id=corpus.id) }}"><i class="nopaque-icons left">K</i>Build</a>
+          <a class="action-button btn disabled waves-effect waves-light" data-action="build-request"><i class="nopaque-icons left">K</i>Build</a>
           <a class="btn disabled export-corpus-trigger waves-effect waves-light" href="{{ url_for('corpora.export_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">import_export</i>Export</a>
-          <a class="btn modal-trigger red waves-effect waves-light" data-target="delete-corpus-modal"><i class="material-icons left">delete</i>Delete</a>
+          <a class="action-button btn red waves-effect waves-light" data-action="delete-request"><i class="material-icons left">delete</i>Delete</a>
         </div>
       </div>
     </div>
 
-    <div class="col s12 nopaque-ressource-list" data-corpus-id="{{ corpus.hashid }}" data-ressource-type="CorpusFile" data-user-id="{{ corpus.user.hashid }}">
+    <div class="col s12">
       <div class="card">
         <div class="card-content">
           <span class="card-title" id="files">Corpus files</span>
-          <div class="input-field">
-            <i class="material-icons prefix">search</i>
-            <input class="search" id="search-corpus-files" type="search"></input>
-            <label for="search-corpus-files">Search corpus files</label>
-          </div>
-          <table>
-            <thead>
-              <tr>
-                <th>Filename</th>
-                <th>Author</th>
-                <th>Title</th>
-                <th>Publishing year</th>
-                <th></th>
-              </tr>
-            </thead>
-            <tbody class="list"></tbody>
-          </table>
-          <ul class="pagination"></ul>
+          <div class="corpus-file-list" data-user-id="{{ corpus.user.hashid }}" data-corpus-id="{{ corpus.hashid }}"></div>
         </div>
         <div class="card-action right-align">
-          <a href="{{ url_for('corpora.add_corpus_file', corpus_id=corpus.id) }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Add corpus file</a>
+          <a href="{{ url_for('corpora.create_corpus_file', corpus_id=corpus.id) }}" class="btn waves-effect waves-light"><i class="material-icons left">add</i>Add corpus file</a>
         </div>
       </div>
     </div>
@@ -104,20 +87,6 @@
 </div>
 {% endblock page_content %}
 
-{% block modals %}
-{{ super() }}
-<div id="delete-corpus-modal" class="modal">
-  <div class="modal-content">
-    <h4>Confirm corpus deletion</h4>
-    <p>Do you really want to delete the corpus <span class="corpus-title"></span>? All files will be permanently deleted!</p>
-  </div>
-  <div class="modal-footer">
-    <a class="btn modal-close waves-effect waves-light" href="#!">Cancel</a>
-    <a class="btn modal-close red waves-effect waves-light" href="{{ url_for('corpora.delete_corpus', corpus_id=corpus.id) }}"><i class="material-icons left">delete</i>Delete</a>
-  </div>
-</div>
-{% endblock modals %}
-
 {% block scripts %}
 {{ super() }}
 <script>
diff --git a/app/templates/corpora/add_corpus.html.j2 b/app/templates/corpora/create_corpus.html.j2
similarity index 100%
rename from app/templates/corpora/add_corpus.html.j2
rename to app/templates/corpora/create_corpus.html.j2
diff --git a/app/templates/corpora/add_corpus_file.html.j2 b/app/templates/corpora/create_corpus_file.html.j2
similarity index 66%
rename from app/templates/corpora/add_corpus_file.html.j2
rename to app/templates/corpora/create_corpus_file.html.j2
index 725ee0cb..55b078e4 100644
--- a/app/templates/corpora/add_corpus_file.html.j2
+++ b/app/templates/corpora/create_corpus_file.html.j2
@@ -13,11 +13,10 @@
 
     <div class="col s12 m4">
       <p>Fill out the following form to add a corpus file in verticalized text format (.vrt).</p>
-      <p><b>Do not use the .stand-off.vrt file!</b></p>
     </div>
 
     <div class="col s12 m8">
-      <form class="nopaque-upload-form" data-progress-modal="progress-modal">
+      <form class="create-corpus-file-form" enctype="multipart/form-data" method="POST">
         <div class="card">
           <div class="card-content">
             {{ form.hidden_tag() }}
@@ -52,39 +51,8 @@
             </div>
           </li>
         </ul>
-        <br>
-        <ul class="collapsible hoverable">
-          <li>
-            <div class="collapsible-header"><i class="material-icons">add</i>Add metadata with BibTex</div>
-            <div class="collapsible-body">
-              <span>
-                <div class="row">
-                  <div class="col s12">
-
-                  </div>
-                </div>
-              </span>
-            </div>
-          </li>
-        </ul>
       </form>
     </div>
   </div>
 </div>
 {% endblock page_content %}
-
-
-{% block modals %}
-{{ super() }}
-<div id="progress-modal" class="modal">
-  <div class="modal-content">
-    <h4><i class="material-icons left">file_upload</i>Uploading files...</h4>
-    <div class="progress">
-      <div class="determinate" style="width: 0%"></div>
-    </div>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="modal-close waves-effect waves-light btn red abort-request">Cancel</a>
-  </div>
-</div>
-{% endblock modals %}
diff --git a/app/templates/errors/403.html.j2 b/app/templates/errors/403.html.j2
deleted file mode 100644
index db286454..00000000
--- a/app/templates/errors/403.html.j2
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html.j2" %}
-
-{% block page_content %}
-<div class="container">
-  <h1 id="title">{{ title }}</h1>
-  <p class="light">{{ request.path }}</p>
-  <p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
-</div>
-
-<div class="modal" id="more-information-modal">
-  <div class="modal-content">
-    <h2>About the "{{ title }}" error</h2>
-    <p>The request contained valid data and was understood by the server, but the server is refusing action. This may be due to the user not having the necessary permissions for a resource or needing an account of some sort, or attempting a prohibited action (e.g. creating a duplicate record where only one is allowed). This code is also typically used if the request provided authentication by answering the WWW-Authenticate header field challenge, but the server did not accept that authentication. The request should not be repeated.</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
-  </div>
-</div>
-{% endblock page_content %}
diff --git a/app/templates/errors/404.html.j2 b/app/templates/errors/404.html.j2
deleted file mode 100644
index 62006da9..00000000
--- a/app/templates/errors/404.html.j2
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html.j2" %}
-
-{% block page_content %}
-<div class="container">
-  <h1 id="title">{{ title }}</h1>
-  <p class="light">{{ request.path }}</p>
-  <p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
-</div>
-
-<div class="modal" id="more-information-modal">
-  <div class="modal-content">
-    <h2>About the "{{ title }}" error</h2>
-    <p>The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
-  </div>
-</div>
-{% endblock page_content %}
diff --git a/app/templates/errors/413.html.j2 b/app/templates/errors/413.html.j2
deleted file mode 100644
index 6e4e3e7e..00000000
--- a/app/templates/errors/413.html.j2
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html.j2" %}
-
-{% block page_content %}
-<div class="container">
-  <h1 id="title">{{ title }}</h1>
-  <p class="light">{{ request.path }}</p>
-  <p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
-</div>
-
-<div class="modal" id="more-information-modal">
-  <div class="modal-content">
-    <h2>About the "{{ title }}" error</h2>
-    <p>The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large".</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
-  </div>
-</div>
-{% endblock page_content %}
diff --git a/app/templates/errors/500.html.j2 b/app/templates/errors/500.html.j2
deleted file mode 100644
index 5e2d3e87..00000000
--- a/app/templates/errors/500.html.j2
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html.j2" %}
-
-{% block page_content %}
-<div class="container">
-  <h1 id="title">{{ title }}</h1>
-  <p class="light">{{ request.path }}</p>
-  <p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
-</div>
-
-<div class="modal" id="more-information-modal">
-  <div class="modal-content">
-    <h2>About the "{{ title }}" error</h2>
-    <p>A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
-  </div>
-</div>
-{% endblock page_content %}
diff --git a/app/templates/errors/503.html.j2 b/app/templates/errors/503.html.j2
deleted file mode 100644
index eb10cfb0..00000000
--- a/app/templates/errors/503.html.j2
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html.j2" %}
-
-{% block page_content %}
-<div class="container">
-  <h1 id="title">{{ title }}</h1>
-  <p class="light">{{ request.path }}</p>
-  <p>Alternatively, you can visit the <a href="{{ url_for('main.index') }}">Main Page</a> or read <a class="modal-trigger" href="#more-information-modal">more information</a> about this type of error.</p>
-</div>
-
-<div class="modal" id="more-information-modal">
-  <div class="modal-content">
-    <h2>About the "{{ title }}" error</h2>
-    <p>The server cannot handle the request (because it is overloaded or down for maintenance). Generally, this is a temporary state.</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn-flat modal-close waves-effect waves-green">Close</a>
-  </div>
-</div>
-{% endblock page_content %}
diff --git a/app/templates/errors/error.html.j2 b/app/templates/errors/error.html.j2
new file mode 100644
index 00000000..ef19de5f
--- /dev/null
+++ b/app/templates/errors/error.html.j2
@@ -0,0 +1,10 @@
+{% extends "base.html.j2" %}
+
+{% set title = error.name %}
+
+{% block page_content %}
+<div class="container">
+  <h1 id="title">{{ error.name }}</h1>
+  <p>{{ error.description }}</p>
+</div>
+{% endblock page_content %}
diff --git a/app/templates/jobs/job.html.j2 b/app/templates/jobs/job.html.j2
index 0ec70a3f..5b78d055 100644
--- a/app/templates/jobs/job.html.j2
+++ b/app/templates/jobs/job.html.j2
@@ -79,61 +79,31 @@
         </div>
         <div class="card-action right-align">
           {% if current_user.is_administrator()  %}
-          <a class="btn hide modal-trigger job-log-trigger waves-effect waves-light" data-target="job-log-modal"><i class="material-icons left">repeat</i>Log</a>
-          <a class="btn hide modal-trigger restart-job-trigger waves-effect waves-light" data-target="restart-job-modal"><i class="material-icons left">repeat</i>Restart</a>
+          <a class="action-button btn disabled waves-effect waves-light" data-action="get-log-request"><i class="material-icons left">text_snippet</i>Log</a>
           {% endif %}
-          <!-- <a href="#" class="btn disabled waves-effect waves-light"><i class="material-icons left">settings</i>Export Parameters</a> -->
-          <a class="btn modal-trigger red waves-effect waves-light" data-target="delete-job-modal"><i class="material-icons left">delete</i>Delete</a>
+          <a class="action-button btn disabled waves-effect waves-light" data-action="restart-request"><i class="material-icons left">repeat</i>Restart</a>
+          <a class="action-button btn red waves-effect waves-light" data-action="delete-request"><i class="material-icons left">delete</i>Delete</a>
         </div>
       </div>
     </div>
 
-    <div class="col s12 nopaque-ressource-list" data-job-id="{{ job.hashid }}" data-ressource-type="JobInput" data-user-id="{{ job.user.hashid }}">
+    <div class="col s12">
       <div class="card">
         <div class="card-content">
           <div class="row">
-            <div class="col s12 m2">
-              <span class="card-title"><i class="left material-icons" style="font-size: inherit;">input</i>Inputs</span>
-              <p>Original input files.</p>
-            </div>
-            <div class="col s12 m10">
-              <table>
-                <thead>
-                  <tr>
-                    <th>Filename</th>
-                    <th></th>
-                  </tr>
-                </thead>
-                <tbody class="list"></tbody>
-              </table>
-              <ul class="pagination"></ul>
-            </div>
+            <span class="card-title"><i class="left material-icons" style="font-size: inherit;">input</i>Inputs</span>
+            <div class="job-input-list" data-user-id="{{ job.user.hashid }}" data-job-id="{{ job.hashid }}"></div>
           </div>
         </div>
       </div>
     </div>
 
-    <div class="col s12 nopaque-ressource-list" data-job-id="{{ job.hashid }}" data-ressource-type="JobResult" data-user-id="{{ job.user.hashid }}">
+    <div class="col s12">
       <div class="card">
         <div class="card-content">
           <div class="row">
-            <div class="col s12 m2">
-              <span class="card-title"><i class="left material-icons" style="font-size: inherit;">done</i>Results</span>
-              <p>Processed result files.</p>
-            </div>
-            <div class="col s12 m10">
-              <table>
-                <thead>
-                  <tr>
-                    <th>Description</th>
-                    <th>Filename</th>
-                    <th></th>
-                  </tr>
-                </thead>
-                <tbody class="list"></tbody>
-              </table>
-              <ul class="pagination"></ul>
-            </div>
+            <span class="card-title"><i class="left material-icons" style="font-size: inherit;">done</i>Results</span>
+            <div class="job-result-list" data-user-id="{{ job.user.hashid }}" data-job-id="{{ job.hashid }}"></div>
           </div>
         </div>
       </div>
@@ -142,42 +112,6 @@
 </div>
 {% endblock page_content %}
 
-{% block modals %}
-{{ super() }}
-<div id="job-log-modal" class="modal">
-  <div class="modal-content">
-    <h4>Job logs</h4>
-    <iframe src="{{ url_for('jobs.job_log', job_id=job.id) }}" style="border: 0; width: 100%; height: 450px;"></iframe>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn modal-close waves-effect waves-light">Close</a>
-  </div>
-</div>
-
-<div id="delete-job-modal" class="modal">
-  <div class="modal-content">
-    <h4>Confirm deletion</h4>
-    <p>Do you really want to delete the job <span class="job-title"></span>? All associated files will be permanently deleted.</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
-    <a class="btn modal-close red waves-effect waves-light" href="{{ url_for('jobs.delete_job', job_id=job.id) }}"><i class="material-icons left">delete</i>Delete</a>
-  </div>
-</div>
-
-{% if current_user.is_administrator() %}
-<div id="restart-job-modal" class="modal">
-  <div class="modal-content">
-    <h4>Confirm restart</h4>
-    <p>Do you really want to restart the job <span class="job-title"></span>? All log and result files will be permanently deleted.</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
-    <a class="btn modal-close red waves-effect waves-light" href="{{ url_for('jobs.restart', job_id=job.id) }}"><i class="material-icons left">restart</i>Restart</a>
-  </div>
-</div>
-{% endif %}
-{% endblock modals %}
 
 {% block scripts %}
 {{ super() }}
diff --git a/app/templates/main/dashboard.html.j2 b/app/templates/main/dashboard.html.j2
index d82ca9f5..0d156517 100644
--- a/app/templates/main/dashboard.html.j2
+++ b/app/templates/main/dashboard.html.j2
@@ -6,75 +6,32 @@
   <div class="row">
     <div class="col s12">
       <h1 id="title">{{ title }}</h1>
-    </div>
 
-    <div class="col s12">
       <h3>My Corpora and Query results</h3>
       <p>Create a corpus to interactively perform linguistic analysis or import query results to save interesting passages.</p>
+
       <div class="row">
         <div class="col s12">
           <ul class="tabs">
             <li class="tab col s6"><a class="active" href="#corpora">Corpora</a></li>
-            <li class="tab col s6"><a href="#query-results">Query results</a></li>
+            <li class="tab col s6 disabled"><a href="#query-results">Query results</a></li>
           </ul>
         </div>
-        <div class="col s12 nopaque-ressource-list" data-ressource-type="Corpus" data-user-id="{{ current_user.hashid }}" id="corpora">
+        <div class="col s12" id="corpora">
           <div class="card">
             <div class="card-content">
-              <div class="input-field">
-                <i class="material-icons prefix">search</i>
-                <input id="search-corpus" class="search" type="search"></input>
-                <label for="search-corpus">Search corpus</label>
-              </div>
-              <table>
-                <thead>
-                  <tr>
-                    <th></th>
-                    <th>Title and Description</th>
-                    <th>Status</th>
-                    <th></th>
-                  </tr>
-                </thead>
-                <tbody class="list"></tbody>
-              </table>
-              <ul class="pagination"></ul>
+              <div class="corpus-list" data-user-id="{{ current_user.hashid }}"></div>
             </div>
             <div class="card-action right-align">
-              <a class="btn waves-effect waves-light" href="{{ url_for('corpora.import_corpus') }}"><i class="material-icons right">import_export</i>Import Corpus</a>
-              <a class="btn waves-effect waves-light" href="{{ url_for('corpora.add_corpus') }}">New corpus<i class="material-icons right">add</i></a>
-            </div>
-          </div>
-        </div>
-        {# <div class="col s12 nopaque-ressource-list" data-ressource-type="QueryResult" data-user-id="{{ current_user.hashid }}" id="query-results"> #}
-        <div class="col s12" id="query-results">
-          <div class="card">
-            <div class="card-content">
-              <div class="input-field">
-                <i class="material-icons prefix">search</i>
-                <input id="search-query-results" class="search" type="search"></input>
-                <label for="search-query-results">Search query result</label>
-              </div>
-              <table>
-                <thead>
-                  <tr>
-                    <th>Title and Description</th>
-                    <th>Corpus and Query</th>
-                    <th></th>
-                  </tr>
-                </thead>
-                <tbody class="list"></tbody>
-              </table>
-              <ul class="pagination"></ul>
-            </div>
-            <div class="card-action right-align">
-              <a class="waves-effect waves-light btn disabled">Add query result<i class="material-icons right">file_upload</i></a>
+              <a class="btn disabled waves-effect waves-light" href="{{ url_for('corpora.import_corpus') }}">Import Corpus<i class="material-icons right">import_export</i></a>
+              <a class="btn waves-effect waves-light" href="{{ url_for('corpora.create_corpus') }}">Create corpus<i class="material-icons right">add</i></a>
             </div>
           </div>
         </div>
       </div>
     </div>
 
-    <div class="col s12 nopaque-ressource-list" data-ressource-type="Job" data-user-id="{{ current_user.hashid }}" id="jobs">
+    <div class="col s12" id="jobs">
       <h3>My Jobs</h3>
       <p>
         A job is the execution of a service provided by nopaque. You can
@@ -87,26 +44,10 @@
       <p><b>Where is my Job data?</b> Don't worry, please read <a href="{{ url_for('main.news', _anchor='april-2022-update') }}">this news</a> entry</p>
       <div class="card">
         <div class="card-content">
-          <div class="input-field">
-            <i class="material-icons prefix">search</i>
-            <input id="search-job" class="search" type="search"></input>
-            <label for="search-job">Search job</label>
-          </div>
-          <table>
-            <thead>
-              <tr>
-                <th>Service</th>
-                <th>Title and Description</th>
-                <th>Status</th>
-                <th></th>
-              </tr>
-            </thead>
-            <tbody class="list"></tbody>
-          </table>
-          <ul class="pagination"></ul>
+          <div class="job-list" data-user-id="{{ current_user.hashid }}"></div>
         </div>
         <div class="card-action right-align">
-          <p><a class="modal-trigger waves-effect waves-light btn" href="#" data-target="new-job-modal"><i class="material-icons left">add</i>New job</a></p>
+          <p><a class="btn modal-trigger waves-effect waves-light" data-target="create-job-modal"><i class="material-icons left">add</i>Create job</a></p>
         </div>
       </div>
     </div>
@@ -116,7 +57,7 @@
 
 {% block modals %}
 {{ super() }}
-<div id="new-job-modal" class="modal">
+<div id="create-job-modal" class="modal">
   <div class="modal-content">
     <h4>Select a service</h4>
     <p>&nbsp;</p>
@@ -160,7 +101,7 @@
     </div>
   </div>
   <div class="modal-footer">
-    <a href="#!" class="modal-close waves-effect waves-light btn-flat">Close</a>
+    <a class="btn-flat modal-close waves-effect waves-light">Close</a>
   </div>
 </div>
 {% endblock modals %}
diff --git a/app/templates/services/corpus_analysis.html.j2 b/app/templates/services/corpus_analysis.html.j2
index f89b46fc..e72d11b8 100644
--- a/app/templates/services/corpus_analysis.html.j2
+++ b/app/templates/services/corpus_analysis.html.j2
@@ -22,36 +22,20 @@
       <p>Nopaque lets you create and upload as many text corpora as you want. It makes use of CQP Query Language, which allows for complex search requests with the aid of metadata and NLP tags. The results can either be displayed as text or abstract visualizations.</p>
     </div>
 
-    <div class="col s12 nopaque-ressource-list" data-ressource-type="Corpus" data-user-id="{{ current_user.hashid }}" id="corpora">
+    <div class="col s12" id="corpora">
       <h2>My Corpora</h2>
       <div class="card">
         <div class="card-content">
-          <div class="input-field">
-            <i class="material-icons prefix">search</i>
-            <input id="search-corpus" class="search" type="search"></input>
-            <label for="search-corpus">Search corpus</label>
-          </div>
-          <table class="highlight">
-            <thead>
-              <tr>
-                <th></th>
-                <th>Title and Description</th>
-                <th>Status</th>
-                <th></th>
-              </tr>
-            </thead>
-            <tbody class="list"></tbody>
-          </table>
-          <ul class="pagination"></ul>
+          <div class="corpus-list" data-user-id="{{ current_user.hashid }}"></div>
         </div>
         <div class="card-action right-align">
-          <a class="waves-effect waves-light btn" href="{{ url_for('corpora.import_corpus') }}"><i class="material-icons right">import_export</i>Import Corpus</a>
-          <a class="btn waves-effect waves-light" href="{{ url_for('corpora.add_corpus') }}">New corpus<i class="material-icons right">add</i></a>
+          <a class="waves-effect waves-light btn" href="{{ url_for('corpora.import_corpus') }}">Import Corpus<i class="material-icons right">import_export</i></a>
+          <a class="btn waves-effect waves-light" href="{{ url_for('corpora.create_corpus') }}">Create corpus<i class="material-icons right">add</i></a>
         </div>
       </div>
     </div>
 
-    <div class="col s12 nopaque-ressource-list" data-ressource-type="QueryResult" data-user-id="{{ current_user.hashid }}" id="query-results">
+    <div class="col s12 query-result-list" data-user-id="{{ current_user.hashid }}" id="query-results">
       <h2>My query results</h2>
       <div class="card">
         <div class="card-content">
diff --git a/app/templates/services/file_setup_pipeline.html.j2 b/app/templates/services/file_setup_pipeline.html.j2
index dfb11fec..eca9b8b7 100644
--- a/app/templates/services/file_setup_pipeline.html.j2
+++ b/app/templates/services/file_setup_pipeline.html.j2
@@ -39,7 +39,7 @@
     <div class="col s12">
       <h2>Submit a job</h2>
       <div class="card">
-        <form class="nopaque-upload-form" data-progress-modal="progress-modal">
+        <form class="create-job-form" enctype="multipart/form-data" method="POST">
           <div class="card-content">
             {{ form.hidden_tag() }}
             <div class="row">
diff --git a/app/templates/services/spacy_nlp_pipeline.html.j2 b/app/templates/services/spacy_nlp_pipeline.html.j2
index 106e72e1..7552b588 100644
--- a/app/templates/services/spacy_nlp_pipeline.html.j2
+++ b/app/templates/services/spacy_nlp_pipeline.html.j2
@@ -57,7 +57,7 @@
     <div class="col s12">
       <h2>Submit a job</h2>
       <div class="card">
-        <form class="nopaque-upload-form" data-progress-modal="progress-modal">
+        <form class="create-job-form" enctype="multipart/form-data" method="POST">
           <div class="card-content">
             {{ form.hidden_tag() }}
             <div class="row">
diff --git a/app/templates/services/tesseract_ocr_pipeline.html.j2 b/app/templates/services/tesseract_ocr_pipeline.html.j2
index 129b74aa..c38c3965 100644
--- a/app/templates/services/tesseract_ocr_pipeline.html.j2
+++ b/app/templates/services/tesseract_ocr_pipeline.html.j2
@@ -39,7 +39,7 @@
     <div class="col s12">
       <h2>Submit a job</h2>
       <div class="card">
-        <form class="nopaque-upload-form" data-progress-modal="progress-modal">
+        <form class="create-job-form" enctype="multipart/form-data" method="POST">
           <div class="card-content">
             {{ form.hidden_tag() }}
             <div class="row">
@@ -178,28 +178,4 @@
     <a href="#!" class="modal-close waves-effect waves-light btn">Close</a>
   </div>
 </div>
-
-<div id="progress-modal" class="modal">
-  <div class="modal-content">
-    <h4><i class="material-icons left">file_upload</i>Uploading files...</h4>
-    <div class="progress">
-      <div class="determinate" style="width: 0%"></div>
-    </div>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="modal-close waves-effect waves-light btn red abort-request">Cancel</a>
-  </div>
-</div>
 {% endblock modals %}
-
-{% block scripts %}
-{{ super() }}
-<script>
-  let versionField = document.querySelector('#add-job-form-version');
-  versionField.addEventListener('change', (event) => {
-    let url = new URL(window.location.href);
-    url.search = `?version=${event.target.value}`;
-    window.location.href = url.toString();
-  });
-</script>
-{% endblock scripts %}
diff --git a/app/templates/services/transkribus_htr_pipeline.html.j2 b/app/templates/services/transkribus_htr_pipeline.html.j2
index 7708e8d8..a73839b5 100644
--- a/app/templates/services/transkribus_htr_pipeline.html.j2
+++ b/app/templates/services/transkribus_htr_pipeline.html.j2
@@ -44,7 +44,7 @@
     <div class="col s12">
       <h2>Submit a job</h2>
       <div class="card">
-        <form class="nopaque-upload-form" data-progress-modal="progress-modal">
+        <form class="create-job-form" enctype="multipart/form-data" method="POST">
           <div class="card-content">
             {{ form.hidden_tag() }}
             <div class="row">
diff --git a/app/templates/settings/_breadcrumbs.html.j2 b/app/templates/settings/_breadcrumbs.html.j2
index 33b8984c..3b5077bf 100644
--- a/app/templates/settings/_breadcrumbs.html.j2
+++ b/app/templates/settings/_breadcrumbs.html.j2
@@ -1,6 +1,6 @@
 {% set breadcrumbs %}
 <li class="tab disabled"><i class="material-icons">navigate_next</i></li>
-{% if request.path == url_for('settings.index') %}
-<li class="tab"><a{%if request.path == url_for('settings.index') %} class="active"{% endif %} href="{{ url_for('settings.index') }}" target="_self">Settings</a></li>
+{% if request.path == url_for('settings.settings') %}
+<li class="tab"><a{%if request.path == url_for('settings.settings') %} class="active"{% endif %} href="{{ url_for('settings.settings') }}" target="_self">Settings</a></li>
 {% endif %}
 {% endset %}
diff --git a/app/templates/settings/index.html.j2 b/app/templates/settings/settings.html.j2
similarity index 83%
rename from app/templates/settings/index.html.j2
rename to app/templates/settings/settings.html.j2
index 0a814ea2..441cd367 100644
--- a/app/templates/settings/index.html.j2
+++ b/app/templates/settings/settings.html.j2
@@ -81,7 +81,7 @@
           <span class="card-title">Change Password</span>
           {{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }}
           {{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }}
-          {{ wtf.render_field(change_password_form.new_password_confirmation, material_icon='vpn_key') }}
+          {{ wtf.render_field(change_password_form.new_password_2, material_icon='vpn_key') }}
         </div>
         <div class="card-action">
           <div class="right-align">
@@ -101,23 +101,19 @@
         </ul>
       </div>
       <div class="card-action right-align">
-        <a href="#delete-account-modal" class="btn modal-trigger red waves-effect waves-light"><i class="material-icons left">delete</i>Delete</a>
+        <a class="btn red waves-effect waves-light" id="delete-user"><i class="material-icons left">delete</i>Delete</a>
       </div>
     </div>
   </div>
 </div>
 {% endblock page_content %}
 
-{% block modals %}
+{% block scripts %}
 {{ super() }}
-<div class="modal" id="delete-account-modal">
-  <div class="modal-content">
-    <h4>Confirm deletion</h4>
-    <p>Do you really want to delete your account and all associated data? All associated corpora, jobs and files will be permanently deleted!</p>
-  </div>
-  <div class="modal-footer">
-    <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
-    <a href="{{ url_for('.delete') }}" class="btn red waves-effect waves-light"><i class="material-icons left">delete</i>Delete</a>
-  </div>
-</div>
-{% endblock modals %}
+<script>
+document.querySelector('#delete-user').addEventListener('click', (event) => {
+  Utils.deleteUserRequest(currentUserId)
+    .then((response) => {window.location.href = '/';});
+});
+</script>
+{% endblock scripts %}
diff --git a/app/users/__init__.py b/app/users/__init__.py
index 9f43097d..885cdbe2 100644
--- a/app/users/__init__.py
+++ b/app/users/__init__.py
@@ -2,4 +2,4 @@ from flask import Blueprint
 
 
 bp = Blueprint('users', __name__)
-from . import events
+from . import events, routes
diff --git a/app/users/events.py b/app/users/events.py
index a47db136..7cab2199 100644
--- a/app/users/events.py
+++ b/app/users/events.py
@@ -1,21 +1,8 @@
+from flask_login import current_user
+from flask_socketio import join_room, leave_room
 from app import hashids, socketio
 from app.decorators import socketio_login_required
 from app.models import User
-from flask_login import current_user
-from flask_socketio import join_room, leave_room
-
-
-@socketio.on('GET /users/<user_id>')
-@socketio_login_required
-def get_user(user_hashid):
-    user_id = hashids.decode(user_hashid)
-    user = User.query.get(user_id)
-    if user is None:
-        return {'code': 404, 'msg': 'Not found'}
-    if not (user == current_user or current_user.is_administrator):
-        return {'code': 403, 'msg': 'Forbidden'}
-    dict_user = user.to_dict(backrefs=True, relationships=True)
-    return {'code': 200, 'msg': 'OK', 'payload': dict_user}
 
 
 @socketio.on('SUBSCRIBE /users/<user_id>')
diff --git a/app/users/routes.py b/app/users/routes.py
new file mode 100644
index 00000000..f2f8abf2
--- /dev/null
+++ b/app/users/routes.py
@@ -0,0 +1,38 @@
+from flask import abort, current_app, request
+from flask_login import current_user, login_required
+from threading import Thread
+from app import db
+from app.models import User
+from . import bp
+
+
+@bp.route('/<hashid:user_id>')
+@login_required
+def user(user_id):
+    user = User.query.get_or_404(user_id)
+    if not (user == current_user or current_user.is_administrator()):
+        abort(403)
+    backrefs = request.args.get('backrefs', 'false').lower() == 'true'
+    relationships = (
+        request.args.get('relationships', 'false').lower() == 'true')
+    return user.to_json(backrefs=backrefs, relationships=relationships), 200
+
+
+@bp.route('/<hashid:user_id>', methods=['DELETE'])
+@login_required
+def delete_user(user_id):
+    def _delete_user(app, user_id):
+        with app.app_context():
+            user = User.query.get(user_id)
+            user.delete()
+            db.session.commit()
+
+    user = User.query.get_or_404(user_id)
+    if not (user == current_user or current_user.is_administrator()):
+        abort(403)
+    thread = Thread(
+        target=_delete_user,
+        args=(current_app._get_current_object(), user_id)
+    )
+    thread.start()
+    return {}, 202
diff --git a/migrations/versions/9e8d7d15d950_.py b/migrations/versions/9e8d7d15d950_.py
index b76a490e..9d59da39 100644
--- a/migrations/versions/9e8d7d15d950_.py
+++ b/migrations/versions/9e8d7d15d950_.py
@@ -1,4 +1,4 @@
-"""empty message
+"""Initial database setup
 
 Revision ID: 9e8d7d15d950
 Revises: 
diff --git a/migrations/versions/f9070ff1fa4a_.py b/migrations/versions/f9070ff1fa4a_.py
new file mode 100644
index 00000000..a0cfb00f
--- /dev/null
+++ b/migrations/versions/f9070ff1fa4a_.py
@@ -0,0 +1,31 @@
+"""Remove token entries for rudimentary API authentication mechanism
+
+Revision ID: f9070ff1fa4a
+Revises: 9e8d7d15d950
+Create Date: 2022-09-01 13:46:47.425268
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'f9070ff1fa4a'
+down_revision = '9e8d7d15d950'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index('ix_users_token', table_name='users')
+    op.drop_column('users', 'token')
+    op.drop_column('users', 'token_expiration')
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('token_expiration', sa.DateTime(), autoincrement=False, nullable=True))
+    op.add_column('users', sa.Column('token', sa.VARCHAR(length=32), autoincrement=False, nullable=True))
+    op.create_index('ix_users_token', 'users', ['token'], unique=False)
+    # ### end Alembic commands ###
diff --git a/requirements.txt b/requirements.txt
index f2962f70..40206acb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,8 @@
+apifairy
 cqi
 docker
 eventlet
-Flask
+Flask==2.1.3
 Flask-APScheduler
 Flask-Assets
 Flask-Hashids
@@ -10,12 +11,12 @@ Flask-Login
 Flask-Mail
 Flask-Migrate
 Flask-Paranoid
-Flask-RESTX
 Flask-SocketIO
 Flask-SQLAlchemy
 Flask-WTF
 hiredis
 MarkupSafe==2.0.1
+marshmallow-sqlalchemy
 psycopg2
 PyJWT
 pyScss
-- 
GitLab