diff --git a/check_migrations.py b/check_migrations.py
index 643f30fbf1eb413dcd86deb8add7da1148f0984b..1a9773ea68c6e6939525b5845bd1b2ce739f6a5e 100755
--- a/check_migrations.py
+++ b/check_migrations.py
@@ -7,15 +7,17 @@ import datetime
 import flask_migrate
 
 from uffd import create_app, db
-from uffd.user.models import User, Group
-from uffd.mfa.models import RecoveryCodeMethod, TOTPMethod, WebauthnMethod
-from uffd.role.models import Role, RoleGroup
-from uffd.signup.models import Signup
-from uffd.invite.models import Invite, InviteGrant, InviteSignup
-from uffd.session.models import DeviceLoginConfirmation
-from uffd.service.models import Service
-from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
-from uffd.selfservice.models import PasswordToken, MailToken
+from uffd.models import (
+	User, Group,
+	RecoveryCodeMethod, TOTPMethod, WebauthnMethod,
+	Role, RoleGroup,
+	Signup,
+	Invite, InviteGrant, InviteSignup,
+	DeviceLoginConfirmation,
+	Service,
+	OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation,
+	PasswordToken, MailToken,
+)
 
 def run_test(dburi, revision):
 	config = {
diff --git a/tests/test_api.py b/tests/test_api.py
index 7e86379a7fa9261aa792140843cee1ae1c64da98..46c6fefba59f42f15040e7cec0aebc6cd09faa8f 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2,12 +2,10 @@ import base64
 
 from flask import url_for
 
-from uffd.api.views import apikey_required
-from uffd.api.models import APIClient
-from uffd.service.models import Service
-from uffd.user.models import User, remailer
+from uffd.models import APIClient, Service, User, remailer
 from uffd.password_hash import PlaintextPasswordHash
 from uffd.database import db
+from uffd.views.api import apikey_required
 from utils import UffdTestCase, db_flush
 
 def basic_auth(username, password):
diff --git a/tests/test_csrf.py b/tests/test_csrf.py
index 1486beb74b7a5ce827cdae2a1bba168e4a990454..6475d9699ad5eb6351f48e35b8aa000f5698a65b 100644
--- a/tests/test_csrf.py
+++ b/tests/test_csrf.py
@@ -2,7 +2,7 @@ import unittest
 
 from flask import Flask, Blueprint, session, url_for
 
-from uffd.csrf import csrf_bp, csrf_protect
+from uffd.csrf import bp as csrf_bp, csrf_protect
 
 uid_counter = 0
 
diff --git a/tests/test_invite.py b/tests/test_invite.py
index 0408ca6d9cf0c192a47166234a30ab7cb65ce705..295103208ecd1f1436e4066e7fadd5fe55c2f226 100644
--- a/tests/test_invite.py
+++ b/tests/test_invite.py
@@ -4,14 +4,9 @@ import time
 
 from flask import url_for, session, current_app
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
 from uffd import create_app, db
-from uffd.invite.models import Invite, InviteGrant, InviteSignup
-from uffd.user.models import User, Group
-from uffd.role.models import Role, RoleGroup
-from uffd.session.views import login_get_user
+from uffd.models import Invite, InviteGrant, InviteSignup, User, Group, Role, RoleGroup
+from uffd.views.session import login_get_user
 
 from utils import dump, UffdTestCase, db_flush
 
diff --git a/tests/test_mail.py b/tests/test_mail.py
index ab8abe5b96ee86303d1295e8c2429076ab212a66..f794486b465f984264bd71a0c7116c04c35f58d7 100644
--- a/tests/test_mail.py
+++ b/tests/test_mail.py
@@ -4,11 +4,8 @@ import unittest
 
 from flask import url_for, session
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
-from uffd.mail.models import Mail
 from uffd import create_app, db
+from uffd.models import Mail
 
 from utils import dump, UffdTestCase, db_flush
 
diff --git a/tests/test_mfa.py b/tests/test_mfa.py
index 05a61351bfeafd295282e034bcc73bdbd9406f0c..ff32a105fc9028a4f0c2ba77bfa7fe27d3590486 100644
--- a/tests/test_mfa.py
+++ b/tests/test_mfa.py
@@ -4,13 +4,9 @@ import time
 
 from flask import url_for, session, request
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
-from uffd.user.models import User
-from uffd.role.models import Role, RoleGroup
-from uffd.mfa.models import MFAMethod, MFAType, RecoveryCodeMethod, TOTPMethod, WebauthnMethod, _hotp
 from uffd import create_app, db
+from uffd.models import User, Role, RoleGroup, MFAMethod, MFAType, RecoveryCodeMethod, TOTPMethod, WebauthnMethod
+from uffd.models.mfa import _hotp
 
 from utils import dump, UffdTestCase, db_flush
 
@@ -26,7 +22,7 @@ class TestMfaPrimitives(unittest.TestCase):
 
 def get_fido2_test_cred(self):
 	try:
-		from uffd.mfa.fido2_compat import AttestedCredentialData
+		from uffd.fido2_compat import AttestedCredentialData
 	except ImportError:
 		self.skipTest('fido2 could not be imported')
 	# Example public key from webauthn spec 6.5.1.1
diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py
index 9021240c3789e46c6483671e6c6df6425ff9b36d..cd612b4a02d96472b0eca35351c7defa6ffb4688 100644
--- a/tests/test_oauth2.py
+++ b/tests/test_oauth2.py
@@ -3,15 +3,9 @@ from urllib.parse import urlparse, parse_qs
 
 from flask import url_for, session
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
-from uffd.user.models import User, remailer
-from uffd.password_hash import PlaintextPasswordHash
-from uffd.session.models import DeviceLoginConfirmation
-from uffd.service.models import Service
-from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation
 from uffd import create_app, db
+from uffd.password_hash import PlaintextPasswordHash
+from uffd.models import User, remailer, DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation
 
 from utils import dump, UffdTestCase
 
diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py
index f0bf393c10ceb169a30984c2dc220d29a1868671..a10903f4055ecaa93c472d6c12fdb0333cd4e6e7 100644
--- a/tests/test_ratelimit.py
+++ b/tests/test_ratelimit.py
@@ -2,7 +2,7 @@ import time
 
 from flask import Flask, Blueprint, session, url_for
 
-from uffd.ratelimit import get_addrkey, format_delay, Ratelimit, RatelimitEvent
+from uffd.models.ratelimit import get_addrkey, format_delay, Ratelimit, RatelimitEvent
 
 from utils import UffdTestCase
 
diff --git a/tests/test_role.py b/tests/test_role.py
index 6fe4125a63253c42195e8d41edebbde8305e29ba..7061d3936abd65d949fc01c0f8b5064a1ab0f47c 100644
--- a/tests/test_role.py
+++ b/tests/test_role.py
@@ -4,13 +4,9 @@ import unittest
 
 from flask import url_for, session
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
-from uffd.user.models import User, Group
-from uffd.role.models import flatten_recursive, Role, RoleGroup
-from uffd.mfa.models import TOTPMethod
 from uffd import create_app, db
+from uffd.models import User, Group, Role, RoleGroup, TOTPMethod
+from uffd.models.role import flatten_recursive
 
 from utils import dump, UffdTestCase
 
diff --git a/tests/test_rolemod.py b/tests/test_rolemod.py
index beb4721974574dd0d5708bca8b42ae4c31f84275..2950c83c43dba8801a524969e725397dff551d76 100644
--- a/tests/test_rolemod.py
+++ b/tests/test_rolemod.py
@@ -1,8 +1,7 @@
 from flask import url_for
 
-from uffd.user.models import User, Group
-from uffd.role.models import Role, RoleGroup
 from uffd.database import db
+from uffd.models import User, Group, Role, RoleGroup
 
 from utils import dump, UffdTestCase
 
diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py
index c6724ef4016919fb3d03680a4fed5514d699220e..cc2b0a86a3341ab62d5a6e9deebabbde8bf46e97 100644
--- a/tests/test_selfservice.py
+++ b/tests/test_selfservice.py
@@ -3,13 +3,8 @@ import unittest
 
 from flask import url_for, request
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
-from uffd.selfservice.models import MailToken, PasswordToken
-from uffd.user.models import User
-from uffd.role.models import Role, RoleGroup
 from uffd import create_app, db
+from uffd.models import MailToken, PasswordToken, User, Role, RoleGroup
 
 from utils import dump, UffdTestCase
 
diff --git a/tests/test_services.py b/tests/test_services.py
index efd71898001d796523f9d1733c6846ee5233f696..09cc802e5ae888e509e83b492a31371439d78275 100644
--- a/tests/test_services.py
+++ b/tests/test_services.py
@@ -3,9 +3,6 @@ import unittest
 
 from flask import url_for
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
 from utils import dump, UffdTestCase
 
 class TestServices(UffdTestCase):
diff --git a/tests/test_session.py b/tests/test_session.py
index 173adfd13d669e550c71f62e78b7ff0b55a055c1..bad3b887499b6c9169809a39bcc049a921320629 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -3,16 +3,10 @@ import unittest
 
 from flask import url_for, request
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
-from uffd.session.views import login_required
-from uffd.session.models import DeviceLoginConfirmation
-from uffd.service.models import Service
-from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation
-from uffd.user.models import User
-from uffd.password_hash import PlaintextPasswordHash
 from uffd import create_app, db
+from uffd.password_hash import PlaintextPasswordHash
+from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, User
+from uffd.views.session import login_required
 
 from utils import dump, UffdTestCase, db_flush
 
diff --git a/tests/test_signup.py b/tests/test_signup.py
index c202a5019a5fa369cba952194bbad85f248678ef..5979da3bc0f066a1fde981081deabbe67c76ac11 100644
--- a/tests/test_signup.py
+++ b/tests/test_signup.py
@@ -4,14 +4,9 @@ import time
 
 from flask import url_for, session, request
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
 from uffd import create_app, db
-from uffd.signup.models import Signup
-from uffd.user.models import User
-from uffd.role.models import Role, RoleGroup
-from uffd.session.views import login_get_user
+from uffd.models import Signup, User, Role, RoleGroup
+from uffd.views.session import login_get_user
 
 from utils import dump, UffdTestCase, db_flush
 
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 8f146d5ac8aa3e8c95283657478bc71396328c5b..6c297b1b9b1a73ee0cf1a6b4a073fcaa7d3bcd97 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -12,7 +12,7 @@ class TestCleanupTask(unittest.TestCase):
 		app.debug = True
 		app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
 		db = SQLAlchemy(app)
-		cleanup_task = CleanupTask(app, db)
+		cleanup_task = CleanupTask()
 
 		@cleanup_task.delete_by_attribute('delete_me')
 		class TestModel(db.Model):
@@ -30,7 +30,10 @@ class TestCleanupTask(unittest.TestCase):
 			db.session.expire_all()
 			self.assertEqual(TestModel.query.count(), 5)
 
-		app.test_cli_runner().invoke(args=['cleanup'])
+		with app.test_request_context():
+			cleanup_task.run()
+			db.session.commit()
+			db.session.expire_all()
 
 		with app.test_request_context():
 			self.assertEqual(TestModel.query.count(), 2)
diff --git a/tests/test_user.py b/tests/test_user.py
index 0990a74f0bdb1f54e49eef14bafb19892bafe51b..0152a0583aec2a4c3a66affb0fcb5d9a9dd33580 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -4,13 +4,8 @@ import unittest
 from flask import url_for, session
 import sqlalchemy
 
-# These imports are required, because otherwise we get circular imports?!
-from uffd import user
-
-from uffd.user.models import User, remailer, RemailerAddress, Group
-from uffd.role.models import Role, RoleGroup
-from uffd.service.models import Service
 from uffd import create_app, db
+from uffd.models import User, remailer, RemailerAddress, Group, Role, RoleGroup, Service
 
 from utils import dump, UffdTestCase
 
diff --git a/tests/utils.py b/tests/utils.py
index 6a2e2cb801ec9850fd25b2f42cd5707159a9cd5e..c4017b22f14f93392775406379571be52b6554aa 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -6,8 +6,7 @@ import unittest
 from flask import request, url_for
 
 from uffd import create_app, db
-from uffd.user.models import User, Group
-from uffd.mail.models import Mail
+from uffd.models import User, Group, Mail
 
 def dump(basename, resp):
 	basename = basename.replace('.', '_').replace('/', '_')
diff --git a/uffd/__init__.py b/uffd/__init__.py
index f9a0df33e96e06dab6102eba856d039e4c136b25..6f48d7a02a0ea4db062a7820232d92e195cd8b6f 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -4,24 +4,14 @@ import sys
 
 from flask import Flask, redirect, url_for, request, render_template
 from flask_babel import Babel
-from werkzeug.routing import IntegerConverter
-from werkzeug.serving import make_ssl_devcert
-try:
-	from werkzeug.middleware.profiler import ProfilerMiddleware
-except ImportError:
-	from werkzeug.contrib.profiler import ProfilerMiddleware
-from werkzeug.exceptions import InternalServerError, Forbidden
+from werkzeug.exceptions import Forbidden
 from flask_migrate import Migrate
 
-from uffd.database import db, SQLAlchemyJSON, customize_db_engine
-from uffd.tasks import cleanup_task
-from uffd.template_helper import register_template_helper
-from uffd.navbar import setup_navbar
-from uffd.secure_redirect import secure_local_redirect
-from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, service, signup, rolemod, invite, api
-from uffd.user.models import User, Group
-from uffd.role.models import Role, RoleGroup
-from uffd.mail.models import Mail
+from .database import db, SQLAlchemyJSON, customize_db_engine
+from .template_helper import register_template_helper
+from .navbar import setup_navbar
+from .csrf import bp as csrf_bp
+from . import models, views, commands
 
 def load_config_file(app, path, silent=False):
 	if not os.path.exists(path):
@@ -75,6 +65,11 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta
 	positions += ["rolemod", "invite", "user", "group", "role", "mail"]
 	setup_navbar(app, positions)
 
+	app.register_blueprint(csrf_bp)
+
+	views.init_app(app)
+	commands.init_app(app)
+
 	# We never want to fail here, but at a file access that doesn't work.
 	# We might only have read access to app.instance_path
 	try:
@@ -87,51 +82,10 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta
 	with app.app_context():
 		customize_db_engine(db.engine)
 
-	cleanup_task.init_app(app, db)
-
-	for module in [user, selfservice, role, mail, session, csrf, mfa, oauth2, service, rolemod, api, signup, invite]:
-		for bp in module.bp:
-			app.register_blueprint(bp)
-
 	@app.shell_context_processor
 	def push_request_context(): #pylint: disable=unused-variable
 		app.test_request_context().push() # LDAP ORM requires request context
-		return {'db': db, 'User': User, 'Group': Group, 'Role': Role, 'Mail': Mail}
-
-	@app.errorhandler(403)
-	def handle_403(error):
-		return render_template('403.html', description=error.description if error.description != Forbidden.description else None), 403
-
-	@app.route("/")
-	def index(): #pylint: disable=unused-variable
-		if app.config['DEFAULT_PAGE_SERVICES']:
-			return redirect(url_for('service.overview'))
-		return redirect(url_for('selfservice.index'))
-
-	@app.route('/lang', methods=['POST'])
-	def setlang(): #pylint: disable=unused-variable
-		resp = secure_local_redirect(request.values.get('ref', '/'))
-		if 'lang' in request.values:
-			resp.set_cookie('language', request.values['lang'])
-		return resp
-
-	@app.cli.command("gendevcert", help='Generates a self-signed TLS certificate for development')
-	def gendevcert(): #pylint: disable=unused-variable
-		if os.path.exists('devcert.crt') or os.path.exists('devcert.key'):
-			print('Refusing to overwrite existing "devcert.crt"/"devcert.key" file!')
-			return
-		make_ssl_devcert('devcert')
-		print('Certificate written to "devcert.crt", private key to "devcert.key".')
-		print('Run `flask run --cert devcert.crt --key devcert.key` to use it.')
-
-	@app.cli.command("profile", help='Runs app with profiler')
-	def profile(): #pylint: disable=unused-variable
-		# app.run() is silently ignored if executed from commands. We really want
-		# to do this, so we overwrite the check by overwriting the environment
-		# variable.
-		os.environ['FLASK_RUN_FROM_CLI'] = 'false'
-		app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30])
-		app.run(debug=True)
+		return {'db': db} | {name: getattr(models, name) for name in models.__all__}
 
 	babel = Babel(app)
 
