diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index af5d314dbcc470d8e5aa3791d3ebe3957f26996c..a4349087629ec1c1a04444327e503b6b775cbc4b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,6 +1,8 @@
 include:
     - project: "AlekSIS/official/AlekSIS"
       file: /ci/general.yml
+    - project: "AlekSIS/official/AlekSIS"
+      file: /ci/prepare/lock.yml
     - project: "AlekSIS/official/AlekSIS"
       file: /ci/test/test.yml
     - project: "AlekSIS/official/AlekSIS"
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2eae801df4cf4d6c911bf567a699900cbc810e17..6d1065772560708021b9d7a7941ec2f3980b2f2a 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -13,6 +13,8 @@ Added
 ~~~~~
 
 * Allow configuration of database options
+* User invitations with invite codes and targeted invites for existing
+  persons
 
 Fixed
 ~~~~~
@@ -27,6 +29,8 @@ Changed
 ~~~~~~~
 
 * Modified the appearance of tables for mobile users to be more user friendly
+* [Dev] Remove lock file; locking dependencies is the distribution's
+  responsibility
 
 Removed
 ~~~~~~~
diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index 40e6f91c250bfd15dd2dee238fd46295a9184a3a..78dc9dce29f7519905012193469477ce383f9f9e 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -6,7 +6,7 @@ from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Permission
 from django.contrib.sites.models import Site
-from django.core.exceptions import ValidationError
+from django.core.exceptions import SuspiciousOperation, ValidationError
 from django.db.models import QuerySet
 from django.http import HttpRequest
 from django.utils.translation import gettext_lazy as _
@@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _
 from allauth.account.adapter import get_adapter
 from allauth.account.forms import SignupForm
 from allauth.account.utils import setup_user_email
+from dj_cleavejs import CleaveWidget
 from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget
 from dynamic_preferences.forms import PreferenceForm
 from guardian.shortcuts import assign_perm
@@ -393,6 +394,27 @@ DashboardWidgetOrderFormSet = forms.formset_factory(
 )
 
 
+class InvitationCodeForm(forms.Form):
+    """Form to enter an invitation code."""
+
+    code = forms.CharField(
+        label=_("Invitation code"),
+        help_text=_("Please enter your invitation code."),
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Calculate number of fields
+        length = get_site_preferences()["auth__invite_code_length"]
+        packet_size = get_site_preferences()["auth__invite_code_packet_size"]
+        blocks = [
+            packet_size,
+        ] * length
+
+        self.fields["code"].widget = CleaveWidget(blocks=blocks, delimiter="-", uppercase=True)
+
+
 class SelectPermissionForm(forms.Form):
     """Select a permission to assign."""
 
@@ -513,13 +535,42 @@ class AccountRegisterForm(SignupForm, ExtensibleForm):
     """Form to register new user accounts."""
 
     class Meta:
-        model = Group
-        fields = []
+        model = Person
+        fields = [
+            "first_name",
+            "additional_name",
+            "last_name",
+            "street",
+            "housenumber",
+            "postal_code",
+            "place",
+            "date_of_birth",
+            "place_of_birth",
+            "sex",
+            "photo",
+            "mobile_number",
+            "phone_number",
+            "short_name",
+            "description",
+        ]
 
     layout = Layout(
         Fieldset(
             _("Base data"),
-            Row("first_name", "last_name"),
+            Row("first_name", "additional_name", "last_name"),
+            "short_name",
+        ),
+        Fieldset(
+            _("Adress data"),
+            Row("street", "housenumber"),
+            Row("postal_code", "place"),
+        ),
+        Fieldset(_("Contact data"), Row("mobile_number", "phone_number")),
+        Fieldset(
+            _("Additional data"),
+            Row("date_of_birth", "place_of_birth"),
+            Row("sex", "photo"),
+            "description",
         ),
         Fieldset(
             _("Account data"),
@@ -527,72 +578,48 @@ class AccountRegisterForm(SignupForm, ExtensibleForm):
             Row("email", "email2"),
             Row("password1", "password2"),
         ),
-        Fieldset(
-            _("Consents"),
-            Row("privacy_policy"),
-        ),
     )
 
-    def __init__(self, *args, **kwargs):
-        super(AccountRegisterForm, self).__init__(*args, **kwargs)
-        self.fields["password1"] = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
-
-        privacy_policy = get_site_preferences()["footer__privacy_url"]
-
-        if settings.SIGNUP_PASSWORD_ENTER_TWICE:
-            self.fields["password2"] = forms.CharField(
-                label=_("Password (again)"), widget=forms.PasswordInput
-            )
+    password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
 
-        self.fields["first_name"] = forms.CharField(
-            required=True,
-        )
+    privacy_policy = get_site_preferences()["footer__privacy_url"]
 
-        self.fields["last_name"] = forms.CharField(
-            required=True,
-        )
+    if settings.SIGNUP_PASSWORD_ENTER_TWICE:
+        password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput)
 
