From c2a92a1aa4858afcae92e2dcae13ca9374b9bbd9 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 27 Mar 2025 00:21:59 +0100
Subject: [PATCH] Implement invitations in account registration component

---
 .../account/AccountRegistrationForm.vue       | 134 +++++++++++++++---
 .../components/account/helpers.graphql        |   9 ++
 .../generic/multi_step/ControlRow.vue         |   6 +-
 aleksis/core/frontend/messages/en.json        |  15 ++
 aleksis/core/frontend/mixins/permissions.js   |   9 +-
 aleksis/core/frontend/routes.js               |   1 +
 aleksis/core/models.py                        |   1 +
 aleksis/core/rules.py                         |   2 +-
 aleksis/core/schema/__init__.py               |  11 ++
 aleksis/core/schema/person.py                 |  84 ++++++-----
 aleksis/core/schema/person_invitation.py      |  28 ++++
 aleksis/core/schema/user.py                   |   2 -
 12 files changed, 237 insertions(+), 65 deletions(-)
 create mode 100644 aleksis/core/schema/person_invitation.py

diff --git a/aleksis/core/frontend/components/account/AccountRegistrationForm.vue b/aleksis/core/frontend/components/account/AccountRegistrationForm.vue
index 340644118..2103a7045 100644
--- a/aleksis/core/frontend/components/account/AccountRegistrationForm.vue
+++ b/aleksis/core/frontend/components/account/AccountRegistrationForm.vue
@@ -47,7 +47,7 @@ import PersonDetailsCard from "../person/PersonDetailsCard.vue";
           </v-col>
         </v-row>
       </v-alert>
-      <v-stepper v-model="step" class="mb-4">
+      <v-stepper v-model="step" class="mb-4" v-if="isPermissionFetched('core.invite_enabled')">
         <v-stepper-header>
           <template v-for="(stepChoice, index) in steps">
             <v-stepper-step
@@ -62,6 +62,52 @@ import PersonDetailsCard from "../person/PersonDetailsCard.vue";
           </template>
         </v-stepper-header>
         <v-stepper-items>
+          <v-stepper-content
+            v-if="isStepEnabled('invitation')"
+            :step="getStepIndex('invitation')"
+          >
+            <h2 class="text-h6 mb-4">{{ $t(getStepTitleKey("invitation")) }}</h2>
+            <div class="mb-4">
+              <v-form v-model="validationStatuses['invitation']">
+                <div :aria-required="invitationCodeRequired">
+                  <v-text-field
+                    outlined
+                    v-model="data.invitationCode"
+                    :label="
+                      $t(
+                        'accounts.signup.form.steps.invitation.fields.invitation_code.label',
+                      )
+                    "
+                    :hint="
+                      $t(
+                        'accounts.signup.form.steps.invitation.fields.invitation_code.help_text',
+                      )
+                    "
+                    persistent-hint
+                    required
+                    :rules="
+                      invitationCodeRequired
+                        ? $rules().required.build()
+                        : []
+                    "
+                  ></v-text-field>
+                </div>
+              </v-form>
+              <v-alert v-if="invitationCodeInvalid" type="error" outlined class="mt-4">{{
+                $t("accounts.signup.form.steps.invitation.not_valid")
+              }}</v-alert>
+            </div>
+            <v-divider class="mb-4" />
+            <control-row
+              :step="step"
+              @set-step="checkInvitationCode"
+              :next-i18n-key="invitationNextI18nKey"
+              :next-disabled="
+                !validationStatuses['invitation']
+              "
+            />
+          </v-stepper-content>
+
           <v-stepper-content
             v-if="isStepEnabled('email')"
             :step="getStepIndex('email')"
@@ -520,6 +566,7 @@ import PersonDetailsCard from "../person/PersonDetailsCard.vue";
 
           <v-stepper-content :step="getStepIndex('confirm')">
             <h2 class="text-h6 mb-4">{{ $t(getStepTitleKey("confirm")) }}</h2>
+            <!-- TODO: this should somehow also indicate whether an invitation code was used -->
             <person-details-card class="mb-4" :person="personDataForSummary" :show-username="true" title-key="accounts.signup.form.steps.confirm.card_title" />
 
             <ApolloMutation