diff --git a/uffd/api/__init__.py b/uffd/api/__init__.py
deleted file mode 100644
index 656390049779db11f3fbd9a16498218b18982068..0000000000000000000000000000000000000000
--- a/uffd/api/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as _bp
-
-bp = [_bp]
diff --git a/uffd/commands/__init__.py b/uffd/commands/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e3ad7436096922115fb7129f0684251e73ac5a74
--- /dev/null
+++ b/uffd/commands/__init__.py
@@ -0,0 +1,16 @@
+from .user import user_command
+from .group import group_command
+from .role import role_command
+from .profile import profile_command
+from .gendevcert import gendevcert_command
+from .cleanup import cleanup_command
+from .roles_update_all import roles_update_all_command
+
+def init_app(app):
+	app.cli.add_command(user_command)
+	app.cli.add_command(group_command)
+	app.cli.add_command(role_command)
+	app.cli.add_command(gendevcert_command)
+	app.cli.add_command(profile_command)
+	app.cli.add_command(cleanup_command)
+	app.cli.add_command(roles_update_all_command)
diff --git a/uffd/commands/cleanup.py b/uffd/commands/cleanup.py
new file mode 100644
index 0000000000000000000000000000000000000000..925dda274c6298d9c12d52ac8f2255edf962ae2d
--- /dev/null
+++ b/uffd/commands/cleanup.py
@@ -0,0 +1,11 @@
+import click
+from flask.cli import with_appcontext
+
+from uffd.tasks import cleanup_task
+from uffd.database import db
+
+@click.command('cleanup', help='Cleanup expired data')
+@with_appcontext
+def cleanup_command():
+	cleanup_task.run()
+	db.session.commit()
diff --git a/uffd/commands/gendevcert.py b/uffd/commands/gendevcert.py
new file mode 100644
index 0000000000000000000000000000000000000000..a536768ff80da3db3d6722a3940aaf1f88072983
--- /dev/null
+++ b/uffd/commands/gendevcert.py
@@ -0,0 +1,13 @@
+import os
+
+import click
+from werkzeug.serving import make_ssl_devcert
+
+@click.command("gendevcert", help='Generates a self-signed TLS certificate for development')
+def gendevcert_command(): #pylint: disable=unused-variable
+	if os.path.exists('devcert.crt') or os.path.exists('devcert.key'):
+		print('Refusing to overwrite existing "devcert.crt"/"devcert.key" file!')
+		return
+	make_ssl_devcert('devcert')
+	print('Certificate written to "devcert.crt", private key to "devcert.key".')
+	print('Run `flask run --cert devcert.crt --key devcert.key` to use it.')
diff --git a/uffd/user/cli_group.py b/uffd/commands/group.py
similarity index 78%
rename from uffd/user/cli_group.py
rename to uffd/commands/group.py
index 0dd80e539290132d93a7c400c67bd9e8564fe4a8..a98e23b3f9bac00591cdb4d0c62e3e12a3b1584b 100644
--- a/uffd/user/cli_group.py
+++ b/uffd/commands/group.py
@@ -1,26 +1,21 @@
-from flask import Blueprint, current_app
+from flask import current_app
 from flask.cli import AppGroup
 from sqlalchemy.exc import IntegrityError
 import click
 
 from uffd.database import db
 
