From 6b2ee671395580a897a3084d07f78b76447a5bb4 Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Sun, 13 Nov 2022 16:10:51 +0100
Subject: [PATCH] Add user deactivation

---
 tests/commands/test_user.py                   |  17 ++
 tests/models/test_invite.py                   |   3 +
 tests/views/test_api.py                       |  59 ++++++
 tests/views/test_oauth2.py                    |  35 ++++
 tests/views/test_selfservice.py               |  13 ++
 tests/views/test_services.py                  |  19 ++
 tests/views/test_session.py                   |  27 ++-
 tests/views/test_signup.py                    |   9 +-
 tests/views/test_user.py                      |  19 ++
 uffd/commands/user.py                         |  15 +-
 .../versions/23293f32b503_deactivate_users.py |  90 ++++++++
 uffd/models/invite.py                         |   2 +
 uffd/models/service.py                        |   1 +
 uffd/models/user.py                           |   1 +
 uffd/templates/role/show.html                 |   4 +-
 uffd/templates/service/show.html              |   7 +
 uffd/templates/user/list.html                 |   3 +
 uffd/templates/user/show.html                 |  18 +-
 uffd/translations/de/LC_MESSAGES/messages.mo  | Bin 40611 -> 41508 bytes
 uffd/translations/de/LC_MESSAGES/messages.po  | 194 +++++++++++-------
 uffd/views/api.py                             |  12 +-
 uffd/views/oauth2.py                          |   7 +-
 uffd/views/selfservice.py                     |   2 +-
 uffd/views/service.py                         |   1 +
 uffd/views/session.py                         |  17 +-
 uffd/views/user.py                            |  18 ++
 26 files changed, 481 insertions(+), 112 deletions(-)
 create mode 100644 uffd/migrations/versions/23293f32b503_deactivate_users.py

diff --git a/tests/commands/test_user.py b/tests/commands/test_user.py
index 3612fc3b..3fcf49e8 100644
--- a/tests/commands/test_user.py
+++ b/tests/commands/test_user.py
@@ -57,6 +57,13 @@ class TestUserCLI(UffdTestCase):
 			self.assertTrue(user.password.verify('newpassword'))
 			self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
 			self.assertIn(self.get_admin_group(), user.groups)
+			self.assertFalse(user.is_deactivated)
+
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser2', '--mail', 'newmail2@example.com', '--deactivate'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='newuser2').first()
+			self.assertTrue(user.is_deactivated)
 
 	def test_update(self):
 		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'doesnotexist', '--displayname', 'foo'])
@@ -106,6 +113,16 @@ class TestUserCLI(UffdTestCase):
 			user = User.query.filter_by(loginname='testuser').first()
 			self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
 			self.assertIn(self.get_admin_group(), user.groups)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--deactivate'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='testuser').first()
+			self.assertTrue(user.is_deactivated)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--activate'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='testuser').first()
+			self.assertFalse(user.is_deactivated)
 
 	def test_delete(self):
 		with self.app.test_request_context():
diff --git a/tests/models/test_invite.py b/tests/models/test_invite.py
index fbbf8ab7..31fb104a 100644
--- a/tests/models/test_invite.py
+++ b/tests/models/test_invite.py
@@ -38,6 +38,9 @@ class TestInviteModel(UffdTestCase):
 		invite.creator = self.get_admin()
 		self.assertTrue(invite.permitted)
 		self.assertTrue(invite.active)
+		invite.creator.is_deactivated = True
+		self.assertFalse(invite.permitted)
+		self.assertFalse(invite.active)
 		invite.creator = self.get_user()
 		self.assertFalse(invite.permitted)
 		self.assertFalse(invite.active)
diff --git a/tests/views/test_api.py b/tests/views/test_api.py
index fce8554b..996bb1be 100644
--- a/tests/views/test_api.py
+++ b/tests/views/test_api.py
@@ -109,6 +109,13 @@ class TestAPICheckPassword(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertEqual(r.json, None)
 
+	def test_deactivated(self):
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.client.post(path=url_for('api.checkpassword'), data={'loginname': 'testuser', 'password': 'userpassword'}, headers=[basic_auth('test', 'test')])
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.json, None)
+
 class TestAPIGetusers(UffdTestCase):
 	def setUpDB(self):
 		db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_users=True))