@@ -550,10 +597,11 @@ import PersonDetailsCard from "../person/PersonDetailsCard.vue";
 </template>
 
 <script>
-import { gqlRequiredFieldsPreference } from "./helpers.graphql";
+import { gqlRequiredFieldsPreference, gqlPersonInvitationByCode } from "./helpers.graphql";
 import { collections } from "aleksisAppImporter";
 
 import formRulesMixin from "../../mixins/formRulesMixin";
+import permissionsMixin from "../../mixins/permissions";
 
 export default {
   name: "AccountRegistrationForm",
@@ -561,13 +609,35 @@ export default {
     systemProperties: {
       query: gqlRequiredFieldsPreference,
     },
+    personInvitationByCode: {
+      query: gqlPersonInvitationByCode,
+      variables() {
+        return {
+          code: this.data.invitationCode,
+        };
+      },
+      skip: true,
+    },
   },
-  mixins: [formRulesMixin],
+  mixins: [formRulesMixin, permissionsMixin],
   methods: {
     setStep(step) {
       this.step = step;
       this.valid = false;
     },
+    checkInvitationCode(step) {
+      this.invitationCodeInvalid = false;
+      this.$apollo.queries.personInvitationByCode.skip = false;
+      this.$apollo.queries.personInvitationByCode.options.result = ({ data, loading, networkStatus }) => {
+        if (data?.personInvitationByCode?.valid) {
+          this.invitation = data.personInvitationByCode;
+          this.setStep(step);
+        } else {
+          this.invitationCodeInvalid = true;
+        }
+      };
+      this.$apollo.queries.personInvitationByCode.refetch();
+    },
     accountRegistrationDone({ data }) {
       if (data.sendAccountRegistration.ok) {
         this.accountRegistrationSent = true;
@@ -682,9 +752,17 @@ export default {
     },
     steps() {
       return [
+        ...(this.checkPermission("core.invite_enabled")
+          ? [
+              {
+                name: "invitation",
+                titleKey: "accounts.signup.form.steps.invitation.title",
+              },
+            ]
+          : []),
         ...(this.collectionSteps.some(
           (s) => s.key === "postbuero-mail-address-form-step",
-        )
+        ) && !this.invitation?.hasEmail
           ? [
               {
                 name: "email",
@@ -696,22 +774,26 @@ export default {
           name: "account",
           titleKey: "accounts.signup.form.steps.account.title",
         },
-        {
-          name: "base_data",
-          titleKey: "accounts.signup.form.steps.base_data.title",
-        },
-        {
-          name: "address_data",
-          titleKey: "accounts.signup.form.steps.address_data.title",
-        },
-        {
-          name: "contact_data",
-          titleKey: "accounts.signup.form.steps.contact_data.title",
-        },
-        {
-          name: "additional_data",
-          titleKey: "accounts.signup.form.steps.additional_data.title",
-        },
+        ...(!this.invitation?.hasPerson
+          ? [
+              {
+                name: "base_data",
+                titleKey: "accounts.signup.form.steps.base_data.title",
+              },
+              {
+                name: "address_data",
+                titleKey: "accounts.signup.form.steps.address_data.title",
+              },
+              {
+                name: "contact_data",
+                titleKey: "accounts.signup.form.steps.contact_data.title",
+              },
+              {
+                name: "additional_data",
+                titleKey: "accounts.signup.form.steps.additional_data.title",
+              },
+            ]
+          : []),
         {
           name: "confirm",
           titleKey: "accounts.signup.form.steps.confirm.title",
@@ -724,10 +806,18 @@ export default {
       }
       return [];
     },
+    invitationNextI18nKey() {
+      return this.data.invitationCode ? "accounts.signup.form.steps.invitation.next.with_code" : this.invitationCodeRequired ? "accounts.signup.form.steps.invitation.next.code_required" : "accounts.signup.form.steps.invitation.next.without_code";
+    },
+    invitationCodeRequired() {
+      return this.checkPermission("core.invite_enabled") && !this.checkPermission("core.signup_rule");
+    },
   },
   data() {
     return {
       validationStatuses: {},
+      invitation: null,
+      invitationCodeInvalid: false,
       accountRegistrationSent: false,
       step: 1,
       emailMode: null,
@@ -761,6 +851,7 @@ export default {
           password: "",
           confirmPassword: "",
         },
+        invitationCode: "",
       },
     };
   },
@@ -770,6 +861,9 @@ export default {
       comp.$el.scrollIntoView();
     },
   },
+  mounted() {
+    this.addPermissions(["core.signup_rule", "core.invite_enabled"]);
+  },
 };
 </script>
 
diff --git a/aleksis/core/frontend/components/account/helpers.graphql b/aleksis/core/frontend/components/account/helpers.graphql
index 9970d1691..ee0eaf997 100644
--- a/aleksis/core/frontend/components/account/helpers.graphql
+++ b/aleksis/core/frontend/components/account/helpers.graphql
@@ -6,3 +6,12 @@ query gqlRequiredFieldsPreference {
     }
   }
 }
+
+query gqlPersonInvitationByCode($code: String!) {
+  personInvitationByCode(code: $code) {
+    id
+    valid
+    hasEmail
+    hasPerson
+  }
+}
diff --git a/aleksis/core/frontend/components/generic/multi_step/ControlRow.vue b/aleksis/core/frontend/components/generic/multi_step/ControlRow.vue
index fa81f4c41..ab90a1e4b 100644
--- a/aleksis/core/frontend/components/generic/multi_step/ControlRow.vue
+++ b/aleksis/core/frontend/components/generic/multi_step/ControlRow.vue
@@ -27,7 +27,7 @@
       :loading="nextLoading"
       @click="$emit('set-step', step + 1)"
     >
-      {{ $t("actions.next") }}
+      {{ $t(nextI18nKey) }}
       <v-icon right>mdi-chevron-right</v-icon>
     </v-btn>
   </div>
@@ -53,6 +53,10 @@ export default {
       type: Boolean,
       default: false,
     },
+    nextI18nKey: {
+      type: String,
+      default: "actions.next",
+    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index c5dcea2a6..f530a924e 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -48,6 +48,21 @@
         },
         "existing_account_alert": "Already have an account? Then please log in.",
         "steps": {
+          "invitation": {
+            "title": "Invitation code",
+            "next": {
+              "with_code": "Continue with code",
+              "without_code": "Continue without code",
+              "code_required": "Invitation code required"
+            },
+            "not_valid": "The invitation code you entered is not valid.",
+            "fields": {
+              "invitation_code": {
+                "label": "Invitation code",
+                "help_text": "If you have an invitation code, please enter it."
+              }
+            }
+          },
           "email": {
             "title": "E-Mail address",
             "choose_mode": {
diff --git a/aleksis/core/frontend/mixins/permissions.js b/aleksis/core/frontend/mixins/permissions.js
index 2dd7a462d..3156b5d47 100644
--- a/aleksis/core/frontend/mixins/permissions.js
+++ b/aleksis/core/frontend/mixins/permissions.js
@@ -4,10 +4,15 @@
 
 const permissionsMixin = {
   methods: {
-    checkPermission(permissionName) {
+    isPermissionFetched(permissionName) {
       return (
         this.$root.permissions &&
-        this.$root.permissions.find((p) => p.name === permissionName) &&
+        this.$root.permissions.find((p) => p.name === permissionName)
+      );
+    },
+    checkPermission(permissionName) {
+      return (
+        this.isPermissionFetched(permissionName) &&
         this.$root.permissions.find((p) => p.name === permissionName).result
       );
     },
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index 69f347662..e7f1d1e25 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -30,6 +30,7 @@ const routes = [
       invalidate: "leave",
     },
   },
+  // TODO: Use rule checking (maybe) and add invitation code to URL
   {
     path: "/accounts/signup/",
     name: "core.accounts.signup",
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index d1641fa09..bcaef14e5 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -1436,6 +1436,7 @@ class PersonInvitation(AbstractBaseInvitation, PureDjangoModel):
 
     def send_invitation(self, request, **kwargs):
         """Send the invitation email to the person."""
+        # TODO: Use correct URL to new signup wizard
         invite_url = reverse("invitations:accept-invite", args=[self.key])
         invite_url = request.build_absolute_uri(invite_url).replace("/django", "")
         context = kwargs
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index 405797868..c1bde1498 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -402,7 +402,7 @@ edit_default_dashboard_predicate = has_person & has_global_perm("core.edit_defau
 rules.add_perm("core.edit_default_dashboard_rule", edit_default_dashboard_predicate)
 
 # django-allauth
-signup_predicate = is_site_preference_set(section="auth", pref="signup_enabled") | (is_site_preference_set(section="auth", pref="signup_enabled") & is_invitation_code_in_session )
+signup_predicate = is_site_preference_set(section="auth", pref="signup_enabled")
 rules.add_perm("core.signup_rule", signup_predicate)
 
 change_password_predicate = has_person & is_site_preference_set(
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index 5b6fdf711..b515c321f 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -26,6 +26,7 @@ from ..models import (
     OAuthApplication,
     PDFFile,
     Person,
+    PersonInvitation,
     Role,
     Room,
     SchoolTerm,
@@ -89,6 +90,7 @@ from .person import (
     PersonType,
     SendAccountRegistrationMutation,
 )
+from .person_invitation import PersonInvitationType
 from .personal_event import (
     PersonalEventBatchCreateMutation,
     PersonalEventBatchDeleteMutation,
@@ -193,6 +195,8 @@ class Query(graphene.ObjectType):
 
     countries = graphene.List(CountryType)
 
+    person_invitation_by_code = graphene.Field(PersonInvitationType, code=graphene.String())
+
     def resolve_ping(root, info, payload) -> str:
         return payload
 
@@ -474,6 +478,13 @@ class Query(graphene.ObjectType):
     def resolve_countries(root, info, **kwargs):
         return countries
 
+    @staticmethod
+    def resolve_person_invitation_by_code(root, info, code, **kwargs):
+        formatted_code = "".join(code.lower().split("-"))
+        if PersonInvitation.objects.filter(key=formatted_code).exists():
+            return PersonInvitation.objects.get(key=formatted_code)
+        return None
+
 
 class Mutation(graphene.ObjectType):
     delete_persons = PersonBatchDeleteMutation.Field()
diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py
index 09fdc0a09..1998ac20f 100644
--- a/aleksis/core/schema/person.py
+++ b/aleksis/core/schema/person.py
@@ -12,6 +12,7 @@ import graphene
 import graphene_django_optimizer
 from graphene_django import DjangoObjectType
 from graphene_file_upload.scalars import Upload
+from invitations.views import accept_invitation
 
 from ..filters import PersonFilter
 from ..models import (
@@ -585,6 +586,7 @@ class AccountRegistrationInputType(graphene.InputObjectType):
 
     person = graphene.Field(PersonInputType, required=True)
     user = graphene.Field(UserInputType, required=True)
+    invitation_code = graphene.String(required=False)
 
 
 class SendAccountRegistrationMutation(PersonAddressMutationMixin, graphene.Mutation):
@@ -596,15 +598,21 @@ class SendAccountRegistrationMutation(PersonAddressMutationMixin, graphene.Mutat
     @classmethod
     @transaction.atomic
     def mutate(cls, root, info, account_registration: AccountRegistrationInputType):
-        invitation_code = info.context.session.get("invitation_code")
-
-        if not get_site_preferences()["auth__signup_enabled"] and not (get_site_preferences()["auth__invite_enabled"] and invitation_code):
+        if account_registration["invitation_code"]:
+            try:
+                invitation = PersonInvitation.objects.get(key=account_registration["invitation_code"])
+            except PersonInvitation.DoesNotExist as exc:
+                raise SuspiciousOperation from exc
+        
+        if not get_site_preferences()["auth__signup_enabled"] and not (get_site_preferences()["auth__invite_enabled"] and invitation):
             raise PermissionDenied(_("Signup is not enabled."))
 
         # Create email
         email = None
 
-        if "postbuero" in [app.label for app in apps.get_app_configs() if isinstance(app, AppConfig)] and account_registration["email"] is not None:
+        if invitation and invitation.email:
+            email = invitation.email
+        elif "postbuero" in [app.label for app in apps.get_app_configs() if isinstance(app, AppConfig)] and account_registration["email"] is not None:
             try:
                 domain = MailDomain.objects.get(pk=account_registration["email"]["domain"])
             except IntegrityError:
@@ -631,49 +639,47 @@ class SendAccountRegistrationMutation(PersonAddressMutationMixin, graphene.Mutat
         except IntegrityError:
             raise ValidationError(_("A user with this username or e-mail already exists."))
 
-        # Create person
-        try:
-            person, created = Person.objects.get_or_create(
-                user=user,
-                defaults={
-                    "email": email,
-                    "first_name": account_registration["person"]["first_name"],
-                    "last_name": account_registration["person"]["last_name"],
-                },
-            )
-        except IntegrityError:
-            raise ValidationError(
-                _("A person using the e-mail address %s already exists.")
-                % email
-            )
+        # Create person if no invitation is given or if invitation isn't linked to a person
+        if invitation and invitation.person:
+            person = invitation.person
+            person.user = user
+            person.save()
+        else:
+            try:
+                person, created = Person.objects.get_or_create(
+                    user=user,
+                    defaults={
+                        "email": email,
+                        "first_name": account_registration["person"]["first_name"],
+                        "last_name": account_registration["person"]["last_name"],
+                    },
+                )
+            except IntegrityError:
+                raise ValidationError(
+                    _("A person using the e-mail address %s already exists.")
+                    % email
+                )
 
-        # Store contact information in database
-        for field in Person._meta.get_fields():
-            if (
-                field.name in account_registration["person"]
-                and account_registration["person"][field.name] is not None
-                and account_registration["person"][field.name] != ""
-            ):
-                setattr(person, field.name, account_registration["person"][field.name])
-        person.save()
+            # Store contact information in database
+            for field in Person._meta.get_fields():
+                if (
+                    field.name in account_registration["person"]
+                    and account_registration["person"][field.name] is not None
+                    and account_registration["person"][field.name] != ""
+                ):
+                    setattr(person, field.name, account_registration["person"][field.name])
+            person.save()
 
-        # Store address information
-        cls._handle_address(root, info, account_registration["person"], person)
+            # Store address information
+            cls._handle_address(root, info, account_registration["person"], person)
 
         # Link person to postbuero mail address, if created
-        if "postbuero" in [app.label for app in apps.get_app_configs() if isinstance(app, AppConfig)] and account_registration["email"] is not None:
+        if _mail_address:
             _mail_address.person = person
             _mail_address.save()
 
         # Accept invitation, if exists
-        if invitation_code:
-            from invitations.views import accept_invitation  # noqa
-
-            try:
-                invitation = PersonInvitation.objects.get(key=invitation_code)
-            except PersonInvitation.DoesNotExist as exc:
-                raise SuspiciousOperation from exc
-
+        if invitation:
             accept_invitation(invitation, info.context, info.context.user)
 
         _act = Activity(
diff --git a/aleksis/core/schema/person_invitation.py b/aleksis/core/schema/person_invitation.py
new file mode 100644
index 000000000..a6abf77ea
--- /dev/null
+++ b/aleksis/core/schema/person_invitation.py
@@ -0,0 +1,28 @@
+import graphene
+from graphene_django import DjangoObjectType
+
+from ..models import PersonInvitation
+
+
+class PersonInvitationType(DjangoObjectType):
+    class Meta:
+        model = PersonInvitation
+        fields = [
+            "id",
+        ]
+
+    valid = graphene.Boolean()
+    has_email = graphene.Boolean()
+    has_person = graphene.Boolean()
+
+    @staticmethod
+    def resolve_valid(root, info, **kwargs):
+        return not root.accepted and not root.key_expired()
+
+    @staticmethod
+    def resolve_has_email(root, info, **kwargs):
+        return bool(root.email)
+
+    @staticmethod
+    def resolve_has_person(root, info, **kwargs):
+        return bool(root.person)
diff --git a/aleksis/core/schema/user.py b/aleksis/core/schema/user.py
index 13fbbc4e6..c3d875c92 100644
--- a/aleksis/core/schema/user.py
+++ b/aleksis/core/schema/user.py
@@ -21,8 +21,6 @@ class UserType(graphene.ObjectType):
     )
 
     def resolve_global_permissions_by_name(root, info, permissions, **kwargs):
-        if root.is_anonymous:
-            return [{"name": permission_name, "result": False} for permission_name in permissions]
         return [
             {"name": permission_name, "result": info.context.user.has_perm(permission_name)}
             for permission_name in permissions
-- 
GitLab