-from .models import Group
+from uffd.models import Group
 
-bp = Blueprint('group_cli', __name__)
-group_cli = AppGroup('group', help='Manage groups')
+group_command = AppGroup('group', help='Manage groups')
 
-@bp.record
-def add_cli_commands(state):
-	state.app.cli.add_command(group_cli)
-
-@group_cli.command(help='List names of all groups')
+@group_command.command(help='List names of all groups')
 def list():
 	with current_app.test_request_context():
 		for group in Group.query:
 			click.echo(group.name)
 
-@group_cli.command(help='Show details of group')
+@group_command.command(help='Show details of group')
 @click.argument('name')
 def show(name):
 	with current_app.test_request_context():
@@ -32,7 +27,7 @@ def show(name):
 		click.echo(f'Description: {group.description}')
 		click.echo(f'Members: {", ".join([user.loginname for user in group.members])}')
 
-@group_cli.command(help='Create new group')
+@group_command.command(help='Create new group')
 @click.argument('name')
 @click.option('--description', default='', help='Set description text. Empty per default.')
 def create(name, description):
@@ -46,7 +41,7 @@ def create(name, description):
 		except IntegrityError as ex:
 			raise click.ClickException(f'Group creation failed: {ex}')
 
-@group_cli.command(help='Update group attributes')
+@group_command.command(help='Update group attributes')
 @click.argument('name')
 @click.option('--description', help='Set description text.')
 def update(name, description):
@@ -58,7 +53,7 @@ def update(name, description):
 			group.description = description
 		db.session.commit()
 
-@group_cli.command(help='Delete group')
+@group_command.command(help='Delete group')
 @click.argument('name')
 def delete(name):
 	with current_app.test_request_context():
diff --git a/uffd/commands/profile.py b/uffd/commands/profile.py
new file mode 100644
index 0000000000000000000000000000000000000000..38d8de55136f3bc7e48a978acc4da2e73e61eab7
--- /dev/null
+++ b/uffd/commands/profile.py
@@ -0,0 +1,19 @@
+import os
+
+from flask import current_app
+from flask.cli import with_appcontext
+import click
+try:
+	from werkzeug.middleware.profiler import ProfilerMiddleware
+except ImportError:
+	from werkzeug.contrib.profiler import ProfilerMiddleware
+
+@click.command("profile", help='Runs app with profiler')
+@with_appcontext
+def profile_command(): #pylint: disable=unused-variable
+	# app.run() is silently ignored if executed from commands. We really want
+	# to do this, so we overwrite the check by overwriting the environment
+	# variable.
+	os.environ['FLASK_RUN_FROM_CLI'] = 'false'
+	current_app.wsgi_app = ProfilerMiddleware(current_app.wsgi_app, restrictions=[30])
+	current_app.run(debug=True)
diff --git a/uffd/role/cli.py b/uffd/commands/role.py
similarity index 92%
rename from uffd/role/cli.py
rename to uffd/commands/role.py
index 6e94f5f1a78c3a1ada626d503c844746e12c56cb..584aaa0d3ec8da7ad4459827ea29ff81e6849c4f 100644
--- a/uffd/role/cli.py
+++ b/uffd/commands/role.py
@@ -1,19 +1,12 @@
-from flask import Blueprint, current_app
+from flask import current_app
 from flask.cli import AppGroup
 from sqlalchemy.exc import IntegrityError
 import click
 
 from uffd.database import db
-from uffd.user.models import Group
+from uffd.models import Group, Role, RoleGroup
 
-from .models import Role, RoleGroup
-
-bp = Blueprint('role_cli', __name__)
-role_cli = AppGroup('role', help='Manage roles')
-
-@bp.record
-def add_cli_commands(state):
-	state.app.cli.add_command(role_cli)
+role_command = AppGroup('role', help='Manage roles')
 
 # pylint: disable=too-many-arguments,too-many-locals
 