@@ -201,6 +208,23 @@ class TestAPIGetusers(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertEqual(r.json, [])
 
+	def test_deactivated(self):
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.client.get(path=url_for('api.getusers'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(self.fix_result(r.json), [
+			{'displayname': 'Test User', 'email': 'test@example.com', 'id': 10000, 'loginname': 'testuser', 'groups': ['uffd_access', 'users']},
+			{'displayname': 'Test Admin', 'email': 'admin@example.com', 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']}
+		])
+		Service.query.filter_by(name='test').first().hide_deactivated_users = True
+		db.session.commit()
+		r = self.client.get(path=url_for('api.getusers'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(self.fix_result(r.json), [
+			{'displayname': 'Test Admin', 'email': 'admin@example.com', 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']}
+		])
+
 class TestAPIGetgroups(UffdTestCase):
 	def setUpDB(self):
 		db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_users=True))
@@ -220,6 +244,26 @@ class TestAPIGetgroups(UffdTestCase):
 			{'id': 20003, 'members': ['testadmin'], 'name': 'uffd_admin'}
 		])
 
+	def test_all_deactivated_members(self):
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.client.get(path=url_for('api.getgroups'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(self.fix_result(r.json), [
+			{'id': 20001, 'members': ['testadmin', 'testuser'], 'name': 'users'},
+			{'id': 20002, 'members': ['testadmin', 'testuser'], 'name': 'uffd_access'},
+			{'id': 20003, 'members': ['testadmin'], 'name': 'uffd_admin'}
+		])
+		Service.query.filter_by(name='test').first().hide_deactivated_users = True
+		db.session.commit()
+		r = self.client.get(path=url_for('api.getgroups'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(self.fix_result(r.json), [
+			{'id': 20001, 'members': ['testadmin'], 'name': 'users'},
+			{'id': 20002, 'members': ['testadmin'], 'name': 'uffd_access'},
+			{'id': 20003, 'members': ['testadmin'], 'name': 'uffd_admin'}
+		])
+
 	def test_id(self):
 		r = self.client.get(path=url_for('api.getgroups', id=20002), headers=[basic_auth('test', 'test')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
@@ -257,6 +301,21 @@ class TestAPIGetgroups(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertEqual(r.json, [])
 
+	def test_member_deactivated(self):
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.client.get(path=url_for('api.getgroups', member='testuser'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(self.fix_result(r.json), [
+			{'id': 20001, 'members': ['testadmin', 'testuser'], 'name': 'users'},
+			{'id': 20002, 'members': ['testadmin', 'testuser'], 'name': 'uffd_access'},
+		])
+		Service.query.filter_by(name='test').first().hide_deactivated_users = True
+		db.session.commit()
+		r = self.client.get(path=url_for('api.getgroups', member='testuser'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(self.fix_result(r.json), [])
+
 class TestAPIRemailerResolve(UffdTestCase):
 	def setUpDB(self):
 		db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_remailer=True))
diff --git a/tests/views/test_oauth2.py b/tests/views/test_oauth2.py
index 72c9ff7f..6d26baf6 100644
--- a/tests/views/test_oauth2.py
+++ b/tests/views/test_oauth2.py
@@ -139,6 +139,23 @@ class TestViews(UffdTestCase):
 		r = self.client.post(path=url_for('session.devicelogin_submit', ref=ref), data={'confirmation-code': code}, follow_redirects=False)
 		self.assert_authorization(r)
 
+	def test_authorization_devicelogin_auth_deactivated(self):
+		with self.client.session_transaction() as _session:
+			initiation = OAuth2DeviceLoginInitiation(client=OAuth2Client.query.filter_by(client_id='test').one())
+			db.session.add(initiation)
+			confirmation = DeviceLoginConfirmation(initiation=initiation, user=self.get_user())
+			db.session.add(confirmation)
+			db.session.commit()
+			_session['devicelogin_id'] = initiation.id
+			_session['devicelogin_secret'] = initiation.secret
+			code = confirmation.code
+		self.client.get(path='/')
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		ref = url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback')
+		r = self.client.post(path=url_for('session.devicelogin_submit', ref=ref), data={'confirmation-code': code}, follow_redirects=True)
+		self.assertIn(b'Device login failed', r.data)
+
 	def get_auth_code(self):
 		self.login_as('user')
 		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False)
@@ -195,7 +212,25 @@ class TestViews(UffdTestCase):
 		self.assertEqual(r.content_type, 'application/json')
 		self.assertEqual(r.json['error'], 'unsupported_grant_type')
 
+	def test_token_deactivated_user(self):
+		code = self.get_auth_code()
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.client.post(path=url_for('oauth2.token'),
+			data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True)
+		self.assertIn(r.status_code, [400, 401]) # oauthlib behaviour changed between v2.1.0 and v3.1.0
+		self.assertEqual(r.content_type, 'application/json')
+
 	def test_userinfo_invalid_access_token(self):
 		token = 'invalidtoken'
 		r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True)
 		self.assertEqual(r.status_code, 401)
+
+	def test_userinfo_invalid_access_token(self):
+		r = self.client.post(path=url_for('oauth2.token'),
+			data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True)
+		token = r.json['access_token']
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True)
+		self.assertEqual(r.status_code, 401)
diff --git a/tests/views/test_selfservice.py b/tests/views/test_selfservice.py
index 1b598aa9..b5b20241 100644
--- a/tests/views/test_selfservice.py
+++ b/tests/views/test_selfservice.py
@@ -396,6 +396,19 @@ class TestSelfservice(UffdTestCase):
 		self.assertFalse(hasattr(self.app, 'last_mail'))
 		self.assertEqual(len(PasswordToken.query.all()), 0)
 
+	def test_forgot_password_wrong_user(self):
+		user = self.get_user()
+		r = self.client.get(path=url_for('selfservice.forgot_password'))
+		self.assertEqual(r.status_code, 200)
+		user = self.get_user()
+		user.is_deactivated = True
+		db.session.commit()
+		r = self.client.post(path=url_for('selfservice.forgot_password'),
+			data={'loginname': user.loginname, 'mail': user.primary_email.address}, follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertFalse(hasattr(self.app, 'last_mail'))
+		self.assertEqual(len(PasswordToken.query.all()), 0)
+
 	def test_token_password(self):
 		user = self.get_user()
 		token = PasswordToken(user=user)
diff --git a/tests/views/test_services.py b/tests/views/test_services.py
index a40e447f..51e296a4 100644
--- a/tests/views/test_services.py
+++ b/tests/views/test_services.py
@@ -172,6 +172,7 @@ class TestServiceAdminViews(UffdTestCase):
 		self.assertEqual(service.access_group, None)
 		self.assertEqual(service.remailer_mode, RemailerMode.DISABLED)
 		self.assertEqual(service.enable_email_preferences, False)
+		self.assertEqual(service.hide_deactivated_users, False)
 
 	def test_edit_access_all(self):
 		self.login_as('admin')
@@ -209,6 +210,24 @@ class TestServiceAdminViews(UffdTestCase):
 		self.assertEqual(service.limit_access, True)
 		self.assertEqual(service.access_group, self.get_users_group())
 
+	def test_edit_hide_deactivated_users(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': '',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+				'hide_deactivated_users': '1',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.get(self.service_id)
+		self.assertEqual(service.hide_deactivated_users, True)
+
 	def test_edit_email_preferences(self):
 		self.login_as('admin')
 		r = self.client.post(
diff --git a/tests/views/test_session.py b/tests/views/test_session.py
index 0cb5c340..1780ac87 100644
--- a/tests/views/test_session.py
+++ b/tests/views/test_session.py
@@ -17,7 +17,7 @@ class TestSession(UffdTestCase):
 		@self.app.route('/test_login_required')
 		@login_required()
 		def test_login_required():
-			return 'SUCCESS', 200
+			return 'SUCCESS ' + request.user.loginname, 200
 
 		@self.app.route('/test_group_required1')
 		@login_required(lambda: request.user.is_in_group('users'))
@@ -38,15 +38,10 @@ class TestSession(UffdTestCase):
 		self.assertIsNotNone(request.user)
 
 	def assertLoggedIn(self):
-		self.assertIsNotNone(request.user)
-		self.assertEqual(self.client.get(path=url_for('test_login_required'), follow_redirects=True).data, b'SUCCESS')
-		self.assertEqual(request.user.loginname, self.get_user().loginname)
+		self.assertEqual(self.client.get(path=url_for('test_login_required'), follow_redirects=True).data, b'SUCCESS testuser')
 
 	def assertLoggedOut(self):
-		self.assertIsNone(request.user)
-		self.assertNotEqual(self.client.get(path=url_for('test_login_required'),
-							follow_redirects=True).data, b'SUCCESS')
-		self.assertEqual(request.user, None)
+		self.assertNotIn(b'SUCCESS', self.client.get(path=url_for('test_login_required'), follow_redirects=True).data)
 
 	def test_login(self):
 		self.assertLoggedOut()
@@ -78,7 +73,7 @@ class TestSession(UffdTestCase):
 	def test_redirect(self):
 		r = self.login_as('user', ref=url_for('test_login_required'))
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(r.data, b'SUCCESS')
+		self.assertEqual(r.data, b'SUCCESS testuser')
 
 	def test_wrong_password(self):
 		r = self.client.post(path=url_for('session.login'),
@@ -125,6 +120,20 @@ class TestSession(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertLoggedOut()
 
+	def test_deactivated(self):
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.login_as('user')
+		dump('login_deactivated', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertLoggedOut()
+
+	def test_deactivated_after_login(self):
+		self.login_as('user')
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		self.assertLoggedOut()
+
 	def test_group_required(self):
 		self.login()
 		self.assertEqual(self.client.get(path=url_for('test_group_required1'),
diff --git a/tests/views/test_signup.py b/tests/views/test_signup.py
index 72f80488..7db3b7fe 100644
--- a/tests/views/test_signup.py
+++ b/tests/views/test_signup.py
@@ -3,8 +3,7 @@ import datetime
 from flask import url_for, request
 
 from uffd.database import db
-from uffd.models import Signup, Role, RoleGroup, FeatureFlag
-from uffd.views.session import login_get_user
+from uffd.models import User, Signup, Role, RoleGroup, FeatureFlag
 
 from tests.utils import dump, UffdTestCase, db_flush
 
@@ -161,13 +160,13 @@ class TestSignupViews(UffdTestCase):
 		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
 		signup = refetch_signup(signup)
 		self.assertFalse(signup.completed)
-		self.assertIsNone(login_get_user('newuser', 'notsecret'))
+		self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none())
 		r = self.client.get(path=url_for('signup.signup_confirm', signup_id=signup.id, token=signup.token), follow_redirects=True)
 		dump('test_signup_confirm', r)
 		self.assertEqual(r.status_code, 200)
 		signup = refetch_signup(signup)
 		self.assertFalse(signup.completed)
-		self.assertIsNone(login_get_user('newuser', 'notsecret'))
+		self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none())
 		r = self.client.post(path=url_for('signup.signup_confirm_submit', signup_id=signup.id, token=signup.token), follow_redirects=True, data={'password': 'notsecret'})
 		dump('test_signup_confirm_submit', r)
 		self.assertEqual(r.status_code, 200)
@@ -176,7 +175,7 @@ class TestSignupViews(UffdTestCase):
 		self.assertEqual(signup.user.loginname, 'newuser')
 		self.assertEqual(signup.user.displayname, 'New User')
 		self.assertEqual(signup.user.primary_email.address, 'new@example.com')
-		self.assertIsNotNone(login_get_user('newuser', 'notsecret'))
+		self.assertTrue(User.query.filter_by(loginname='newuser').one_or_none().password.verify('notsecret'))
 
 	def test_confirm_loggedin(self):
 		baserole = Role(name='baserole', is_default=True)
diff --git a/tests/views/test_user.py b/tests/views/test_user.py
index a024b53d..6bffa84d 100644
--- a/tests/views/test_user.py
+++ b/tests/views/test_user.py
@@ -353,12 +353,31 @@ class TestUserViews(UffdTestCase):
 		dump('user_show', r)
 		self.assertEqual(r.status_code, 200)
 
+	def test_show_self(self):
+		r = self.client.get(path=url_for('user.show', id=self.get_admin().id), follow_redirects=True)
+		dump('user_show_self', r)
+		self.assertEqual(r.status_code, 200)
+
 	def test_delete(self):
 		r = self.client.get(path=url_for('user.delete', id=self.get_user().id), follow_redirects=True)
 		dump('user_delete', r)
 		self.assertEqual(r.status_code, 200)
 		self.assertIsNone(self.get_user())
 
+	def test_deactivate(self):
+		r = self.client.get(path=url_for('user.deactivate', id=self.get_user().id), follow_redirects=True)
+		dump('user_deactivate', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertTrue(self.get_user().is_deactivated)
+
+	def test_activate(self):
+		self.get_user().is_deactivated = True
+		db.session.commit()
+		r = self.client.get(path=url_for('user.activate', id=self.get_user().id), follow_redirects=True)
+		dump('user_activate', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertFalse(self.get_user().is_deactivated)
+
 	def test_csvimport(self):
 		role1 = Role(name='role1')
 		db.session.add(role1)
diff --git a/uffd/commands/user.py b/uffd/commands/user.py
index 2db24b3f..68aa675f 100644
--- a/uffd/commands/user.py
+++ b/uffd/commands/user.py
@@ -12,7 +12,7 @@ user_command = AppGroup('user', help='Manage users')
 
 def update_attrs(user, mail=None, displayname=None, password=None,
                  prompt_password=False, clear_roles=False,
-                 add_role=tuple(), remove_role=tuple()):
+                 add_role=tuple(), remove_role=tuple(), deactivate=None):
 	if password is None and prompt_password:
 		password = click.prompt('Password', hide_input=True, confirmation_prompt='Confirm password')
 	if mail is not None and not user.set_primary_email_address(mail):
@@ -21,6 +21,8 @@ def update_attrs(user, mail=None, displayname=None, password=None,
 		raise click.ClickException('Invalid displayname')
 	if password is not None and not user.set_password(password):
 		raise click.ClickException('Invalid password')
+	if deactivate is not None:
+		user.is_deactivated = deactivate
 	if clear_roles:
 		user.roles.clear()
 	for role_name in add_role:
@@ -49,6 +51,7 @@ def show(loginname):
 		if user is None:
 			raise click.ClickException(f'User {loginname} not found')
 		click.echo(f'Loginname: {user.loginname}')
+		click.echo(f'Deactivated: {user.is_deactivated}')
 		click.echo(f'Displayname: {user.displayname}')
 		click.echo(f'Mail: {user.primary_email.address}')
 		click.echo(f'Service User: {user.is_service_user}')
@@ -65,7 +68,8 @@ def show(loginname):
 @click.option('--password', help='Password for SSO login. Login disabled if unset.')
 @click.option('--prompt-password', is_flag=True, flag_value=True, default=False, help='Read password interactively from terminal.')
 @click.option('--add-role', multiple=True, help='Add role to user. Repeat to add multiple roles.', metavar='ROLE_NAME')
-def create(loginname, mail, displayname, service, password, prompt_password, add_role):
+@click.option('--deactivate', is_flag=True, flag_value=True, default=None, help='Deactivate account.')
+def create(loginname, mail, displayname, service, password, prompt_password, add_role, deactivate):
 	with current_app.test_request_context():
 		if displayname is None:
 			displayname = loginname
@@ -74,7 +78,7 @@ def create(loginname, mail, displayname, service, password, prompt_password, add
 			raise click.ClickException('Invalid loginname')
 		try:
 			db.session.add(user)
-			update_attrs(user, mail, displayname, password, prompt_password, add_role=add_role)
+			update_attrs(user, mail, displayname, password, prompt_password, add_role=add_role, deactivate=deactivate)
 			db.session.commit()
 		except IntegrityError:
 			# pylint: disable=raise-missing-from
@@ -89,13 +93,14 @@ def create(loginname, mail, displayname, service, password, prompt_password, add
 @click.option('--clear-roles', is_flag=True, flag_value=True, default=False, help='Remove all roles from user. Executed before --add-role.')
 @click.option('--add-role', multiple=True, help='Add role to user. Repeat to add multiple roles.')
 @click.option('--remove-role', multiple=True, help='Remove role from user. Repeat to remove multiple roles.')
-def update(loginname, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role):
+@click.option('--deactivate/--activate', default=None, help='Deactivate or reactivate account.')
+def update(loginname, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role, deactivate):
 	with current_app.test_request_context():
 		user = User.query.filter_by(loginname=loginname).one_or_none()
 		if user is None:
 			raise click.ClickException(f'User {loginname} not found')
 		try:
-			update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role)
+			update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role, deactivate)
 			db.session.commit()
 		except IntegrityError:
 			# pylint: disable=raise-missing-from
diff --git a/uffd/migrations/versions/23293f32b503_deactivate_users.py b/uffd/migrations/versions/23293f32b503_deactivate_users.py
new file mode 100644
index 00000000..c25dbf6c
--- /dev/null
+++ b/uffd/migrations/versions/23293f32b503_deactivate_users.py
@@ -0,0 +1,90 @@
+"""Deactivate users
+
+Revision ID: 23293f32b503
+Revises: e249233e2a31
+Create Date: 2022-11-10 02:06:27.766520
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = '23293f32b503'
+down_revision = 'e249233e2a31'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	with op.batch_alter_table('service', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false()))
+	service = sa.Table('service', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('name', sa.String(length=255), nullable=False),
+		sa.Column('limit_access', sa.Boolean(), nullable=False),
+		sa.Column('access_group_id', sa.Integer(), nullable=True),
+		sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False),
+		sa.Column('enable_email_preferences', sa.Boolean(), nullable=False),
+		sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false()),
+		sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
+		sa.UniqueConstraint('name', name=op.f('uq_service_name'))
+	)
+	with op.batch_alter_table('service', copy_from=service) as batch_op:
+		batch_op.alter_column('hide_deactivated_users', server_default=None)
+	with op.batch_alter_table('user', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false()))
+	user = sa.Table('user', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('unix_uid', sa.Integer(), nullable=False),
+		sa.Column('loginname', sa.String(length=32), nullable=False),
+		sa.Column('displayname', sa.String(length=128), nullable=False),
+		sa.Column('primary_email_id', sa.Integer(), nullable=False),
+		sa.Column('recovery_email_id', sa.Integer(), nullable=True),
+		sa.Column('pwhash', sa.Text(), nullable=True),
+		sa.Column('is_service_user', sa.Boolean(), nullable=False),
+		sa.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false()),
+		sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'),
+		sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_user')),
+		sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')),
+		sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid'))
+	)
+	with op.batch_alter_table('user', copy_from=user) as batch_op:
+		batch_op.alter_column('is_deactivated', server_default=None)
+
+def downgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	user = sa.Table('user', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('unix_uid', sa.Integer(), nullable=False),
+		sa.Column('loginname', sa.String(length=32), nullable=False),
+		sa.Column('displayname', sa.String(length=128), nullable=False),
+		sa.Column('primary_email_id', sa.Integer(), nullable=False),
+		sa.Column('recovery_email_id', sa.Integer(), nullable=True),
+		sa.Column('pwhash', sa.Text(), nullable=True),
+		sa.Column('is_service_user', sa.Boolean(), nullable=False),
+		sa.Column('is_deactivated', sa.Boolean(), nullable=False),
+		sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'),
+		sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_user')),
+		sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')),
+		sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid'))
+	)
+	with op.batch_alter_table('user', schema=None) as batch_op:
+		batch_op.drop_column('is_deactivated')
+	service = sa.Table('service', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('name', sa.String(length=255), nullable=False),
+		sa.Column('limit_access', sa.Boolean(), nullable=False),
+		sa.Column('access_group_id', sa.Integer(), nullable=True),
+		sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False),
+		sa.Column('enable_email_preferences', sa.Boolean(), nullable=False),
+		sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False),
+		sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
+		sa.UniqueConstraint('name', name=op.f('uq_service_name'))
+	)
+	with op.batch_alter_table('service', copy_from=service) as batch_op:
+		batch_op.drop_column('hide_deactivated_users')
diff --git a/uffd/models/invite.py b/uffd/models/invite.py
index 27c1db2b..ef8acc36 100644
--- a/uffd/models/invite.py
+++ b/uffd/models/invite.py
@@ -43,6 +43,8 @@ class Invite(db.Model):
 	def permitted(self):
 		if self.creator is None:
 			return False # Creator does not exist (anymore)
+		if self.creator.is_deactivated:
+			return False
 		if self.creator.is_in_group(current_app.config['ACL_ADMIN_GROUP']):
 			return True
 		if self.allow_signup and not self.creator.is_in_group(current_app.config['ACL_SIGNUP_GROUP']):
diff --git a/uffd/models/service.py b/uffd/models/service.py
index ff31419b..1817954c 100644
--- a/uffd/models/service.py
+++ b/uffd/models/service.py
@@ -35,6 +35,7 @@ class Service(db.Model):
 
 	remailer_mode = Column(Enum(RemailerMode), default=RemailerMode.DISABLED, nullable=False)
 	enable_email_preferences = Column(Boolean(), default=False, nullable=False)
+	hide_deactivated_users = Column(Boolean(), default=False, nullable=False)
 
 class ServiceUser(db.Model):
 	'''Service-related configuration and state for a user
diff --git a/uffd/models/user.py b/uffd/models/user.py
index e3fa2f6a..2a933907 100644
--- a/uffd/models/user.py
+++ b/uffd/models/user.py
@@ -168,6 +168,7 @@ class User(db.Model):
 	_password = Column('pwhash', Text(), nullable=True)
 	password = PasswordHashAttribute('_password', LowEntropyPasswordHash)
 	is_service_user = Column(Boolean(), default=False, nullable=False)
+	is_deactivated = Column(Boolean(), default=False, nullable=False)
 	groups = relationship('Group', secondary='user_groups', back_populates='members')
 	roles = relationship('Role', secondary='role_members', back_populates='members')
 
diff --git a/uffd/templates/role/show.html b/uffd/templates/role/show.html
index 3ee88d69..282bbb7c 100644
--- a/uffd/templates/role/show.html
+++ b/uffd/templates/role/show.html
@@ -9,7 +9,7 @@
 
 <form action="{{ url_for("role.update", roleid=role.id) }}" method="POST">
 <div class="align-self-center">
-	<div class="float-sm-right pb-2">
+	<div class="clearfix pb-2"><div class="float-sm-right">
 		<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button>
 		<a href="{{ url_for("role.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a>
 		{% if role.id %}
@@ -23,7 +23,7 @@
 			<a href="#" class="btn btn-secondary disabled">{{_("Set as default")}}</a>
 			<a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a>
 		{% endif %}
-	</div>
+	</div></div>
 	<ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist">
 		<li class="nav-item">
 			<a class="nav-link active" id="settings-tab" data-toggle="tab" href="#settings" role="tab" aria-controls="settings" aria-selected="true">{{_("Settings")}}</a>
diff --git a/uffd/templates/service/show.html b/uffd/templates/service/show.html
index 08d03ba0..08e0629f 100644
--- a/uffd/templates/service/show.html
+++ b/uffd/templates/service/show.html
@@ -31,6 +31,13 @@
 			</select>
 		</div>
 
+		<div class="form-group col">
+			<div class="form-check">
+				<input class="form-check-input" type="checkbox" id="hide-deactivated-users" name="hide_deactivated_users" value="1" aria-label="enabled" {{ 'checked' if service.hide_deactivated_users }}>
+				<label class="form-check-label" for="hide-deactivated-users">{{ _('Hide deactivated users from service') }}</label>
+			</div>
+		</div>
+
 		<div class="form-group col">
 			<div class="form-check">
 				<input class="form-check-input" type="checkbox" id="service-enable-email-preferences" name="enable_email_preferences" value="1" aria-label="enabled" {{ 'checked' if service.enable_email_preferences }}>
diff --git a/uffd/templates/user/list.html b/uffd/templates/user/list.html
index 63748d0f..6e7d08a2 100644
--- a/uffd/templates/user/list.html
+++ b/uffd/templates/user/list.html
@@ -33,6 +33,9 @@
 						{% if user.is_service_user %}
 						<span class="badge badge-secondary">{{_('service')}}</span>
 						{% endif %}
+						{% if user.is_deactivated %}
+						<span class="badge badge-danger">{{ _('deactivated') }}</span>
+						{% endif %}
 					</td>
 					<td>
 						{{ user.displayname }}
diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html
index 7a4f265d..04b2cf35 100644
--- a/uffd/templates/user/show.html
+++ b/uffd/templates/user/show.html
@@ -22,15 +22,27 @@
 <form action="{{ url_for("user.create") }}" method="POST">
 {% endif %}
 <div class="align-self-center">
-	<div class="float-sm-right pb-2">
+	{% if user.id and user.is_deactivated %}
+	<div class="alert alert-warning">
+		{{ _('This account is deactivated. The user cannot login and existing sessions are not usable. The user cannot log into services, but existing sessions on services might still be active.') }}
+	</div>
+	{% endif %}
+	<div class="clearfix pb-2"><div class="float-sm-right">
 		<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button>
 		<a href="{{ url_for("user.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a>
-		{% if user.id %}
+		{% if user.id and not user.is_deactivated and user != request.user %}
+			<a href="{{ url_for("user.deactivate", id=user.id) }}" class="btn btn-secondary">{{ _("Deactivate") }}</a>
+		{% elif user.id and user.is_deactivated %}
+			<a href="{{ url_for("user.activate", id=user.id) }}" class="btn btn-primary">{{ _("Activate") }}</a>
+		{% else %}
+			<a href="#" class="btn btn-secondary disabled">{{ _("Deactivate") }}</a>
+		{% endif %}
+		{% if user.id and user != request.user %}
 			<a href="{{ url_for("user.delete", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a>
 		{% else %}
 			<a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a>
 		{% endif %}
-	</div>
+	</div></div>
 	<ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist">
 		<li class="nav-item">
 			<a class="nav-link active" id="profile-tab" data-toggle="tab" href="#profile" role="tab" aria-controls="profile" aria-selected="true">{{_("Profile")}}</a>
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 18d96bb2f77e2483e726cbbf418892af96e7fd69..68f53c06570d8c2f651b2e70a4884e8674ca69ba 100644
GIT binary patch
delta 7509
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
MXGFGaviQ6n00%6DBme*a

delta 6675
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
JC(m5i4FFlyV`cyV

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 667d221f..4f4d621c 100644
--- a/uffd/translations/de/LC_MESSAGES/messages.po
+++ b/uffd/translations/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PROJECT VERSION\n"
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2022-11-08 20:12+0100\n"
+"POT-Creation-Date: 2022-11-13 02:05+0100\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -144,8 +144,8 @@ msgstr "Über uffd"
 
 #: uffd/templates/group/list.html:8 uffd/templates/invite/list.html:6
 #: uffd/templates/mail/list.html:8 uffd/templates/role/list.html:8
-#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:99
-#: uffd/templates/service/show.html:127 uffd/templates/user/list.html:8
+#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:106
+#: uffd/templates/service/show.html:134 uffd/templates/user/list.html:8
 msgid "New"
 msgstr "Neu"
 
@@ -162,8 +162,8 @@ msgstr "GID"
 #: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44
 #: uffd/templates/selfservice/self.html:190
 #: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20
-#: uffd/templates/service/show.html:133 uffd/templates/user/show.html:193
-#: uffd/templates/user/show.html:225
+#: uffd/templates/service/show.html:140 uffd/templates/user/show.html:205
+#: uffd/templates/user/show.html:237
 msgid "Name"
 msgstr "Name"
 
@@ -171,14 +171,14 @@ msgstr "Name"
 #: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15
 #: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10
 #: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:191
-#: uffd/templates/user/show.html:194 uffd/templates/user/show.html:226
+#: uffd/templates/user/show.html:206 uffd/templates/user/show.html:238
 msgid "Description"
 msgstr "Beschreibung"
 
 #: uffd/templates/group/show.html:8 uffd/templates/mail/show.html:27
 #: uffd/templates/role/show.html:13 uffd/templates/rolemod/show.html:8
 #: uffd/templates/service/api.html:15 uffd/templates/service/oauth2.html:15
-#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:26
+#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:31
 msgid "Save"
 msgstr "Speichern"
 
@@ -189,7 +189,7 @@ msgstr "Speichern"
 #: uffd/templates/service/show.html:10
 #: uffd/templates/session/deviceauth.html:39
 #: uffd/templates/session/deviceauth.html:49
-#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:27
+#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:32
 msgid "Cancel"
 msgstr "Abbrechen"
 
@@ -197,7 +197,7 @@ msgstr "Abbrechen"
 #: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61
 #: uffd/templates/selfservice/self.html:205 uffd/templates/service/api.html:11
 #: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12
-#: uffd/templates/user/show.html:29 uffd/templates/user/show.html:181
+#: uffd/templates/user/show.html:41 uffd/templates/user/show.html:193
 msgid "Are you sure?"
 msgstr "Wirklich fortfahren?"
 
@@ -207,8 +207,8 @@ msgstr "Wirklich fortfahren?"
 #: uffd/templates/role/show.html:21 uffd/templates/role/show.html:24
 #: uffd/templates/selfservice/self.html:62 uffd/templates/service/api.html:12
 #: uffd/templates/service/oauth2.html:12 uffd/templates/service/show.html:13
-#: uffd/templates/user/show.html:29 uffd/templates/user/show.html:31
-#: uffd/templates/user/show.html:104
+#: uffd/templates/user/show.html:41 uffd/templates/user/show.html:43
+#: uffd/templates/user/show.html:116
 msgid "Delete"
 msgstr "Löschen"
 
@@ -238,7 +238,7 @@ msgid "Created by"
 msgstr "Erstellt durch"
 
 #: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34
-#: uffd/templates/service/show.html:134
+#: uffd/templates/service/show.html:141
 msgid "Permissions"
 msgstr "Berechtigungen"
 
@@ -266,7 +266,7 @@ msgstr "Account-Registrierung"
 msgid "user signups"
 msgstr "Account-Registrierungen"
 
-#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:178
+#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:190
 msgid "Disabled"
 msgstr "Deaktiviert"
 
@@ -460,7 +460,7 @@ msgid "One address per line"
 msgstr "Eine Adresse pro Zeile"
 
 #: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:159
-#: uffd/templates/user/show.html:176
+#: uffd/templates/user/show.html:188
 msgid "Two-Factor Authentication"
 msgstr "Zwei-Faktor-Authentifizierung"
 
@@ -599,7 +599,7 @@ msgid "Reset two-factor configuration"
 msgstr "Zwei-Faktor-Authentifizierung zurücksetzen"
 
 #: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5
-#: uffd/templates/user/show.html:179
+#: uffd/templates/user/show.html:191
 msgid "Recovery Codes"
 msgstr "Wiederherstellungscodes"
 
@@ -637,7 +637,7 @@ msgstr "Generiere neue Wiederherstellungscodes"
 msgid "You have no remaining recovery codes."
 msgstr "Du hast keine Wiederherstellungscodes übrig."
 
-#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:179
+#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:191
 msgid "Authenticator Apps (TOTP)"
 msgstr "Authentifikator-Apps (TOTP)"
 
@@ -667,7 +667,7 @@ msgstr "Registriert am"
 msgid "No authenticator apps registered yet"
 msgstr "Bisher keine Authentifikator-Apps registriert"
 
-#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:179
+#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:191
 msgid "U2F and FIDO2 Devices"
 msgstr "U2F und FIDO2 Geräte"
 
@@ -972,7 +972,7 @@ msgstr "Passwort vergessen"
 #: uffd/templates/selfservice/forgot_password.html:9
 #: uffd/templates/selfservice/self.html:21 uffd/templates/session/login.html:12
 #: uffd/templates/signup/start.html:9 uffd/templates/user/list.html:18
-#: uffd/templates/user/show.html:66
+#: uffd/templates/user/show.html:78
 msgid "Login Name"
 msgstr "Anmeldename"
 
@@ -993,7 +993,7 @@ msgstr ""
 "Authentifizierung.\n"
 "\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast."
 
-#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:36
+#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:48
 msgid "Profile"
 msgstr "Profil"
 
@@ -1010,7 +1010,7 @@ msgid "Changes may take several minutes to be visible in all services."
 msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar."
 
 #: uffd/templates/selfservice/self.html:25 uffd/templates/signup/start.html:22
-#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:81
+#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:93
 msgid "Display Name"
 msgstr "Anzeigename"
 
@@ -1018,7 +1018,7 @@ msgstr "Anzeigename"
 msgid "Update Profile"
 msgstr "Änderungen speichern"
 
-#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:98
+#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:110
 msgid "E-Mail Addresses"
 msgstr "E-Mail-Adressen"
 
@@ -1043,7 +1043,7 @@ msgstr "Neue E-Mail-Adresse"
 msgid "Add address"
 msgstr "Adresse hinzufügen"
 
-#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:114
+#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:126
 msgid "primary"
 msgstr "primär"
 
@@ -1090,8 +1090,8 @@ msgid "Address for Password Reset E-Mails"
 msgstr "Adresse für Passwort-Zurücksetzen-E-Mails"
 
 #: uffd/templates/selfservice/self.html:103
-#: uffd/templates/selfservice/self.html:116 uffd/templates/user/show.html:143
-#: uffd/templates/user/show.html:153
+#: uffd/templates/selfservice/self.html:116 uffd/templates/user/show.html:155
+#: uffd/templates/user/show.html:165
 msgid "Use primary address"
 msgstr "Primäre Adresse verwenden"
 
@@ -1110,7 +1110,7 @@ msgstr "E-Mail-Einstellungen speichern"
 
 #: uffd/templates/selfservice/self.html:136
 #: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36
-#: uffd/templates/user/show.html:163
+#: uffd/templates/user/show.html:175
 msgid "Password"
 msgstr "Passwort"
 
@@ -1155,7 +1155,7 @@ msgid "Manage two-factor authentication"
 msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten"
 
 #: uffd/templates/selfservice/self.html:178 uffd/templates/user/list.html:20
-#: uffd/templates/user/show.html:39 uffd/templates/user/show.html:188
+#: uffd/templates/user/show.html:51 uffd/templates/user/show.html:200
 #: uffd/views/role.py:21
 msgid "Roles"
 msgstr "Rollen"
@@ -1231,7 +1231,7 @@ msgstr "Zugriff auf Mail-Weiterleitungen"
 msgid "Resolve remailer addresses"
 msgstr "Auflösen von Remailer-Adressen"
 
-#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:48
+#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:55
 msgid "This option has no effect: Remailer config options are unset"
 msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert"
 
@@ -1239,7 +1239,7 @@ msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert"
 msgid "Access uffd metrics"
 msgstr "Zugriff auf uffd-Metriken"
 
-#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:105
+#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:112
 msgid "Client ID"
 msgstr "Client-ID"
 
@@ -1286,8 +1286,8 @@ msgstr "Kein Zugriff"
 msgid "Manage OAuth2 and API clients"
 msgstr "OAuth2- und API-Clients verwalten"
 
-#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:58
-#: uffd/templates/user/list.html:79
+#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:61
+#: uffd/templates/user/list.html:82
 msgid "Close"
 msgstr "Schließen"
 
@@ -1309,6 +1309,10 @@ msgid "Members of group \"%(group_name)s\" have access"
 msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff"
 
 #: uffd/templates/service/show.html:37
+msgid "Hide deactivated users from service"
+msgstr "Deaktivierte Nutzer verstecken"
+
+#: uffd/templates/service/show.html:44
 msgid ""
 "Allow users with access to select a different e-mail address for this "
 "service"
@@ -1316,29 +1320,29 @@ msgstr ""
 "Ermögliche Nutzern mit Zugriff auf diesen Dienst eine andere E-Mail-"
 "Adresse auszuwählen"
 
-#: uffd/templates/service/show.html:39
+#: uffd/templates/service/show.html:46
 msgid "If disabled, the service always uses the primary e-mail address."
 msgstr "Wenn deaktiviert, wird immer die primäre E-Mail-Adresse verwendet."
 
-#: uffd/templates/service/show.html:46
+#: uffd/templates/service/show.html:53
 msgid "Hide e-mail addresses with remailer"
 msgstr "E-Mail-Adressen mit Remailer verstecken"
 
-#: uffd/templates/service/show.html:53 uffd/templates/service/show.html:76
+#: uffd/templates/service/show.html:60 uffd/templates/service/show.html:83
 msgid "Remailer disabled"
 msgstr "Remailer deaktiviert"
 
-#: uffd/templates/service/show.html:56 uffd/templates/service/show.html:79
+#: uffd/templates/service/show.html:63 uffd/templates/service/show.html:86
 msgid "Remailer enabled"
 msgstr "Remailer aktiviert"
 
-#: uffd/templates/service/show.html:59 uffd/templates/service/show.html:82
+#: uffd/templates/service/show.html:66 uffd/templates/service/show.html:89
 msgid "Remailer enabled (deprecated, case-sensitive format)"
 msgstr ""
 "Remailer aktiviert (veraltetes, Groß-/Kleinschreibung-unterscheidendes "
 "Format)"
 
-#: uffd/templates/service/show.html:63
+#: uffd/templates/service/show.html:70
 msgid ""
 "Some services notify users about changes to their e-mail address. "
 "Modifying this setting immediatly affects the e-mail addresses of all "
@@ -1349,15 +1353,15 @@ msgstr ""
 "-Mail-Adressen aller Nutzer aus und kann zu massenhaftem Versand von "
 "Benachrichtigungs-E-Mails führen."
 
-#: uffd/templates/service/show.html:69
+#: uffd/templates/service/show.html:76
 msgid "Overwrite remailer setting for specific users"
 msgstr "Überschreibe Remailer-Einstellung für ausgewählte Nutzer"
 
-#: uffd/templates/service/show.html:72
+#: uffd/templates/service/show.html:79
 msgid "Login names"
 msgstr "Anmeldenamen"
 
-#: uffd/templates/service/show.html:87
+#: uffd/templates/service/show.html:94
 msgid ""
 "Useful for testing remailer before enabling it for all users. Specify "
 "users as a comma-seperated list of login names."
@@ -1467,7 +1471,7 @@ msgstr "Überprüfen"
 msgid "At least one and at most 128 characters, no other special requirements."
 msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen."
 
-#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:90
+#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:102
 msgid "E-Mail Address"
 msgstr "E-Mail-Adresse"
 
@@ -1527,15 +1531,19 @@ msgstr "CSV-Import"
 msgid "UID"
 msgstr "UID"
 
-#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:48
+#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:60
 msgid "service"
 msgstr "service"
 
-#: uffd/templates/user/list.html:57
+#: uffd/templates/user/list.html:37
+msgid "deactivated"
+msgstr "deaktiviert"
+
+#: uffd/templates/user/list.html:60
 msgid "Import a csv formated list of users"
 msgstr "Importiere eine als CSV formatierte Liste von Accounts"
 
-#: uffd/templates/user/list.html:64
+#: uffd/templates/user/list.html:67
 msgid ""
 "The format should be \"loginname,mailaddres,roleid1;roleid2\". Neither "
 "setting the display name nor setting passwords is supported (yet). "
@@ -1545,11 +1553,11 @@ msgstr ""
 "Anzeigename oder das Password können (derzeit) nicht gesetzt werden. "
 "Beispiel:"
 
-#: uffd/templates/user/list.html:75 uffd/templates/user/show.html:76
+#: uffd/templates/user/list.html:78 uffd/templates/user/show.html:88
 msgid "Ignore login name blocklist"
 msgstr "Liste der nicht erlaubten Anmeldenamen ignorieren"
 
-#: uffd/templates/user/list.html:80
+#: uffd/templates/user/list.html:83
 msgid "Import"
 msgstr "Importieren"
 
@@ -1557,19 +1565,38 @@ msgstr "Importieren"
 msgid "New address"
 msgstr "Neue Adresse"
 
-#: uffd/templates/user/show.html:46
+#: uffd/templates/user/show.html:27
+msgid ""
+"This account is deactivated. The user cannot login and existing sessions "
+"are not usable. The user cannot log into services, but existing sessions "
+"on services might still be active."
+msgstr ""
+"Dieser Account ist deaktiviert. Der Nutzer kann sich nicht neu anmelden. "
+"Existierende Sitzungen sind nicht nutzbar. Eine Anmeldung bei Diensten "
+"ist nicht möglich, allerdings könnten bestehende Sitzungen weiterhin "
+"aktiv sein."
+
+#: uffd/templates/user/show.html:34 uffd/templates/user/show.html:38
+msgid "Deactivate"
+msgstr "Deaktivieren"
+
+#: uffd/templates/user/show.html:36
+msgid "Activate"
+msgstr "Aktivieren"
+
+#: uffd/templates/user/show.html:58
 msgid "User ID"
 msgstr "Account ID"
 
-#: uffd/templates/user/show.html:54
+#: uffd/templates/user/show.html:66
 msgid "will be choosen"
 msgstr "wird automatisch bestimmt"
 
-#: uffd/templates/user/show.html:61
+#: uffd/templates/user/show.html:73
 msgid "Service User"
 msgstr "Service-Account"
 
-#: uffd/templates/user/show.html:69
+#: uffd/templates/user/show.html:81
 msgid ""
 "Only letters, numbers, dashes (\"-\") and underscores (\"_\") are "
 "allowed. At most 32, at least 2 characters. There is a word blocklist. "
@@ -1579,7 +1606,7 @@ msgstr ""
 "erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Liste nicht "
 "erlaubter Namen. Muss einmalig sein."
 
-#: uffd/templates/user/show.html:84
+#: uffd/templates/user/show.html:96
 msgid ""
 "If you leave this empty it will be set to the login name. At most 128, at"
 " least 2 characters. No character restrictions."
@@ -1587,7 +1614,7 @@ msgstr ""
 "Wenn das Feld leer bleibt, wird der Anmeldename verwendet. Maximal 128, "
 "mindestens 2 Zeichen. Keine Zeichenbeschränkung."
 
-#: uffd/templates/user/show.html:93
+#: uffd/templates/user/show.html:105
 msgid ""
 "Make sure the address is correct! Services might use e-mail addresses as "
 "account identifiers and rely on them being unique and verified."
@@ -1596,15 +1623,15 @@ msgstr ""
 " E-Mail-Adresse um Accounts zu identifizieren und verlassen sich darauf, "
 "dass diese verifiziert und einzigartig sind."
 
-#: uffd/templates/user/show.html:102
+#: uffd/templates/user/show.html:114
 msgid "Address"
 msgstr "Adresse"
 
-#: uffd/templates/user/show.html:103
+#: uffd/templates/user/show.html:115
 msgid "Verified"
 msgstr "Verifiziert"
 
-#: uffd/templates/user/show.html:129
+#: uffd/templates/user/show.html:141
 msgid ""
 "Make sure that addresses you add are correct! Services might use e-mail "
 "addresses as account identifiers and rely on them being unique and "
@@ -1614,36 +1641,36 @@ msgstr ""
 "Dienste verwenden die E-Mail-Adresse um Accounts zu identifizieren und "
 "verlassen sich darauf, dass diese verifiziert und einzigartig sind."
 
-#: uffd/templates/user/show.html:133
+#: uffd/templates/user/show.html:145
 msgid "Primary E-Mail Address"
 msgstr "Primäre E-Mail-Adresse"
 
-#: uffd/templates/user/show.html:141
+#: uffd/templates/user/show.html:153
 msgid "Recovery E-Mail Address"
 msgstr "Wiederherstellungs-E-Mail-Adresse"
 
-#: uffd/templates/user/show.html:151
+#: uffd/templates/user/show.html:163
 #, python-format
 msgid "Address for %(name)s"
 msgstr "Adresse für %(name)s"
 
-#: uffd/templates/user/show.html:167
+#: uffd/templates/user/show.html:179
 msgid "E-Mail to set it will be sent"
 msgstr "Mail zum Setzen wird versendet"
 
-#: uffd/templates/user/show.html:178
+#: uffd/templates/user/show.html:190
 msgid "Status:"
 msgstr "Status:"
 
-#: uffd/templates/user/show.html:178
+#: uffd/templates/user/show.html:190
 msgid "Enabled"
 msgstr "Aktiv"
 
-#: uffd/templates/user/show.html:181
+#: uffd/templates/user/show.html:193
 msgid "Reset 2FA"
 msgstr "2FA zurücksetzen"
 
-#: uffd/templates/user/show.html:221
+#: uffd/templates/user/show.html:233
 msgid "Resulting groups (only updated after save)"
 msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)"
 
@@ -1785,8 +1812,8 @@ msgstr ""
 msgid "Two-factor authentication failed"
 msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
 
-#: uffd/views/oauth2.py:167 uffd/views/selfservice.py:66
-#: uffd/views/session.py:72
+#: uffd/views/oauth2.py:172 uffd/views/selfservice.py:66
+#: uffd/views/session.py:67
 #, python-format
 msgid ""
 "We received too many requests from your ip address/network! Please wait "
@@ -1795,19 +1822,19 @@ msgstr ""
 "Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
 "Netzwerk empfangen! Bitte warte mindestens %(delay)s."
 
-#: uffd/views/oauth2.py:175
+#: uffd/views/oauth2.py:180
 msgid "Device login is currently not available. Try again later!"
 msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
 
-#: uffd/views/oauth2.py:188
+#: uffd/views/oauth2.py:193
 msgid "Device login failed"
 msgstr "Gerätelogin fehlgeschlagen"
 
-#: uffd/views/oauth2.py:194
+#: uffd/views/oauth2.py:199
 msgid "You need to login to access this service"
 msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können"
 
-#: uffd/views/oauth2.py:201
+#: uffd/views/oauth2.py:206
 #, python-format
 msgid ""
 "You don't have the permission to access the service "
@@ -1933,7 +1960,7 @@ msgstr "Rolle %(role_name)s verlassen"
 msgid "Services"
 msgstr "Dienste"
 
-#: uffd/views/session.py:70
+#: uffd/views/session.py:65
 #, python-format
 msgid ""
 "We received too many invalid login attempts for this user! Please wait at"
@@ -1942,27 +1969,34 @@ msgstr ""
 "Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account "
 "erhalten! Bitte warte mindestens %(delay)s."
 
-#: uffd/views/session.py:78
+#: uffd/views/session.py:74
 msgid "Login name or password is wrong"
 msgstr "Der Anmeldename oder das Passwort ist falsch"
 
-#: uffd/views/session.py:84
+#: uffd/views/session.py:77
+#, python-format
+msgid "Your account is deactivated. Contact %(contact_email)s for details."
+msgstr ""
+"Dein Account ist deaktiviert. Kontaktiere %(contact_email)s für weitere "
+"Informationen."
+
+#: uffd/views/session.py:83
 msgid "You do not have access to this service"
 msgstr "Du hast keinen Zugriff auf diesen Service"
 
-#: uffd/views/session.py:96 uffd/views/session.py:107
+#: uffd/views/session.py:95 uffd/views/session.py:106
 msgid "You need to login first"
 msgstr "Du musst dich erst anmelden"
 
-#: uffd/views/session.py:128 uffd/views/session.py:138
+#: uffd/views/session.py:127 uffd/views/session.py:137
 msgid "Initiation code is no longer valid"
 msgstr "Startcode ist nicht mehr gültig"
 
-#: uffd/views/session.py:142
+#: uffd/views/session.py:141
 msgid "Invalid confirmation code"
 msgstr "Ungültiger Bestätigungscode"
 
-#: uffd/views/session.py:154 uffd/views/session.py:165
+#: uffd/views/session.py:153 uffd/views/session.py:164
 msgid "Invalid initiation code"
 msgstr "Ungültiger Startcode"
 
@@ -2019,7 +2053,15 @@ msgstr "Passwort ist ungültig"
 msgid "User updated"
 msgstr "Account aktualisiert"
 
-#: uffd/views/user.py:156
+#: uffd/views/user.py:155
+msgid "User deactivated"
+msgstr "Account deaktiviert"
+
+#: uffd/views/user.py:164
+msgid "User activated"
+msgstr "Account aktiviert"
+
+#: uffd/views/user.py:174
 msgid "Deleted user"
 msgstr "Account gelöscht"
 
diff --git a/uffd/views/api.py b/uffd/views/api.py
index cdab573d..d0be61d4 100644
--- a/uffd/views/api.py
+++ b/uffd/views/api.py
@@ -38,7 +38,11 @@ def generate_group_dict(group):
 	return {
 		'id': group.unix_gid,
 		'name': group.name,
-		'members': [user.loginname for user in group.members]
+		'members': [
+			user.loginname
+			for user in group.members
+			if not user.is_deactivated or not request.api_client.service.hide_deactivated_users
+		]
 	}
 
 @bp.route('/getgroups', methods=['GET', 'POST'])
@@ -57,6 +61,8 @@ def getgroups():
 		query = query.filter(Group.name == values[0])
 	elif key == 'member' and len(values) == 1:
 		query = query.join(Group.members).filter(User.loginname == values[0])
+		if request.api_client.service.hide_deactivated_users:
+			query = query.filter(db.not_(User.is_deactivated))
 	else:
 		abort(400)
 	# Single-result queries perform better without eager loading
@@ -81,6 +87,8 @@ def getusers():
 	key = (list(request.values.keys()) or [None])[0]
 	values = request.values.getlist(key)
 	query = ServiceUser.query.filter_by(service=request.api_client.service).join(ServiceUser.user)
+	if request.api_client.service.hide_deactivated_users:
+		query = query.filter(db.not_(User.is_deactivated))
 	if key is None:
 		pass
 	elif key == 'id' and len(values) == 1:
@@ -117,6 +125,8 @@ def checkpassword():
 	if service_user is None or not service_user.user.password.verify(password):
 		login_ratelimit.log(username)
 		return jsonify(None)
+	if service_user.user.is_deactivated:
+		return jsonify(None)
 	if service_user.user.password.needs_rehash:
 		service_user.user.password = password
 		db.session.commit()
diff --git a/uffd/views/oauth2.py b/uffd/views/oauth2.py
index 1d0df2d0..43d65fa6 100644
--- a/uffd/views/oauth2.py
+++ b/uffd/views/oauth2.py
@@ -85,6 +85,8 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 			return False
 		if oauthreq.grant.expired:
 			return False
+		if oauthreq.grant.user.is_deactivated:
+			return False
 		oauthreq.user = oauthreq.grant.user
 		oauthreq.scopes = oauthreq.grant.scopes
 		return True
@@ -130,6 +132,9 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator):
 		if tok.expired:
 			oauthreq.error_message = 'Token expired'
 			return False
+		if tok.user.is_deactivated:
+			oauthreq.error_message = 'User deactivated'
+			return False
 		if not set(scopes).issubset(tok.scopes):
 			oauthreq.error_message = 'Scopes invalid'
 			return False
@@ -184,7 +189,7 @@ def authorize():
 		del session['devicelogin_id']
 		del session['devicelogin_secret']
 		del session['devicelogin_confirmation']
-		if not initiation or initiation.expired or not confirmation:
+		if not initiation or initiation.expired or not confirmation or confirmation.user.is_deactivated:
 			flash(_('Device login failed'))
 			return redirect(url_for('session.login', ref=request.full_path, devicelogin=True))
 		credentials['user'] = confirmation.user
diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py
index 3459d01a..bbf062bf 100644
--- a/uffd/views/selfservice.py
+++ b/uffd/views/selfservice.py
@@ -68,7 +68,7 @@ def forgot_password():
 	reset_ratelimit.log(loginname+'/'+mail)
 	host_ratelimit.log()
 	flash(_("We sent a mail to this user's mail address if you entered the correct mail and login name combination"))
-	user = User.query.filter_by(loginname=loginname).one_or_none()
+	user = User.query.filter_by(loginname=loginname, is_deactivated=False).one_or_none()
 	if not user:
 		return redirect(url_for('session.login'))
 	matches = any(map(lambda email: secrets.compare_digest(email.address, mail), user.verified_emails))
diff --git a/uffd/views/service.py b/uffd/views/service.py
index 37fa1f67..374180ca 100644
--- a/uffd/views/service.py
+++ b/uffd/views/service.py
@@ -80,6 +80,7 @@ def edit_submit(id=None):
 	else:
 		service.limit_access = True
 		service.access_group = Group.query.get(request.form['access-group'])
+	service.hide_deactivated_users = request.form.get('hide_deactivated_users') == '1'
 	service.enable_email_preferences = request.form.get('enable_email_preferences') == '1'
 	service.remailer_mode = RemailerMode[request.form['remailer-mode']]
 	remailer_overwrite_mode = RemailerMode[request.form['remailer-overwrite-mode']]
diff --git a/uffd/views/session.py b/uffd/views/session.py
index 9e45c210..df3301af 100644
--- a/uffd/views/session.py
+++ b/uffd/views/session.py
@@ -25,18 +25,12 @@ def set_request_user():
 	if datetime.datetime.utcnow().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']:
 		return
 	user = User.query.get(session['user_id'])
-	if not user or not user.is_in_group(current_app.config['ACL_ACCESS_GROUP']):
+	if not user or user.is_deactivated or not user.is_in_group(current_app.config['ACL_ACCESS_GROUP']):
 		return
 	request.user_pre_mfa = user
 	if session.get('user_mfa'):
 		request.user = user
 
-def login_get_user(loginname, password):
-	user = User.query.filter_by(loginname=loginname).one_or_none()
-	if user is None or not user.password.verify(password):
-		return None
-	return user
-
 @bp.route("/logout")
 def logout():
 	# The oauth2 module takes data from `session` and injects it into the url,
@@ -56,6 +50,7 @@ def set_session(user, skip_mfa=False):
 
 @bp.route("/login", methods=('GET', 'POST'))
 def login():
+	# pylint: disable=too-many-return-statements
 	if request.user_pre_mfa:
 		return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index'))))
 	if request.method == 'GET':
@@ -71,12 +66,16 @@ def login():
 		else:
 			flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay)))
 		return render_template('session/login.html', ref=request.values.get('ref'))
-	user = login_get_user(username, password)
-	if user is None:
+
+	user = User.query.filter_by(loginname=username).one_or_none()
+	if user is None or not user.password.verify(password):
 		login_ratelimit.log(username)
 		host_ratelimit.log()
 		flash(_('Login name or password is wrong'))
 		return render_template('session/login.html', ref=request.values.get('ref'))
+	if user.is_deactivated:
+		flash(_('Your account is deactivated. Contact %(contact_email)s for details.', contact_email=current_app.config['ORGANISATION_CONTACT']))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 	if user.password.needs_rehash:
 		user.password = password
 		db.session.commit()
diff --git a/uffd/views/user.py b/uffd/views/user.py
index 04fb953b..51d4d64e 100644
--- a/uffd/views/user.py
+++ b/uffd/views/user.py
@@ -146,6 +146,24 @@ def update(id):
 	flash(_('User updated'))
 	return redirect(url_for('user.show', id=user.id))
 
+@bp.route('/<int:id>/deactivate')
+@csrf_protect(blueprint=bp)
+def deactivate(id):
+	user = User.query.get_or_404(id)
+	user.is_deactivated = True
+	db.session.commit()
+	flash(_('User deactivated'))
+	return redirect(url_for('user.show', id=user.id))
+
+@bp.route('/<int:id>/activate')
+@csrf_protect(blueprint=bp)
+def activate(id):
+	user = User.query.get_or_404(id)
+	user.is_deactivated = False
+	db.session.commit()
+	flash(_('User activated'))
+	return redirect(url_for('user.show', id=user.id))
+
 @bp.route("/<int:id>/del")
 @csrf_protect(blueprint=bp)
 def delete(id):
-- 
GitLab