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