@@ -57,13 +50,13 @@ def update_attrs(role, description=None, default=None,
 			raise click.ClickException(f'Role {role_name} not found')
 		role.included_roles.remove(_role)
 
-@role_cli.command(help='List names of all roles')
+@role_command.command(help='List names of all roles')
 def list():
 	with current_app.test_request_context():
 		for role in Role.query:
 			click.echo(role.name)
 
-@role_cli.command(help='Show details of group')
+@role_command.command(help='Show details of group')
 @click.argument('name')
 def show(name):
 	with current_app.test_request_context():
@@ -80,7 +73,7 @@ def show(name):
 		click.echo(f'Direct members: {", ".join(sorted([user.loginname for user in role.members]))}')
 		click.echo(f'Effective members: {", ".join(sorted([user.loginname for user in role.members_effective]))}')
 
-@role_cli.command(help='Create new role')
+@role_command.command(help='Create new role')
 @click.argument('name')
 @click.option('--description', default='', help='Set description text.')
 @click.option('--default/--no-default', default=False, help='Mark role as default or not. Non-service users are auto-added to default roles.')
@@ -99,7 +92,7 @@ def create(name, description, default, moderator_group, add_group, add_role):
 		except IntegrityError as ex:
 			raise click.ClickException(f'Role creation failed: {ex}')
 
-@role_cli.command(help='Update role attributes')
+@role_command.command(help='Update role attributes')
 @click.argument('name')
 @click.option('--description', help='Set description text.')
 @click.option('--default/--no-default', default=None, help='Mark role as default or not. Non-service users are auto-added to default roles.')
@@ -126,7 +119,7 @@ def update(name, description, default, moderator_group, no_moderator_group,
 		role.update_member_groups()
 		db.session.commit()
 
-@role_cli.command(help='Delete role')
+@role_command.command(help='Delete role')
 @click.argument('name')
 def delete(name):
 	with current_app.test_request_context():
diff --git a/uffd/commands/roles_update_all.py b/uffd/commands/roles_update_all.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ad676e12c745fc01e6ae6cad00f7509980310a8
--- /dev/null
+++ b/uffd/commands/roles_update_all.py
@@ -0,0 +1,30 @@
+import sys
+
+from flask import current_app
+from flask.cli import with_appcontext
+import click
+
+from uffd.database import db
+from uffd.models import User
+
+@click.command('roles-update-all', help='Update group memberships for all users based on their roles')
+@click.option('--check-only', is_flag=True)
+@with_appcontext
+def roles_update_all_command(check_only): #pylint: disable=unused-variable
+	consistent = True
+	with current_app.test_request_context():
+		for user in User.query.all():
+			groups_added, groups_removed = user.update_groups()
+			if groups_added:
+				consistent = False
+				print('Adding groups [%s] to user %s'%(', '.join([group.name for group in groups_added]), user.loginname))
+			if groups_removed:
+				consistent = False
+				print('Removing groups [%s] from user %s'%(', '.join([group.name for group in groups_removed]), user.loginname))
+		if not check_only:
+			db.session.commit()
+		if check_only and not consistent:
+			print('No changes were made because --check-only is set')
+			print()
+			print('Error: Groups are not consistent with roles in database')
+			sys.exit(1)
diff --git a/uffd/user/cli_user.py b/uffd/commands/user.py
similarity index 90%
rename from uffd/user/cli_user.py
rename to uffd/commands/user.py
index 490af1cf2a84441ad6d2fff7e24e94b43a8e76e1..5b82eec677016caf71c071ed4c63e811b3fe4484 100644
--- a/uffd/user/cli_user.py
+++ b/uffd/commands/user.py
@@ -1,19 +1,12 @@
-from flask import Blueprint, current_app
+from flask import current_app
 from flask.cli import AppGroup
 from sqlalchemy.exc import IntegrityError
 import click
 
-from uffd.role.models import Role
 from uffd.database import db
+from uffd.models import User, Role
 
-from .models import User
-
-bp = Blueprint('user_cli', __name__)
-user_cli = AppGroup('user', help='Manage users')
-
-@bp.record
-def add_cli_commands(state):
-	state.app.cli.add_command(user_cli)
+user_command = AppGroup('user', help='Manage users')
 
 # pylint: disable=too-many-arguments
 
@@ -42,13 +35,13 @@ def update_attrs(user, mail=None, displayname=None, password=None,
 		role.members.remove(user)
 	user.update_groups()
 
-@user_cli.command(help='List login names of all users')
+@user_command.command(help='List login names of all users')
 def list():
 	with current_app.test_request_context():
 		for user in User.query:
 			click.echo(user.loginname)
 
-@user_cli.command(help='Show details of user')
+@user_command.command(help='Show details of user')
 @click.argument('loginname')
 def show(loginname):
 	with current_app.test_request_context():
@@ -62,7 +55,7 @@ def show(loginname):
 		click.echo(f'Roles: {", ".join([role.name for role in user.roles])}')
 		click.echo(f'Groups: {", ".join([group.name for group in user.groups])}')
 
-@user_cli.command(help='Create new user')
+@user_command.command(help='Create new user')
 @click.argument('loginname')
 @click.option('--mail', required=True, metavar='EMAIL_ADDRESS', help='E-Mail address')
 @click.option('--displayname', help='Set display name. Defaults to login name.')
@@ -86,7 +79,7 @@ def create(loginname, mail, displayname, service, password, prompt_password, add
 		except IntegrityError as ex:
 			raise click.ClickException(f'User creation failed: {ex}')
 
-@user_cli.command(help='Update user attributes and roles')
+@user_command.command(help='Update user attributes and roles')
 @click.argument('loginname')
 @click.option('--mail', metavar='EMAIL_ADDRESS', help='Set e-mail address.')
 @click.option('--displayname', help='Set display name.')
@@ -103,7 +96,7 @@ def update(loginname, mail, displayname, password, prompt_password, clear_roles,
 		update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role)
 		db.session.commit()
 
-@user_cli.command(help='Delete user')
+@user_command.command(help='Delete user')
 @click.argument('loginname')
 def delete(loginname):
 	with current_app.test_request_context():
diff --git a/uffd/csrf/csrf.py b/uffd/csrf.py
similarity index 100%
rename from uffd/csrf/csrf.py
rename to uffd/csrf.py
diff --git a/uffd/csrf/__init__.py b/uffd/csrf/__init__.py
deleted file mode 100644
index 601eb5f16ebb0cd6d4a8bbeae30d261ebf584f21..0000000000000000000000000000000000000000
--- a/uffd/csrf/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .csrf import bp as csrf_bp, csrf_protect
-
-bp = [csrf_bp]
diff --git a/uffd/mfa/fido2_compat.py b/uffd/fido2_compat.py
similarity index 100%
rename from uffd/mfa/fido2_compat.py
rename to uffd/fido2_compat.py
diff --git a/uffd/invite/__init__.py b/uffd/invite/__init__.py
deleted file mode 100644
index 656390049779db11f3fbd9a16498218b18982068..0000000000000000000000000000000000000000
--- a/uffd/invite/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as _bp
-
-bp = [_bp]
diff --git a/uffd/mail/__init__.py b/uffd/mail/__init__.py
deleted file mode 100644
index 671578662f91c82cb987ffe679c1f102dc493d1f..0000000000000000000000000000000000000000
--- a/uffd/mail/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as bp_ui
-
-bp = [bp_ui]
diff --git a/uffd/mfa/__init__.py b/uffd/mfa/__init__.py
deleted file mode 100644
index 656390049779db11f3fbd9a16498218b18982068..0000000000000000000000000000000000000000
--- a/uffd/mfa/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as _bp
-
-bp = [_bp]
diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2734866ec39d5c7eef3f19e56952a07def7ac6bb
--- /dev/null
+++ b/uffd/models/__init__.py
@@ -0,0 +1,27 @@
+from .api import APIClient
+from .invite import Invite, InviteGrant, InviteSignup
+from .mail import Mail, MailReceiveAddress, MailDestinationAddress
+from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod
+from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
+from .role import Role, RoleGroup, RoleGroupMap
+from .selfservice import PasswordToken, MailToken
+from .service import Service, get_services
+from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation
+from .signup import Signup
+from .user import User, Group, RemailerAddress, remailer
+from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay
+
+__all__ = [
+	'APIClient',
+	'Invite', 'InviteGrant', 'InviteSignup',
+	'Mail', 'MailReceiveAddress', 'MailDestinationAddress',
+	'MFAType', 'MFAMethod', 'RecoveryCodeMethod', 'TOTPMethod', 'WebauthnMethod',
+	'OAuth2Client', 'OAuth2RedirectURI', 'OAuth2LogoutURI', 'OAuth2Grant', 'OAuth2Token', 'OAuth2DeviceLoginInitiation',
+	'Role', 'RoleGroup', 'RoleGroupMap',
+	'PasswordToken', 'MailToken',
+	'Service', 'get_services',
+	'DeviceLoginType', 'DeviceLoginInitiation', 'DeviceLoginConfirmation',
+	'Signup',
+	'User', 'Group', 'RemailerAddress', 'remailer',
+	'RatelimitEvent', 'Ratelimit', 'HostRatelimit', 'host_ratelimit', 'format_delay',
+]
diff --git a/uffd/api/models.py b/uffd/models/api.py
similarity index 100%
rename from uffd/api/models.py
rename to uffd/models/api.py
diff --git a/uffd/invite/models.py b/uffd/models/invite.py
similarity index 99%
rename from uffd/invite/models.py
rename to uffd/models/invite.py
index f3c970040ea1f225efde39d1238499e7e148c715..12018d35ca11089c70e31da611de5d3b5060727f 100644
--- a/uffd/invite/models.py
+++ b/uffd/models/invite.py
@@ -5,9 +5,9 @@ from flask import current_app
 from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Boolean
 from sqlalchemy.orm import relationship
 
-from uffd.database import db
-from uffd.signup.models import Signup
 from uffd.utils import token_urlfriendly
+from uffd.database import db
+from .signup import Signup
 
 invite_roles = db.Table('invite_roles',
 	Column('invite_id', Integer(), ForeignKey('invite.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True),
diff --git a/uffd/mail/models.py b/uffd/models/mail.py
similarity index 100%
rename from uffd/mail/models.py
rename to uffd/models/mail.py
diff --git a/uffd/mfa/models.py b/uffd/models/mfa.py
similarity index 97%
rename from uffd/mfa/models.py
rename to uffd/models/mfa.py
index 1e117ac64842ce5619d640a33f6ffdc8fbca079d..7db21b5cd3f97ffad9da32c4f41d931ce1b26180 100644
--- a/uffd/mfa/models.py
+++ b/uffd/models/mfa.py
@@ -16,7 +16,7 @@ from sqlalchemy import Column, Integer, Enum, String, DateTime, Text, ForeignKey
 from sqlalchemy.orm import relationship, backref
 
 from uffd.database import db
-from uffd.user.models import User
+from .user import User
 
 User.mfa_enabled = property(lambda user: bool(user.mfa_totp_methods or user.mfa_webauthn_methods))
 
@@ -141,7 +141,7 @@ class WebauthnMethod(MFAMethod):
 
 	@property
 	def cred(self):
-		from .fido2_compat import AttestedCredentialData #pylint: disable=import-outside-toplevel
+		from uffd.fido2_compat import AttestedCredentialData #pylint: disable=import-outside-toplevel
 		return AttestedCredentialData(base64.b64decode(self._cred))
 
 	@cred.setter
diff --git a/uffd/oauth2/models.py b/uffd/models/oauth2.py
similarity index 98%
rename from uffd/oauth2/models.py
rename to uffd/models/oauth2.py
index 6ed91097900bda7b42f6620336ab66f47c075cb7..dd0df00f3b068c8db4529c13a347431574e0c30d 100644
--- a/uffd/oauth2/models.py
+++ b/uffd/models/oauth2.py
@@ -9,7 +9,7 @@ from sqlalchemy.ext.associationproxy import association_proxy
 from uffd.database import db, CommaSeparatedList
 from uffd.tasks import cleanup_task
 from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash
-from uffd.session.models import DeviceLoginInitiation, DeviceLoginType
+from .session import DeviceLoginInitiation, DeviceLoginType
 
 class OAuth2Client(db.Model):
 	__tablename__ = 'oauth2client'
diff --git a/uffd/ratelimit.py b/uffd/models/ratelimit.py
similarity index 100%
rename from uffd/ratelimit.py
rename to uffd/models/ratelimit.py
index 5604299d983a0e076e107dc2f9e1f46e5f14cfb3..cd370956b0e9fa980ff09e5e4d5faca614c83a52 100644
--- a/uffd/ratelimit.py
+++ b/uffd/models/ratelimit.py
@@ -7,8 +7,8 @@ from flask_babel import gettext as _
 from sqlalchemy import Column, Integer, String, DateTime
 from sqlalchemy.ext.hybrid import hybrid_property
 
-from uffd.database import db
 from uffd.tasks import cleanup_task
+from uffd.database import db
 
 @cleanup_task.delete_by_attribute('expired')
 class RatelimitEvent(db.Model):
diff --git a/uffd/role/models.py b/uffd/models/role.py
similarity index 99%
rename from uffd/role/models.py
rename to uffd/models/role.py
index 8bd6fcfcbac5f9ff289f9c6ff845dff232a4304e..27111b8c0524a7c6b335989b5a91b24b9a439e2d 100644
--- a/uffd/role/models.py
+++ b/uffd/models/role.py
@@ -3,7 +3,7 @@ from sqlalchemy.orm import relationship
 from sqlalchemy.orm.collections import MappedCollection, collection
 
 from uffd.database import db
-from uffd.user.models import User
+from .user import User
 
 class RoleGroup(db.Model):
 	__tablename__ = 'role_groups'
diff --git a/uffd/selfservice/models.py b/uffd/models/selfservice.py
similarity index 100%
rename from uffd/selfservice/models.py
rename to uffd/models/selfservice.py
index 4ac327bd597230040c78cc3c020f990853fa7819..bb1484579e70b27f948d9b55638f25c124ef018c 100644
--- a/uffd/selfservice/models.py
+++ b/uffd/models/selfservice.py
@@ -5,8 +5,8 @@ from sqlalchemy.orm import relationship
 from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
-from uffd.tasks import cleanup_task
 from uffd.utils import token_urlfriendly
+from uffd.tasks import cleanup_task
 
 @cleanup_task.delete_by_attribute('expired')
 class PasswordToken(db.Model):
diff --git a/uffd/service/models.py b/uffd/models/service.py
similarity index 100%
rename from uffd/service/models.py
rename to uffd/models/service.py
diff --git a/uffd/session/models.py b/uffd/models/session.py
similarity index 100%
rename from uffd/session/models.py
rename to uffd/models/session.py
index 4b2099e8a5c88780778146b686ab1b40f068026e..6b72912b08c0b7a068a46ad3e0932bd055f6d2c3 100644
--- a/uffd/session/models.py
+++ b/uffd/models/session.py
@@ -7,8 +7,8 @@ from sqlalchemy.orm import relationship
 from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
-from uffd.tasks import cleanup_task
 from uffd.utils import token_typeable
+from uffd.tasks import cleanup_task
 
 # Device login provides a convenient and secure way to log into SSO-enabled
 # services on a secondary device without entering the user password or
diff --git a/uffd/signup/models.py b/uffd/models/signup.py
similarity index 99%
rename from uffd/signup/models.py
rename to uffd/models/signup.py
index 073d5eabdc59c3969bb867e83b212fd79691afd9..204edbc416d1c0b8c571499f12cb1a50454b6c5a 100644
--- a/uffd/signup/models.py
+++ b/uffd/models/signup.py
@@ -5,11 +5,11 @@ from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
 from sqlalchemy.orm import relationship, backref
 from sqlalchemy.ext.hybrid import hybrid_property
 
-from uffd.database import db
 from uffd.tasks import cleanup_task
-from uffd.user.models import User
 from uffd.utils import token_urlfriendly
 from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash
+from uffd.database import db
+from .user import User
 
 @cleanup_task.delete_by_attribute('expired_and_not_completed')
 class Signup(db.Model):
diff --git a/uffd/user/models.py b/uffd/models/user.py
similarity index 99%
rename from uffd/user/models.py
rename to uffd/models/user.py
index 8ff69508929b1714087c940b25a01871c85a7bf5..a91c9fe75f5f5f8e616da3c2dd2f12b9c680c1f6 100644
--- a/uffd/user/models.py
+++ b/uffd/models/user.py
@@ -172,7 +172,7 @@ class Remailer:
 	def parse_address(self, address):
 		# With a top-level import we get circular import problems
 		# pylint: disable=import-outside-toplevel
-		from uffd.service.models import Service
+		from .service import Service
 		if '@' not in address:
 			return None
 		local_part, domain = address.rsplit('@', 1)
diff --git a/uffd/oauth2/__init__.py b/uffd/oauth2/__init__.py
deleted file mode 100644
index 656390049779db11f3fbd9a16498218b18982068..0000000000000000000000000000000000000000
--- a/uffd/oauth2/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as _bp
-
-bp = [_bp]
diff --git a/uffd/role/__init__.py b/uffd/role/__init__.py
deleted file mode 100644
index 550874c08599da42cff0a0caf7c4d2c04e085400..0000000000000000000000000000000000000000
--- a/uffd/role/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .views import bp as bp_ui
-from .cli import bp as bp_cli
-
-bp = [bp_ui, bp_cli]
diff --git a/uffd/rolemod/__init__.py b/uffd/rolemod/__init__.py
deleted file mode 100644
index 671578662f91c82cb987ffe679c1f102dc493d1f..0000000000000000000000000000000000000000
--- a/uffd/rolemod/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as bp_ui
-
-bp = [bp_ui]
diff --git a/uffd/selfservice/__init__.py b/uffd/selfservice/__init__.py
deleted file mode 100644
index 69a1df974cdf718d8d51c2672d3d43611b3bbe03..0000000000000000000000000000000000000000
--- a/uffd/selfservice/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as bp_ui, send_passwordreset
-
-bp = [bp_ui]
diff --git a/uffd/service/__init__.py b/uffd/service/__init__.py
deleted file mode 100644
index 656390049779db11f3fbd9a16498218b18982068..0000000000000000000000000000000000000000
--- a/uffd/service/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as _bp
-
-bp = [_bp]
diff --git a/uffd/session/__init__.py b/uffd/session/__init__.py
deleted file mode 100644
index 0e571f3a6aec00dcb8dd2d7eff8788441b735f3d..0000000000000000000000000000000000000000
--- a/uffd/session/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as bp_ui, login_required, set_session
-
-bp = [bp_ui]
diff --git a/uffd/signup/__init__.py b/uffd/signup/__init__.py
deleted file mode 100644
index 656390049779db11f3fbd9a16498218b18982068..0000000000000000000000000000000000000000
--- a/uffd/signup/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .views import bp as _bp
-
-bp = [_bp]
diff --git a/uffd/tasks.py b/uffd/tasks.py
index 4ae6d380f9d221b54d8c57ce2ade602c4e065eb8..85f7b412002942ca1a412efa897d72584ee79b04 100644
--- a/uffd/tasks.py
+++ b/uffd/tasks.py
@@ -1,14 +1,6 @@
 class CleanupTask:
-	def __init__(self, *init_args):
+	def __init__(self):
 		self.handlers = []
-		if init_args:
-			self.init_app(*init_args)
-
-	def init_app(self, app, db):
-		@app.cli.command('cleanup', help='Cleanup expired data')
-		def cleanup(): #pylint: disable=unused-variable
-			self.run()
-			db.session.commit()
 
 	def handler(self, func):
 		self.handlers.append(func)
diff --git a/uffd/user/templates/group/list.html b/uffd/templates/group/list.html
similarity index 100%
rename from uffd/user/templates/group/list.html
rename to uffd/templates/group/list.html
diff --git a/uffd/user/templates/group/show.html b/uffd/templates/group/show.html
similarity index 100%
rename from uffd/user/templates/group/show.html
rename to uffd/templates/group/show.html
diff --git a/uffd/invite/templates/invite/list.html b/uffd/templates/invite/list.html
similarity index 100%
rename from uffd/invite/templates/invite/list.html
rename to uffd/templates/invite/list.html
diff --git a/uffd/invite/templates/invite/new.html b/uffd/templates/invite/new.html
similarity index 100%
rename from uffd/invite/templates/invite/new.html
rename to uffd/templates/invite/new.html
diff --git a/uffd/invite/templates/invite/use.html b/uffd/templates/invite/use.html
similarity index 100%
rename from uffd/invite/templates/invite/use.html
rename to uffd/templates/invite/use.html
diff --git a/uffd/mail/templates/mail/list.html b/uffd/templates/mail/list.html
similarity index 100%
rename from uffd/mail/templates/mail/list.html
rename to uffd/templates/mail/list.html
diff --git a/uffd/mail/templates/mail/show.html b/uffd/templates/mail/show.html
similarity index 100%
rename from uffd/mail/templates/mail/show.html
rename to uffd/templates/mail/show.html
diff --git a/uffd/mfa/templates/mfa/auth.html b/uffd/templates/mfa/auth.html
similarity index 100%
rename from uffd/mfa/templates/mfa/auth.html
rename to uffd/templates/mfa/auth.html
diff --git a/uffd/mfa/templates/mfa/disable.html b/uffd/templates/mfa/disable.html
similarity index 100%
rename from uffd/mfa/templates/mfa/disable.html
rename to uffd/templates/mfa/disable.html
diff --git a/uffd/mfa/templates/mfa/setup.html b/uffd/templates/mfa/setup.html
similarity index 100%
rename from uffd/mfa/templates/mfa/setup.html
rename to uffd/templates/mfa/setup.html
diff --git a/uffd/mfa/templates/mfa/setup_recovery.html b/uffd/templates/mfa/setup_recovery.html
similarity index 100%
rename from uffd/mfa/templates/mfa/setup_recovery.html
rename to uffd/templates/mfa/setup_recovery.html
diff --git a/uffd/mfa/templates/mfa/setup_totp.html b/uffd/templates/mfa/setup_totp.html
similarity index 100%
rename from uffd/mfa/templates/mfa/setup_totp.html
rename to uffd/templates/mfa/setup_totp.html
diff --git a/uffd/oauth2/templates/oauth2/error.html b/uffd/templates/oauth2/error.html
similarity index 100%
rename from uffd/oauth2/templates/oauth2/error.html
rename to uffd/templates/oauth2/error.html
diff --git a/uffd/oauth2/templates/oauth2/logout.html b/uffd/templates/oauth2/logout.html
similarity index 100%
rename from uffd/oauth2/templates/oauth2/logout.html
rename to uffd/templates/oauth2/logout.html
diff --git a/uffd/role/templates/role/list.html b/uffd/templates/role/list.html
similarity index 100%
rename from uffd/role/templates/role/list.html
rename to uffd/templates/role/list.html
diff --git a/uffd/role/templates/role/show.html b/uffd/templates/role/show.html
similarity index 100%
rename from uffd/role/templates/role/show.html
rename to uffd/templates/role/show.html
diff --git a/uffd/rolemod/templates/rolemod/list.html b/uffd/templates/rolemod/list.html
similarity index 100%
rename from uffd/rolemod/templates/rolemod/list.html
rename to uffd/templates/rolemod/list.html
diff --git a/uffd/rolemod/templates/rolemod/show.html b/uffd/templates/rolemod/show.html
similarity index 100%
rename from uffd/rolemod/templates/rolemod/show.html
rename to uffd/templates/rolemod/show.html
diff --git a/uffd/selfservice/templates/selfservice/forgot_password.html b/uffd/templates/selfservice/forgot_password.html
similarity index 100%
rename from uffd/selfservice/templates/selfservice/forgot_password.html
rename to uffd/templates/selfservice/forgot_password.html
diff --git a/uffd/selfservice/templates/selfservice/mailverification.mail.txt b/uffd/templates/selfservice/mailverification.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/selfservice/mailverification.mail.txt
rename to uffd/templates/selfservice/mailverification.mail.txt
diff --git a/uffd/selfservice/templates/selfservice/newuser.mail.txt b/uffd/templates/selfservice/newuser.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/selfservice/newuser.mail.txt
rename to uffd/templates/selfservice/newuser.mail.txt
diff --git a/uffd/selfservice/templates/selfservice/passwordreset.mail.txt b/uffd/templates/selfservice/passwordreset.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/selfservice/passwordreset.mail.txt
rename to uffd/templates/selfservice/passwordreset.mail.txt
diff --git a/uffd/selfservice/templates/selfservice/self.html b/uffd/templates/selfservice/self.html
similarity index 100%
rename from uffd/selfservice/templates/selfservice/self.html
rename to uffd/templates/selfservice/self.html
diff --git a/uffd/selfservice/templates/selfservice/set_password.html b/uffd/templates/selfservice/set_password.html
similarity index 100%
rename from uffd/selfservice/templates/selfservice/set_password.html
rename to uffd/templates/selfservice/set_password.html
diff --git a/uffd/service/templates/service/api.html b/uffd/templates/service/api.html
similarity index 100%
rename from uffd/service/templates/service/api.html
rename to uffd/templates/service/api.html
diff --git a/uffd/service/templates/service/index.html b/uffd/templates/service/index.html
similarity index 100%
rename from uffd/service/templates/service/index.html
rename to uffd/templates/service/index.html
diff --git a/uffd/service/templates/service/oauth2.html b/uffd/templates/service/oauth2.html
similarity index 100%
rename from uffd/service/templates/service/oauth2.html
rename to uffd/templates/service/oauth2.html
diff --git a/uffd/service/templates/service/overview.html b/uffd/templates/service/overview.html
similarity index 100%
rename from uffd/service/templates/service/overview.html
rename to uffd/templates/service/overview.html
diff --git a/uffd/service/templates/service/show.html b/uffd/templates/service/show.html
similarity index 100%
rename from uffd/service/templates/service/show.html
rename to uffd/templates/service/show.html
diff --git a/uffd/session/templates/session/deviceauth.html b/uffd/templates/session/deviceauth.html
similarity index 100%
rename from uffd/session/templates/session/deviceauth.html
rename to uffd/templates/session/deviceauth.html
diff --git a/uffd/session/templates/session/devicelogin.html b/uffd/templates/session/devicelogin.html
similarity index 100%
rename from uffd/session/templates/session/devicelogin.html
rename to uffd/templates/session/devicelogin.html
diff --git a/uffd/session/templates/session/login.html b/uffd/templates/session/login.html
similarity index 100%
rename from uffd/session/templates/session/login.html
rename to uffd/templates/session/login.html
diff --git a/uffd/signup/templates/signup/confirm.html b/uffd/templates/signup/confirm.html
similarity index 100%
rename from uffd/signup/templates/signup/confirm.html
rename to uffd/templates/signup/confirm.html
diff --git a/uffd/signup/templates/signup/mail.txt b/uffd/templates/signup/mail.txt
similarity index 100%
rename from uffd/signup/templates/signup/mail.txt
rename to uffd/templates/signup/mail.txt
diff --git a/uffd/signup/templates/signup/start.html b/uffd/templates/signup/start.html
similarity index 100%
rename from uffd/signup/templates/signup/start.html
rename to uffd/templates/signup/start.html
diff --git a/uffd/signup/templates/signup/submitted.html b/uffd/templates/signup/submitted.html
similarity index 100%
rename from uffd/signup/templates/signup/submitted.html
rename to uffd/templates/signup/submitted.html
diff --git a/uffd/user/templates/user/list.html b/uffd/templates/user/list.html
similarity index 100%
rename from uffd/user/templates/user/list.html
rename to uffd/templates/user/list.html
diff --git a/uffd/user/templates/user/show.html b/uffd/templates/user/show.html
similarity index 100%
rename from uffd/user/templates/user/show.html
rename to uffd/templates/user/show.html
diff --git a/uffd/user/__init__.py b/uffd/user/__init__.py
deleted file mode 100644
index 17a20ec5bc904b2bf507b0e3f9aa922767a2452e..0000000000000000000000000000000000000000
--- a/uffd/user/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .views_user import bp as bp_user
-from .cli_user import bp as bp_cli_user
-from .views_group import bp as bp_group
-from .cli_group import bp as bp_cli_group
-
-bp = [bp_user, bp_group, bp_cli_user, bp_cli_group]
diff --git a/uffd/views/__init__.py b/uffd/views/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4f5bdb08fdb0ae7a282d4e57df70e1b84f39e45f
--- /dev/null
+++ b/uffd/views/__init__.py
@@ -0,0 +1,38 @@
+from flask import redirect, url_for, request, render_template
+from werkzeug.exceptions import Forbidden
+
+from uffd.secure_redirect import secure_local_redirect
+
+from . import session, selfservice, signup, mfa, oauth2, user, group, service, role, invite, api, mail, rolemod
+
+def init_app(app):
+	@app.errorhandler(403)
+	def handle_403(error):
+		return render_template('403.html', description=error.description if error.description != Forbidden.description else None), 403
+
+	@app.route("/")
+	def index(): #pylint: disable=unused-variable
+		if app.config['DEFAULT_PAGE_SERVICES']:
+			return redirect(url_for('service.overview'))
+		return redirect(url_for('selfservice.index'))
+
+	@app.route('/lang', methods=['POST'])
+	def setlang(): #pylint: disable=unused-variable
+		resp = secure_local_redirect(request.values.get('ref', '/'))
+		if 'lang' in request.values:
+			resp.set_cookie('language', request.values['lang'])
+		return resp
+
+	app.register_blueprint(session.bp)
+	app.register_blueprint(selfservice.bp)
+	app.register_blueprint(signup.bp)
+	app.register_blueprint(mfa.bp)
+	app.register_blueprint(oauth2.bp)
+	app.register_blueprint(user.bp)
+	app.register_blueprint(group.bp)
+	app.register_blueprint(service.bp)
+	app.register_blueprint(role.bp)
+	app.register_blueprint(invite.bp)
+	app.register_blueprint(api.bp)
+	app.register_blueprint(mail.bp)
+	app.register_blueprint(rolemod.bp)
diff --git a/uffd/api/views.py b/uffd/views/api.py
similarity index 95%
rename from uffd/api/views.py
rename to uffd/views/api.py
index 33e7122ab2726e219955b03873a7834744fb4d0d..11d59db408c2e00f3302ae74b0c24ffd80e42803 100644
--- a/uffd/api/views.py
+++ b/uffd/views/api.py
@@ -2,11 +2,9 @@ import functools
 
 from flask import Blueprint, jsonify, request, abort
 
-from uffd.user.models import User, remailer, Group
-from uffd.mail.models import Mail, MailReceiveAddress, MailDestinationAddress
-from uffd.api.models import APIClient
-from uffd.session.views import login_get_user, login_ratelimit
 from uffd.database import db
+from uffd.models import User, remailer, Group, Mail, MailReceiveAddress, MailDestinationAddress, APIClient
+from .session import login_get_user, login_ratelimit
 
 bp = Blueprint('api', __name__, template_folder='templates', url_prefix='/api/v1/')
 
diff --git a/uffd/user/views_group.py b/uffd/views/group.py
similarity index 96%
rename from uffd/user/views_group.py
rename to uffd/views/group.py
index 63d281f487aacbff8e7aa1c00f5d75926b73c5a2..d70751ef58127036ea722ccbb96cc842bcb41665 100644
--- a/uffd/user/views_group.py
+++ b/uffd/views/group.py
@@ -4,10 +4,9 @@ import sqlalchemy
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
-from uffd.session import login_required
 from uffd.database import db
-
-from .models import Group
+from uffd.models import Group
+from .session import login_required
 
 bp = Blueprint("group", __name__, template_folder='templates', url_prefix='/group/')
 
diff --git a/uffd/invite/views.py b/uffd/views/invite.py
similarity index 95%
rename from uffd/invite/views.py
rename to uffd/views/invite.py
index 7e8074c7b1f414f937de8c56feb5df4f01e8e823..99b0675ed57ac571d90b38b13a097f170e5b1738 100644
--- a/uffd/invite/views.py
+++ b/uffd/views/invite.py
@@ -6,16 +6,13 @@ from flask_babel import gettext as _, lazy_gettext
 import sqlalchemy
 
 from uffd.csrf import csrf_protect
-from uffd.database import db
-from uffd.session import login_required
-from uffd.role.models import Role
-from uffd.invite.models import Invite, InviteSignup, InviteGrant
-from uffd.user.models import User, Group
 from uffd.sendmail import sendmail
 from uffd.navbar import register_navbar
-from uffd.ratelimit import host_ratelimit, format_delay
-from uffd.signup.views import signup_ratelimit
-from uffd.selfservice.views import selfservice_acl_check
+from uffd.database import db
+from uffd.models import Role, User, Group, Invite, InviteSignup, InviteGrant, host_ratelimit, format_delay
+from .session import login_required
+from .signup import signup_ratelimit
+from .selfservice import selfservice_acl_check
 
 bp = Blueprint('invite', __name__, template_folder='templates', url_prefix='/invite/')
 
diff --git a/uffd/mail/views.py b/uffd/views/mail.py
similarity index 96%
rename from uffd/mail/views.py
rename to uffd/views/mail.py
index 30981efa38f617eae29ca71d9b8b7dee4b023c6b..7bd2504907e2e7068a87128e5acc230e9d328787 100644
--- a/uffd/mail/views.py
+++ b/uffd/views/mail.py
@@ -4,9 +4,8 @@ from flask_babel import gettext as _, lazy_gettext
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.database import db
-from uffd.session import login_required
-
-from uffd.mail.models import Mail
+from uffd.models import Mail
+from .session import login_required
 
 bp = Blueprint("mail", __name__, template_folder='templates', url_prefix='/mail/')
 
diff --git a/uffd/mfa/views.py b/uffd/views/mfa.py
similarity index 96%
rename from uffd/mfa/views.py
rename to uffd/views/mfa.py
index bae2464942113ab7636d998e1d6f6d2e85a6dc5f..7471bef285048d31add7d0bf4bfb8a1883292af3 100644
--- a/uffd/mfa/views.py
+++ b/uffd/views/mfa.py
@@ -4,13 +4,11 @@ import urllib.parse
 from flask import Blueprint, render_template, session, request, redirect, url_for, flash, current_app, abort
 from flask_babel import gettext as _
 
-from uffd.database import db
-from uffd.mfa.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod
-from uffd.session.views import login_required, login_required_pre_mfa, set_request_user
-from uffd.user.models import User
 from uffd.csrf import csrf_protect
 from uffd.secure_redirect import secure_local_redirect
-from uffd.ratelimit import Ratelimit, format_delay
+from uffd.database import db
+from uffd.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod, User, Ratelimit, format_delay
+from .session import login_required, login_required_pre_mfa, set_request_user
 
 bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/')
 
@@ -101,7 +99,7 @@ def delete_totp(id): #pylint: disable=redefined-builtin
 # WebAuthn support is optional because fido2 has a pretty unstable
 # interface and might be difficult to install with the correct version
 try:
-	from .fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import
+	from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import
 	WEBAUTHN_SUPPORTED = True
 except ImportError as err:
 	warn(_('2FA WebAuthn support disabled because import of the fido2 module failed (%s)')%err)
diff --git a/uffd/oauth2/views.py b/uffd/views/oauth2.py
similarity index 98%
rename from uffd/oauth2/views.py
rename to uffd/views/oauth2.py
index e53bf7ccd28872d16216ea70a9d80f69be06e539..d9219cab5c02c6ffae05fc9f4eceb51d596fbfe5 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/views/oauth2.py
@@ -7,11 +7,9 @@ import oauthlib.oauth2
 from flask_babel import gettext as _
 from sqlalchemy.exc import IntegrityError
 
-from uffd.ratelimit import host_ratelimit, format_delay
-from uffd.database import db
 from uffd.secure_redirect import secure_local_redirect
-from uffd.session.models import DeviceLoginConfirmation
-from .models import OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
+from uffd.database import db
+from uffd.models import DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, host_ratelimit, format_delay
 
 class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 	# Argument "oauthreq" is named "request" in superclass but this clashes with flask's "request" object
diff --git a/uffd/role/views.py b/uffd/views/role.py
similarity index 76%
rename from uffd/role/views.py
rename to uffd/views/role.py
index ce99dafcca86bb2832e9b813ae713aeecca31491..153c2cda47834384c0343d8db5873c695c7240ba 100644
--- a/uffd/role/views.py
+++ b/uffd/views/role.py
@@ -1,41 +1,14 @@
-import sys
-
 from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
 from flask_babel import gettext as _, lazy_gettext
-import click
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
-from uffd.role.models import Role, RoleGroup
-from uffd.user.models import User, Group
-from uffd.session import login_required
 from uffd.database import db
+from uffd.models import Role, RoleGroup, Group
+from .session import login_required
 
 bp = Blueprint("role", __name__, template_folder='templates', url_prefix='/role/')
 
-@bp.record
-def add_cli_commands(state):
-	@state.app.cli.command('roles-update-all', help='Update group memberships for all users based on their roles')
-	@click.option('--check-only', is_flag=True)
-	def roles_update_all(check_only): #pylint: disable=unused-variable
-		consistent = True
-		with current_app.test_request_context():
-			for user in User.query.all():
-				groups_added, groups_removed = user.update_groups()
-				if groups_added:
-					consistent = False
-					print('Adding groups [%s] to user %s'%(', '.join([group.name for group in groups_added]), user.loginname))
-				if groups_removed:
-					consistent = False
-					print('Removing groups [%s] from user %s'%(', '.join([group.name for group in groups_removed]), user.loginname))
-			if not check_only:
-				db.session.commit()
-			if check_only and not consistent:
-				print('No changes were made because --check-only is set')
-				print()
-				print('Error: LDAP groups are not consistent with roles in database')
-				sys.exit(1)
-
 def role_acl_check():
 	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
 
diff --git a/uffd/rolemod/views.py b/uffd/views/rolemod.py
similarity index 94%
rename from uffd/rolemod/views.py
rename to uffd/views/rolemod.py
index d1c520b72db617f98b3db1ca0264c9c6ebcec6ba..61dcdbffcfe8cf92095376e25e78f8a7e63c2f95 100644
--- a/uffd/rolemod/views.py
+++ b/uffd/views/rolemod.py
@@ -3,10 +3,9 @@ from flask_babel import gettext as _, lazy_gettext
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
-from uffd.role.models import Role
-from uffd.user.models import User, Group
-from uffd.session import login_required
 from uffd.database import db
+from uffd.models import Role, User, Group
+from .session import login_required
 
 bp = Blueprint('rolemod', __name__, template_folder='templates', url_prefix='/rolemod/')
 
diff --git a/uffd/selfservice/views.py b/uffd/views/selfservice.py
similarity index 96%
rename from uffd/selfservice/views.py
rename to uffd/views/selfservice.py
index 3103c118acaf64ac723b2e9234e08d810bc6a708..fee71b8db93a74f3a8a00693dad9ff2c11596c68 100644
--- a/uffd/selfservice/views.py
+++ b/uffd/views/selfservice.py
@@ -5,13 +5,10 @@ from flask_babel import gettext as _, lazy_gettext
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
-from uffd.user.models import User
-from uffd.session import login_required
-from uffd.selfservice.models import PasswordToken, MailToken
 from uffd.sendmail import sendmail
-from uffd.role.models import Role
 from uffd.database import db
-from uffd.ratelimit import host_ratelimit, Ratelimit, format_delay
+from uffd.models import User, PasswordToken, MailToken, Role, host_ratelimit, Ratelimit, format_delay
+from .session import login_required
 
 bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/')
 
diff --git a/uffd/service/views.py b/uffd/views/service.py
similarity index 96%
rename from uffd/service/views.py
rename to uffd/views/service.py
index edcb1c32c33bc9b1ed04e2b8bfe1e44d74280687..dbedf510577ba3a0cb6b161dafaeb0dfd446a659 100644
--- a/uffd/service/views.py
+++ b/uffd/views/service.py
@@ -5,12 +5,10 @@ from flask_babel import lazy_gettext
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
-from uffd.session import login_required
-from uffd.service.models import Service, get_services
-from uffd.user.models import Group
-from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI
-from uffd.api.models import APIClient
 from uffd.database import db
+from uffd.models import Service, get_services, Group, OAuth2Client, OAuth2LogoutURI, APIClient
+
+from .session import login_required
 
 bp = Blueprint('service', __name__, template_folder='templates')
 
diff --git a/uffd/session/views.py b/uffd/views/session.py
similarity index 97%
rename from uffd/session/views.py
rename to uffd/views/session.py
index 690397bc10736f925d1c634c83a50790d3f25f19..3f6a6187452e79193b715953608f2d1bfa58c1d7 100644
--- a/uffd/session/views.py
+++ b/uffd/views/session.py
@@ -8,9 +8,7 @@ from flask_babel import gettext as _
 from uffd.database import db
 from uffd.csrf import csrf_protect
 from uffd.secure_redirect import secure_local_redirect
-from uffd.user.models import User
-from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay
-from uffd.session.models import DeviceLoginInitiation, DeviceLoginConfirmation
+from uffd.models import User, DeviceLoginInitiation, DeviceLoginConfirmation, Ratelimit, host_ratelimit, format_delay
 
 bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
 
diff --git a/uffd/signup/views.py b/uffd/views/signup.py
similarity index 96%
rename from uffd/signup/views.py
rename to uffd/views/signup.py
index 752cc587fc283ec42daefea3dd36e0e6748fc5d9..edbd3dcb5b073a2683a516816959273334414f0a 100644
--- a/uffd/signup/views.py
+++ b/uffd/views/signup.py
@@ -4,12 +4,10 @@ import secrets
 from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
 from flask_babel import gettext as _
 
-from uffd.database import db
-from uffd.session import set_session
-from uffd.user.models import User
 from uffd.sendmail import sendmail
-from uffd.signup.models import Signup
-from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay
+from uffd.database import db
+from uffd.models import User, Signup, Ratelimit, host_ratelimit, format_delay
+from .session import set_session
 
 bp = Blueprint('signup', __name__, template_folder='templates', url_prefix='/signup/')
 
diff --git a/uffd/user/views_user.py b/uffd/views/user.py
similarity index 96%
rename from uffd/user/views_user.py
rename to uffd/views/user.py
index d9a8800b3738b40882babef1ce3f2203ad6627d7..3ecf45d0e1a4942ebefb4dfa69ba41b8db288015 100644
--- a/uffd/user/views_user.py
+++ b/uffd/views/user.py
@@ -7,12 +7,10 @@ from sqlalchemy.exc import IntegrityError
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
-from uffd.selfservice import send_passwordreset
-from uffd.session import login_required
-from uffd.role.models import Role
 from uffd.database import db
-
-from .models import User, remailer
+from uffd.models import User, remailer, Role
+from .selfservice import send_passwordreset
+from .session import login_required
 
 bp = Blueprint("user", __name__, template_folder='templates', url_prefix='/user/')