-        self.fields["privacy_policy"] = forms.BooleanField(
-            help_text=_(
-                f"I have read the <a href='{privacy_policy}'>Privacy policy</a>"
-                " and agree with them."
-            ),
-            required=True,
-        )
+    def __init__(self, *args, **kwargs):
+        request = kwargs.pop("request", None)
+        super(AccountRegisterForm, self).__init__(*args, **kwargs)
 
-    def clean(self):
-        super(AccountRegisterForm, self).clean()
+        if request.session.get("account_verified_email"):
+            email = request.session["account_verified_email"]
 
-        dummy_user = get_user_model()
-        password = self.cleaned_data.get("password1")
-        if password:
             try:
-                get_adapter().clean_password(password, user=dummy_user)
-            except forms.ValidationError as e:
-                self.add_error("password1", e)
+                person = Person.objects.get(email=email)
+            except (Person.DoesNotExist, Person.MultipleObjectsReturned):
+                raise SuspiciousOperation()
 
-        if (
-            settings.SIGNUP_PASSWORD_ENTER_TWICE
-            and "password1" in self.cleaned_data
-            and "password2" in self.cleaned_data
-        ):
-            if self.cleaned_data["password1"] != self.cleaned_data["password2"]:
-                self.add_error(
-                    "password2",
-                    _("You must type the same password each time."),
-                )
-        return self.cleaned_data
+            self.fields["email"].disabled = True
+            self.fields["email2"].disabled = True
+
+            if person:
+                available_fields = [field.name for field in Person._meta.get_fields()]
+                for field in self.fields:
+                    if field in available_fields and getattr(person, field):
+                        self.fields[field].disabled = True
+                        self.fields[field].initial = getattr(person, field)
 
     def save(self, request):
         adapter = get_adapter(request)
         user = adapter.new_user(request)
         adapter.save_user(request, user, self)
-        Person.objects.create(
-            first_name=self.cleaned_data["first_name"],
-            last_name=self.cleaned_data["last_name"],
-            email=self.cleaned_data["email"],
-            user=user,
-        )
+        # Create person
+        data = {}
+        for field in Person._meta.get_fields():
+            if field.name in self.cleaned_data:
+                data[field.name] = self.cleaned_data[field.name]
+        if not Person.objects.filter(email=data["email"]):
+            _person, created = Person.objects.update_or_create(user=user, **data)
         self.custom_signup(request, user)
         setup_user_email(request, user, [])
         return user
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index 9e1b3a2dd0431771af45b0861c2cf891c623e9d7..7938539c53327936d86d6c08334834e8e2a4fb0a 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -20,6 +20,15 @@ MENUS = {
                 ("aleksis.core.util.predicates.permission_validator", "core.can_register"),
             ],
         },
+        {
+            "name": _("Accept invitation"),
+            "url": "enter_invitation_code",
+            "icon": "vpn_key",
+            "validators": [
+                "menu_generator.validators.is_anonymous",
+                ("aleksis.core.util.predicates.permission_validator", "core.invite_enabled"),
+            ],
+        },
         {
             "name": _("Dashboard"),
             "url": "index",
@@ -296,6 +305,15 @@ MENUS = {
                         )
                     ],
                 },
+                {
+                    "name": _("Invite person"),
+                    "url": "invite_person",
+                    "icon": "card_giftcard",
+                    "validators": [
+                        "menu_generator.validators.is_authenticated",
+                        ("aleksis.core.util.predicates.permission_validator", "core.can_invite"),
+                    ],
+                },
             ],
         },
     ],
diff --git a/aleksis/core/migrations/0029_invitations.py b/aleksis/core/migrations/0029_invitations.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c4410267bf7064dbb0b8c9b57ff7f15a9645e93
--- /dev/null
+++ b/aleksis/core/migrations/0029_invitations.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.1.7 on 2021-03-31 16:55
+
+import aleksis.core.mixins
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('core', '0028_char_field_not_null'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PersonInvitation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('accepted', models.BooleanField(default=False, verbose_name='accepted')),
+                ('key', models.CharField(max_length=64, unique=True, verbose_name='key')),
+                ('sent', models.DateTimeField(null=True, verbose_name='sent')),
+                ('email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail address')),
+                ('inviter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                ('person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitation', to='core.person'))
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(models.Model, aleksis.core.mixins.PureDjangoModel),
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 8a8ec40af86ccb91fded46f58c2b0c9b9201ce84..aa20122bff05c234df71ef99db5a6fda318e10cd 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -29,6 +29,10 @@ from cachalot.api import cachalot_disabled
 from cache_memoize import cache_memoize
 from django_celery_results.models import TaskResult
 from dynamic_preferences.models import PerInstancePreferenceModel
+from invitations import signals
+from invitations.adapters import get_invitations_adapter
+from invitations.base_invitation import AbstractBaseInvitation
+from invitations.models import Invitation
 from model_utils import FieldTracker
 from model_utils.models import TimeStampedModel
 from oauth2_provider.models import (
@@ -59,7 +63,7 @@ from .mixins import (
     SchoolTermRelatedExtensibleModel,
 )
 from .tasks import send_notification
-from .util.core_helpers import get_site_preferences, now_tomorrow
+from .util.core_helpers import generate_random_code, get_site_preferences, now_tomorrow
 from .util.model_helpers import ICONS
 
 FIELD_CHOICES = (
@@ -1060,6 +1064,22 @@ class DataCheckResult(ExtensibleModel):
         )
 
 
+class PersonInvitation(AbstractBaseInvitation, PureDjangoModel):
+    """Custom model for invitations to allow to generate invitations codes without email address."""
+
+    email = models.EmailField(verbose_name=_("E-Mail address"), blank=True)
+    person = models.ForeignKey(
+        Person, on_delete=models.CASCADE, blank=True, related_name="invitation", null=True
+    )
+
+    def __str__(self) -> str:
+        return f"{self.email} ({self.inviter})"
+
+    key_expired = Invitation.key_expired
+
+    send_invitation = Invitation.send_invitation
+
+
 class PDFFile(ExtensibleModel):
     """Link to a rendered PDF file."""
 
diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py
index 0110d9aa84d3848912bf411a00063d86609f6c1d..15ca0e67478ed6483a8cd0df03aa38c1529b8762 100644
--- a/aleksis/core/preferences.py
+++ b/aleksis/core/preferences.py
@@ -265,6 +265,29 @@ class SignupEnabled(BooleanPreference):
 
 
 @site_preferences_registry.register
+class InviteEnabled(BooleanPreference):
+    section = auth
+    name = "invite_enabled"
+    default = False
+    verbose_name = _("Enable invitations")
+
+
+@site_preferences_registry.register
+class InviteCodeLength(IntegerPreference):
+    section = auth
+    name = "invite_code_length"
+    default = 3
+    verbose_name = _("Length of invite code. (Default 3: abcde-acbde-abcde)")
+
+
+@site_preferences_registry.register
+class InviteCodePacketSize(IntegerPreference):
+    section = auth
+    name = "invite_code_packet_size"
+    default = 5
+    verbose_name = _("Size of packets. (Default 5: abcde)")
+
+
 class OAuthAllowedGrants(MultipleChoicePreference):
     """Grant Flows allowed for OAuth applications."""
 
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 7c7082e82037aadfd3390677d09a23538f7b4ec4..94971e5c5bbc11277afc245720b12ed8ab979a01 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -93,6 +93,7 @@ INSTALLED_APPS = [
     "rules.apps.AutodiscoverRulesConfig",
     "haystack",
     "polymorphic",
+    "dj_cleavejs.apps.DjCleaveJSConfig",
     "dbbackup",
     "django_celery_beat",
     "django_celery_results",
@@ -121,6 +122,7 @@ INSTALLED_APPS = [
     "allauth",
     "allauth.account",
     "allauth.socialaccount",
+    "invitations",
     "health_check",
     "health_check.db",
     "health_check.cache",
@@ -326,8 +328,11 @@ ACCOUNT_AUTHENTICATION_METHOD = _settings.get("auth.registration.method", "usern
 ACCOUNT_EMAIL_REQUIRED = _settings.get("auth.registration.email_required", True)
 SOCIALACCOUNT_EMAIL_REQUIRED = False
 
+# Cooldown for verification mails
+ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = _settings.get("auth.registration.verification_cooldown", 180)
+
 # Require email verification after sign up
-ACCOUNT_EMAIL_VERIFICATION = _settings.get("auth.registration.email_verification", "mandatory")
+ACCOUNT_EMAIL_VERIFICATION = _settings.get("auth.registration.email_verification", "optional")
 SOCIALACCOUNT_EMAIL_VERIFICATION = False
 
 # Email subject prefix for verification mails
@@ -345,6 +350,21 @@ ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = True
 # Enforce uniqueness of email addresses
 ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.login.registration.unique_email", True)
 
+# Configuration for django-invitations
+
+# Use custom account adapter
+ACCOUNT_ADAPTER = "invitations.models.InvitationsAdapter"
+# Expire invitations are configured amout of days
+INVITATIONS_INVITATION_EXPIRY = _settings.get("auth.invitation.expiry", 3)
+# Use email prefix configured for django-allauth
+INVITATIONS_EMAIL_SUBJECT_PREFIX = ACCOUNT_EMAIL_SUBJECT_PREFIX
+# Use custom invitation model
+INVITATIONS_INVITATION_MODEL = "core.PersonInvitation"
+# Display error message if invitation code is invalid
+INVITATIONS_GONE_ON_ACCEPT_ERROR = False
+# Mark invitation as accepted after signup
+INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True
+
 # Configuration for OAuth2 provider
 OAUTH2_PROVIDER = {"SCOPES_BACKEND_CLASS": "aleksis.core.util.auth_helpers.AppScopes"}
 OAUTH2_PROVIDER_APPLICATION_MODEL = "core.OAuthApplication"
@@ -504,6 +524,7 @@ MEDIA_ROOT = _settings.get("media.root", os.path.join(BASE_DIR, "media"))
 NODE_MODULES_ROOT = _settings.get("node_modules.root", os.path.join(BASE_DIR, "node_modules"))
 
 YARN_INSTALLED_APPS = [
+    "cleave.js",
     "@fontsource/roboto",
     "jquery",
     "@materializecss/materialize",
@@ -545,10 +566,13 @@ ANY_JS = {
     "Roboto700": {"css_url": JS_URL + "/@fontsource/roboto/700.css"},
     "Roboto900": {"css_url": JS_URL + "/@fontsource/roboto/900.css"},
     "Sentry": {"js_url": JS_URL + "/@sentry/tracing/build/bundle.tracing.js"},
+    "cleavejs": {"js_url": "cleave.js/dist/cleave.min.js"},
 }
 
 merge_app_settings("ANY_JS", ANY_JS, True)
 
+CLEAVE_JS = ANY_JS["cleavejs"]["js_url"]
+
 SASS_PROCESSOR_ENABLED = True
 SASS_PROCESSOR_AUTO_INCLUDE = False
 SASS_PROCESSOR_CUSTOM_FUNCTIONS = {
diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py
index 2557b126ceeaebae567df5488b6284783962ac50..ab19c80608f1a8966dab7ae2094eca7caf579252 100644
--- a/aleksis/core/tables.py
+++ b/aleksis/core/tables.py
@@ -1,8 +1,13 @@
+from textwrap import wrap
+
 from django.utils.translation import gettext_lazy as _
 
 import django_tables2 as tables
 from django_tables2.utils import A
 
+from .models import Person
+from .util.core_helpers import get_site_preferences
+
 
 class SchoolTermTable(tables.Table):
     """Table to list persons."""
@@ -93,6 +98,33 @@ class DashboardWidgetTable(tables.Table):
         return record._meta.verbose_name
 
 
+class PersonColumn(tables.Column):
+    """Returns person object from given id."""
+
+    def render(self, value):
+        return Person.objects.get(user__id=value)
+
+
+class InvitationCodeColumn(tables.Column):
+    """Returns invitation code in a more readable format."""
+
+    def render(self, value):
+        packet_size = get_site_preferences()["auth__invite_code_packet_size"]
+        return "-".join(wrap(value, packet_size))
+
+
+class InvitationsTable(tables.Table):
+    """Table to list persons."""
+
+    email = tables.EmailColumn()
+    sent = tables.DateColumn()
+    inviter_id = PersonColumn()
+    key = InvitationCodeColumn()
+    accepted = tables.BooleanColumn(
+        yesno="check,cancel", attrs={"span": {"class": "material-icons"}}
+    )
+
+
 class PermissionDeleteColumn(tables.LinkColumn):
     """Link column with label 'Delete'."""
 
diff --git a/aleksis/core/templates/account/account_inactive.html b/aleksis/core/templates/account/account_inactive.html
index 62b03a3db859699aabb816ef1d05455777965bd9..5c328abcfbca48c6499f71b4b11fac5f1f91ccad 100644
--- a/aleksis/core/templates/account/account_inactive.html
+++ b/aleksis/core/templates/account/account_inactive.html
@@ -9,8 +9,10 @@
   <div class="container">
     <div class="card red">
       <div class="card-content white-text">
-        <div class="material-icons small left">error_outline</div>
-        <span class="card-title">{% blocktrans %}Account inactive.{% endblocktrans %}</span>
+        <div class="card-title">
+          <i class="material-icons small left">error_outline</i>
+          {% blocktrans %}Account inactive.{% endblocktrans %}
+        </div>
         <p>
           {% blocktrans %}
             This account is currently inactive. If you think this is an
diff --git a/aleksis/core/templates/account/signup_closed.html b/aleksis/core/templates/account/signup_closed.html
index 33ef749f5598fbd209e3f72dc2b4a79620f17b1a..0d5cbd51095e9c24fceacbc4ba8f6a510a5705a9 100644
--- a/aleksis/core/templates/account/signup_closed.html
+++ b/aleksis/core/templates/account/signup_closed.html
@@ -9,8 +9,10 @@
   <div class="container">
     <div class="card red">
       <div class="card-content white-text">
-        <div class="material-icons small left">error_outline</div>
-        <span class="card-title">{% blocktrans %}Signup closed.{% endblocktrans %}</span>
+        <div class="card-title">
+        <i class="material-icons small left">error_outline</i>
+          {% blocktrans %}Signup closed.{% endblocktrans %}
+        </div>
         <p>
           {% blocktrans %}
             This sign up is currently closed. If you think this is an
diff --git a/aleksis/core/templates/account/verification_sent.html b/aleksis/core/templates/account/verification_sent.html
index dd486aeb2bf212838a014fe868db4cfe6716cf8d..df3ae47c82911a01997ac9f4d7919c9e1b95a36e 100644
--- a/aleksis/core/templates/account/verification_sent.html
+++ b/aleksis/core/templates/account/verification_sent.html
@@ -25,10 +25,6 @@
             contact us if you do not receive it within a few minutes.
           {% endblocktrans %}
         </p>
-        <p>
-          {% url 'account_email' as email_url %}
-          {% blocktrans with email_url=email_url %}<strong>Note:</strong> you can still <a href="{{ email_url }}">change your e-mail address</a>{% endblocktrans %}
-        </p>
         {% include "core/partials/admins_list.html" %}
       </div>
     </div>
diff --git a/aleksis/core/templates/core/person/full.html b/aleksis/core/templates/core/person/full.html
index d16354f00ebcd2162ffc7bab16715707b41adb81..4a2e68a09517d1888f708b8ece3f757c6b1d8b1f 100644
--- a/aleksis/core/templates/core/person/full.html
+++ b/aleksis/core/templates/core/person/full.html
@@ -14,6 +14,7 @@
   {% has_perm 'core.change_person_preferences_rule' user person as can_change_person_preferences %}
   {% has_perm 'core.delete_person_rule' user person as can_delete_person %}
   {% has_perm "core.impersonate_rule" user person as can_impersonate %}
+  {% has_perm "core.can_invite" user person as can_invite %}
 
   {% if can_change_person or can_change_person_preferences or can_delete_person or can_impersonate %}
     <p>
@@ -43,7 +44,13 @@
           <i class="material-icons left">portrait</i>
           {% trans "Impersonate" %}
         </a>
-      {% endif %}
+    {% endif %}
+    {% if can_invite and not person.user %}
+        <a href="{% url "invite_person_by_id" person.id %}" class="btn waves-effect waves-light">
+          <i class="material-icons left">card_giftcard</i>
+          {% trans "Invite user" %}
+        </a>
+    {% endif %}
     </p>
   {% endif %}
 
diff --git a/aleksis/core/templates/invitations/enter.html b/aleksis/core/templates/invitations/enter.html
new file mode 100644
index 0000000000000000000000000000000000000000..03605a710697334567384a74a289783b64a7f55e
--- /dev/null
+++ b/aleksis/core/templates/invitations/enter.html
@@ -0,0 +1,44 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n %}
+
+{% block browser_title %}{% blocktrans %}Accept invitation{% endblocktrans %}{% endblock %}
+{% block no_page_title %}{% endblock %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+  <div class="col m1 l2 xl3"></div>
+  <div class="col s12 m10 l8 xl6">
+    <div class="card">
+      <form method="post">
+        <div class="card-content">
+          <div class="card-title">{% trans "Accept your invitation" %}</div>
+          <div class="alert primary">
+            <div>
+              <i class="material-icons left">info</i>
+              {% blocktrans %}
+                Please enter your invitation code to register
+                your new user account:
+              {% endblocktrans %}
+            </div>
+          </div>
+          {% csrf_token %}
+          {% form form=form %}{% endform %}
+        </div>
+        <div class="card-action-light">
+          <button type="submit" class="btn green waves-effect waves-light">
+            <i class="material-icons left">card_giftcard</i>
+            {% trans "Accept invite" %}
+          </button>
+        </div>
+      </form>
+    </div>
+  </div>
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/core/templates/invitations/forms/_invite.html b/aleksis/core/templates/invitations/forms/_invite.html
new file mode 100644
index 0000000000000000000000000000000000000000..297997e0b04f3d99a1e1279c8900d0bcc9c50024
--- /dev/null
+++ b/aleksis/core/templates/invitations/forms/_invite.html
@@ -0,0 +1,38 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n %}
+
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Invite{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Invite{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+
+  <div class="row">
+    <div class="col s6">
+      <h5>{% trans "Invite by email address" %}</h5>
+      <form method="post">
+        {% csrf_token %}
+        {% form form=form %}{% endform %}
+        {% trans "Invite" as caption %}
+        {% include "core/partials/save_button.html" with caption=caption icon="card_giftcard" %}
+      </form>
+    </div>
+    <div class="col s6">        
+      <h5>{% trans "Generate invitation code" %}</h5>
+
+      <a class="btn waves-effect waves-light hundred-percent" href="{% url 'generate_invitation_code' %}">
+        {% trans "Generate code" %}
+      </a>
+    </div>
+    <div class="col s12">
+      <h5>{% trans "Invitations" %}</h5>
+
+      {% render_table table %}
+    </div>
+  </div>
+{% endblock %}
diff --git a/aleksis/core/templates/invitations/messages/invite_accepted.txt b/aleksis/core/templates/invitations/messages/invite_accepted.txt
new file mode 100644
index 0000000000000000000000000000000000000000..59699adee30108f241eb6dd4db1478f75399292d
--- /dev/null
+++ b/aleksis/core/templates/invitations/messages/invite_accepted.txt
@@ -0,0 +1,3 @@
+{% load i18n %}
+
+{% blocktrans %}The invitation for {{ email }} has been accepted.{% endblocktrans %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 0c00bb8b26b397f2b0bdfb591bd232fcb3d13e74..753d95085b6c8812927f2db376a6f3e2e6429c4b 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -23,6 +23,7 @@ urlpatterns = [
     path("serviceworker.js", views.ServiceWorkerView.as_view(), name="service_worker"),
     path("offline/", views.OfflineView.as_view(), name="offline"),
     path("about/", views.about, name="about_aleksis"),
+    path("accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup"),
     path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
     path(
         "accounts/password/change/",
@@ -30,6 +31,16 @@ urlpatterns = [
         name="account_change_password",
     ),
     path("accounts/", include("allauth.urls")),
+    path("invitations/send-invite", views.InvitePerson.as_view(), name="invite_person"),
+    path(
+        "invitations/code/enter", views.EnterInvitationCode.as_view(), name="enter_invitation_code"
+    ),
+    path(
+        "invitations/code/generate",
+        views.GenerateInvitationCode.as_view(),
+        name="generate_invitation_code",
+    ),
+    path("invitations/", include("invitations.urls")),
     path(
         "accounts/social/connections/<int:pk>/delete",
         views.SocialAccountDeleteView.as_view(),
@@ -39,6 +50,7 @@ urlpatterns = [
     path("admin/uwsgi/", include("django_uwsgi.urls")),
     path("data_management/", views.data_management, name="data_management"),
     path("status/", views.SystemStatus.as_view(), name="system_status"),
+    path("account/login/", views.LoginView.as_view(), name="login"),
     path("", include(tf_urls)),
     path("celery_progress/<str:task_id>/", views.CeleryProgressView.as_view(), name="task_status"),
     path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
@@ -51,6 +63,7 @@ urlpatterns = [
     path("person/<int:id_>/", views.person, name="person_by_id"),
     path("person/<int:pk>/edit/", views.EditPersonView.as_view(), name="edit_person_by_id"),
     path("person/<int:id_>/delete/", views.delete_person, name="delete_person_by_id"),
+    path("person/<int:id_>/invite/", views.InvitePersonByID.as_view(), name="invite_person_by_id"),
     path("groups", views.groups, name="groups"),
     path("groups/additional_fields", views.additional_fields, name="additional_fields"),
     path("groups/child_groups/", views.groups_child_groups, name="groups_child_groups"),
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index 53ad87a6e71edd3562c642b91e2d4dbf03e17253..3b3c934a85341e75092b6ef1e8df77bdc838687e 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -13,6 +13,7 @@ from django.db.models import Model, QuerySet
 from django.http import HttpRequest
 from django.shortcuts import get_object_or_404
 from django.utils import timezone
+from django.utils.crypto import get_random_string
 from django.utils.functional import lazy
 from django.utils.module_loading import import_string
 
@@ -279,6 +280,11 @@ def queryset_rules_filter(
     return queryset.filter(pk__in=wanted_objects)
 
 
+def generate_random_code(length, packet_size) -> str:
+    """Generate random code for e.g. invitations."""
+    return get_random_string(packet_size * length).lower()
+
+
 def unread_notifications_badge(request: HttpRequest) -> int:
     """Generate badge content with the number of unread notifications."""
     return request.user.person.unread_notifications_count
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 285f28b606370aeb77fb88add55bc9fa16e38169..c3825b7b6548cf3bc9633a4153cb15d78012faee 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -1,3 +1,4 @@
+from textwrap import wrap
 from typing import Any, Dict, Optional, Type
 from urllib.parse import urlencode
 
@@ -22,19 +23,20 @@ from django.http import (
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import loader
 from django.urls import reverse, reverse_lazy
+from django.utils import timezone
 from django.utils.decorators import method_decorator
 from django.utils.translation import get_language
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.cache import never_cache
 from django.views.defaults import ERROR_500_TEMPLATE_NAME
-from django.views.generic import FormView
 from django.views.generic.base import TemplateView, View
 from django.views.generic.detail import DetailView, SingleObjectMixin
-from django.views.generic.edit import DeleteView
+from django.views.generic.edit import DeleteView, FormView
 from django.views.generic.list import ListView
 
 import reversion
-from allauth.account.views import PasswordChangeView
+from allauth.account.utils import _has_verified_for_login, send_email_confirmation
+from allauth.account.views import PasswordChangeView, SignupView
 from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.models import SocialAccount
 from celery_progress.views import get_progress
@@ -48,9 +50,12 @@ from haystack.inputs import AutoQuery
 from haystack.query import SearchQuerySet
 from haystack.utils.loading import UnifiedIndex
 from health_check.views import MainView
+from invitations.views import SendInvite, accept_invitation
 from reversion import set_user
 from reversion.views import RevisionMixin
+from rules import test_rule
 from rules.contrib.views import PermissionRequiredMixin, permission_required
+from two_factor.views.core import LoginView as AllAuthLoginView
 
 from aleksis.core.data_checks import DataCheckRegistry, check_data
 
@@ -64,6 +69,7 @@ from .filters import (
     UserObjectPermissionFilter,
 )
 from .forms import (
+    AccountRegisterForm,
     AnnouncementForm,
     AssignPermissionForm,
     ChildGroupsForm,
@@ -72,6 +78,7 @@ from .forms import (
     EditGroupForm,
     EditGroupTypeForm,
     GroupPreferenceForm,
+    InvitationCodeForm,
     OAuthApplicationForm,
     PersonForm,
     PersonPreferenceForm,
@@ -93,6 +100,7 @@ from .models import (
     OAuthApplication,
     PDFFile,
     Person,
+    PersonInvitation,
     SchoolTerm,
     TaskUserAssignment,
 )
@@ -108,6 +116,7 @@ from .tables import (
     GroupObjectPermissionTable,
     GroupsTable,
     GroupTypesTable,
+    InvitationsTable,
     PersonsTable,
     SchoolTermTable,
     UserGlobalPermissionTable,
@@ -117,6 +126,7 @@ from .util import messages
 from .util.apps import AppConfig
 from .util.celery_progress import render_progress_page
 from .util.core_helpers import (
+    generate_random_code,
     get_allowed_object_ids,
     get_pwa_icons,
     get_site_preferences,
@@ -1054,6 +1064,72 @@ class EditDashboardView(PermissionRequiredMixin, View):
         return render(request, "core/edit_dashboard.html", context=context)
 
 
+class InvitePerson(PermissionRequiredMixin, SingleTableView, SendInvite):
+    """View to invite a person to register an account."""
+
+    template_name = "invitations/forms/_invite.html"
+    permission_required = "core.can_invite"
+    model = PersonInvitation
+    table_class = InvitationsTable
+    context = {}
+
+    # Get queryset of invitations
+    def get_context_data(self, **kwargs):
+        queryset = kwargs.pop("object_list", None)
+        if queryset is None:
+            self.object_list = self.model.objects.all()
+        return super().get_context_data(**kwargs)
+
+
+class EnterInvitationCode(FormView):
+    """View to enter an invitation code."""
+
+    template_name = "invitations/enter.html"
+    form_class = InvitationCodeForm
+
+    def form_valid(self, form):
+        code = "".join(form.cleaned_data["code"].lower().split("-"))
+        # Check if valid invitations exists
+        if (
+            PersonInvitation.objects.filter(key=code).exists()
+            and not PersonInvitation.objects.get(key=code).accepted
+            and not PersonInvitation.objects.get(key=code).key_expired()
+        ):
+            invitation = PersonInvitation.objects.get(key=code)
+            # Mark invitation as accepted and redirect to signup
+            accept_invitation(
+                invitation=invitation, request=self.request, signal_sender=self.request.user
+            )
+            return redirect("account_signup")
+        return redirect("invitations:accept-invite", code)
+
+
+class GenerateInvitationCode(View):
+    """View to generate an invitation code."""
+
+    def get(self, request):
+        # Build code
+        length = get_site_preferences()["auth__invite_code_length"]
+        packet_size = get_site_preferences()["auth__invite_code_packet_size"]
+        code = generate_random_code(length, packet_size)
+
+        # Create invitation object
+        invitation = PersonInvitation.objects.create(
+            email="", inviter=request.user, key=code, sent=timezone.now()
+        )
+
+        # Make code more readable
+        code = "-".join(wrap(invitation.key, 5))
+
+        # Generate success message and print code
+        messages.success(
+            request,
+            _(f"The invitation was successfully created. The invitation code is {code}"),
+        )
+
+        return redirect("invite_person")
+
+
 class PermissionsListBaseView(PermissionRequiredMixin, SingleTableMixin, FilterView):
     """Base view for list of all permissions."""
 
@@ -1318,3 +1394,70 @@ def server_error(
     context = {"request": request}
 
     return HttpResponseServerError(template.render(context))
+
+
+class AccountRegisterView(SignupView):
+    """Custom view to register a user account.
+
+    Rewrites dispatch function from allauth to check if signup is open or if the user
+    has a verified email address from an invitation; otherwise raises permission denied.
+    """
+
+    form_class = AccountRegisterForm
+    success_url = "index"
+
+    def dispatch(self, request, *args, **kwargs):
+        if not test_rule("core.can_register") and not request.session.get("account_verified_email"):
+            raise PermissionDenied()
+        return super(AccountRegisterView, self).dispatch(request, *args, **kwargs)
+
+    def get_form_kwargs(self):
+        kwargs = super(AccountRegisterView, self).get_form_kwargs()
+        kwargs["request"] = self.request
+        return kwargs
+
+
+class InvitePersonByID(View):
+    """Custom view to invite person by their ID."""
+
+    success_url = reverse_lazy("persons")
+
+    def get(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        success_url = reverze_lazy("person_by_id", self.object.pk)
+        person = self.object
+
+        if not PersonInvitation.objects.filter(email=person.email).exists():
+            length = get_site_preferences()["auth__invite_code_length"]
+            packet_size = get_site_preferences()["auth__invite_code_packet_size"]
+            key = generate_random_code(length, packet_size)
+            invite = PersonInvitation.objects.create(person=person, key=key)
+            if person.email:
+                invite.email = person.email
+            invite.inviter = self.request.user
+            invite.save()
+
+            invite.send_invitation(self.request)
+            messages.success(self.request, _("Person was invited successfully."))
+        else:
+            messages.success(self.request, _("Person was already invited."))
+
+        return HttpResponseRedirect(success_url)
+
+
+class LoginView(AllAuthLoginView):
+    """Custom login view covering e-mail verification if mandatory.
+
+    Overrides view from allauth to check if email verification from django-invitations is
+    mandatory. If it i, checks if the user has a verified email address, if not,
+    it re-sends verification.
+    """
+
+    def done(self, form_list, **kwargs):
+        if settings.ACCOUNT_EMAIL_VERIFICATION == "mandatory":
+            user = self.get_user()
+            if not _has_verified_for_login(user, user.email):
+                send_email_confirmation(self.request, user, signup=False, email=user.email)
+                return render(self.request, "account/verification_sent.html")
+
+        return super().done(form_list, **kwargs)
diff --git a/pyproject.toml b/pyproject.toml
index 9a3d5b31260fb18f8c157541e2fe0ce7557eb73f..170725240c480ba107af172560fde9f0dcab1a6b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -99,6 +99,8 @@ django-cachalot = "^2.3.2"
 django-prometheus = "^2.1.0"
 django-model-utils = "^4.0.0"
 bs4 = "^0.0.1"
+django-invitations = "^1.9.3"
+django-cleavejs = "^0.1.0"
 django-allauth = "^0.47.0"
 django-uwsgi-ng = "^1.1.0"
 django-extensions = "^3.1.1"