diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b50afddd47a7d1404429b2072c60c4d8e6752321..a2ba01c643c8279096a52bd391253c70577d0fc2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,7 @@ Changed * DialogObjectForm is now slightly wider. * [Dev] DialogObjectForm now implicitly handles a missing ``isCreate`` prop depending on the presence of ``editItem``. * [Dev] DeleteDialog supports the activator slot now. +* The account registration and invitation code forms were migrated to the new frontend. Fixed ~~~~~ diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index e89d594045c2444fc2423b1400667c26b7fef930..e0a52c461cce0d61065f78350f6f5732bef26518 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -2,16 +2,12 @@ from collections.abc import Sequence from typing import Any, Callable from django import forms -from django.conf import settings from django.contrib.auth.models import Permission -from django.core.exceptions import SuspiciousOperation, ValidationError +from django.core.exceptions import ValidationError from django.db.models import QuerySet from django.http import HttpRequest 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 django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget from dynamic_preferences.forms import PreferenceForm from guardian.shortcuts import assign_perm @@ -31,7 +27,7 @@ from .registries import ( person_preferences_registry, site_preferences_registry, ) -from .util.core_helpers import get_site_preferences, queryset_rules_filter +from .util.core_helpers import queryset_rules_filter class EditGroupForm(SchoolTermRelatedExtensibleForm): @@ -128,15 +124,6 @@ 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."), - ) - - class PersonCreateInviteForm(InviteForm): """Custom form to create a person and invite them.""" @@ -277,127 +264,6 @@ class AssignPermissionForm(forms.Form): assign_perm(permission_name, django_group, instance) -class AccountRegisterForm(SignupForm, ExtensibleForm): - """Form to register new user accounts.""" - - class Meta: - model = Person - fields = [ - "first_name", - "additional_name", - "last_name", - "addresses", - "date_of_birth", - "place_of_birth", - "sex", - "photo", - "mobile_number", - "phone_number", - "short_name", - "description", - ] - - layout = Layout( - Fieldset( - _("Base data"), - Row("first_name", "additional_name", "last_name"), - "short_name", - ), - Fieldset( - _("Address data"), - Row("addresses"), - ), - 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"), - "username", - Row("email", "email2"), - Row("password1", "password2"), - ), - ) - - password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput) - - if settings.SIGNUP_PASSWORD_ENTER_TWICE: - password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput) - - def __init__(self, *args, **kwargs): - request = kwargs.pop("request", None) - super().__init__(*args, **kwargs) - - person = None - if request.session.get("account_verified_email"): - email = request.session["account_verified_email"] - - try: - person = Person.objects.get(email=email) - except (Person.DoesNotExist, Person.MultipleObjectsReturned) as exc: - raise SuspiciousOperation from exc - - elif request.session.get("invitation_code"): - try: - invitation = PersonInvitation.objects.get( - key=request.session.get("invitation_code") - ) - except PersonInvitation.DoesNotExist as exc: - raise SuspiciousOperation from exc - - person = invitation.person - - if person: - self.instance = person - available_fields = [field.name for field in Person._meta.get_fields()] - if person.email: - self.fields["email"].disabled = True - self.fields["email2"].disabled = True - self.fields["email2"].initial = person.email - 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) - # 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 self.instance: - person_qs = Person.objects.filter(pk=self.instance.pk) - else: - person_qs = Person.objects.filter(email=data["email"]) - if not person_qs.exists() and get_site_preferences()["account__auto_create_person"]: - Person.objects.create(user=user, **data) - if person_qs.exists(): - person = person_qs.first() - for field, value in data.items(): - setattr(person, field, value) - person.user = user - person.save() - invitation_code = request.session.get("invitation_code") - 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 - - accept_invitation(invitation, request, user) - self.custom_signup(request, user) - setup_user_email(request, user, []) - return user - - class ActionForm(forms.Form): """Generic form for executing actions on multiple items of a queryset. diff --git a/aleksis/core/frontend/collections.js b/aleksis/core/frontend/collections.js index 98cf666d98980464b8bd66393b62db26cb73025c..a310de146027acfbc9dc597f04b81aaeb0e59cb6 100644 --- a/aleksis/core/frontend/collections.js +++ b/aleksis/core/frontend/collections.js @@ -21,6 +21,14 @@ export const collections = [ name: "personWidgets", type: Object, }, + { + name: "accountRegistrationSteps", + type: Object, + }, + { + name: "accountRegistrationExtraMutations", + type: Object, + }, ]; export const collectionItems = { diff --git a/aleksis/core/frontend/components/account/AccountRegistrationForm.vue b/aleksis/core/frontend/components/account/AccountRegistrationForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..8b10aa463a5562220b150da48932c46f35ccca64 --- /dev/null +++ b/aleksis/core/frontend/components/account/AccountRegistrationForm.vue @@ -0,0 +1,1049 @@ +<script setup> +import ControlRow from "../generic/multi_step/ControlRow.vue"; +import DateField from "../generic/forms/DateField.vue"; +import FileField from "../generic/forms/FileField.vue"; +import SexSelect from "../generic/forms/SexSelect.vue"; +import CountryField from "../generic/forms/CountryField.vue"; +import PasswordField from "../generic/forms/PasswordField.vue"; + +import PrimaryActionButton from "../generic/buttons/PrimaryActionButton.vue"; + +import PersonDetailsCard from "../person/PersonDetailsCard.vue"; +</script> + +<template> + <div> + <div v-if="accountRegistrationSent"> + <v-card> + <v-card-title> + <v-icon class="mr-2" color="success">mdi-check-circle-outline</v-icon> + {{ $t("accounts.signup.form.submitted.title") }} + </v-card-title> + <v-card-text class="text-body-1 black--text"> + {{ $t("accounts.signup.form.submitted.submitted_successfully") }} + </v-card-text> + <v-card-actions> + <primary-action-button + :to="{ name: 'core.account.login' }" + i18n-key="accounts.signup.form.login_button" + /> + </v-card-actions> + </v-card> + </div> + <div + v-else-if=" + checkPermission('core.invite_enabled') && !invitationCodeEntered + " + > + <v-card> + <v-card-title> + {{ $t("accounts.signup.form.steps.invitation.title") }} + </v-card-title> + <v-card-text> + <v-alert + v-if="invitationCodeAutofilled" + type="info" + outlined + class="mb-4" + >{{ + $t("accounts.signup.form.steps.invitation.autofilled") + }}</v-alert + > + <div class="mb-4"> + <v-form v-model="invitationCodeValidationStatus"> + <div :aria-required="invitationCodeRequired"> + <v-text-field + outlined + v-model="data.accountRegistration.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> + </div> + <v-alert + v-if="invitationCodeInvalid" + type="error" + outlined + class="mb-4" + >{{ + $t("accounts.signup.form.steps.invitation.not_valid") + }}</v-alert + > + </v-card-text> + <v-card-actions> + <v-spacer /> + <primary-action-button + @click="checkInvitationCode" + :i18n-key="invitationNextI18nKey" + :disabled="!invitationCodeValidationStatus" + /> + </v-card-actions> + </v-card> + </div> + <div v-else> + <v-alert type="info" dense outlined class="mb-4"> + <v-row align="center" no-gutters> + <v-col cols="12" md="9"> + {{ $t("accounts.signup.form.existing_account_alert") }} + </v-col> + <v-col cols="12" md="3" align="right"> + <v-btn + color="info" + outlined + small + :to="{ name: 'core.account.login' }" + > + {{ $t("accounts.signup.form.login_button") }} + </v-btn> + </v-col> + </v-row> + </v-alert> + <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 + :complete="step > index + 1" + :step="index + 1" + :key="`${index}-step`" + :ref="`step-${index}`" + > + {{ $t(stepChoice.titleKey) }} + </v-stepper-step> + <v-divider + v-if="index + 1 < steps.length" + :key="`${index}-divider`" + ></v-divider> + </template> + </v-stepper-header> + <v-stepper-items> + <v-stepper-content + v-if="isStepEnabled('email')" + :step="getStepIndex('email')" + > + <h2 class="text-h6 mb-4">{{ $t(getStepTitleKey("email")) }}</h2> + <div class="mb-4"> + <!-- TODO: Optional email fields when using injected component --> + <component + v-if="stepOverwrittenByInjection('email')" + :is="collectionSteps.find((s) => s.key === 'email')?.component" + @dataChange="mergeIncomingData" + v-model="validationStatuses['email']" + /> + <v-form v-else v-model="validationStatuses['email']"> + <v-row class="mt-4"> + <v-col cols="12" md="6"> + <div :aria-required="isFieldRequired('email')"> + <v-text-field + outlined + v-model="data.accountRegistration.user.email" + :label=" + $t( + 'accounts.signup.form.steps.email.fields.email.label', + ) + " + required + :rules=" + isFieldRequired('email') + ? $rules().required.isEmail.build() + : $rules().isEmail.build() + " + prepend-icon="mdi-email-outline" + ></v-text-field> + </div> + </v-col> + <v-col cols="12" md="6"> + <div :aria-required="isFieldRequired('email')"> + <v-text-field + outlined + v-model="confirmFields.email" + :label=" + $t( + 'accounts.signup.form.steps.email.fields.confirm_email.label', + ) + " + required + :rules=" + isFieldRequired('email') + ? $rules().required.build(rules.confirmEmail) + : rules.confirmEmail + " + prepend-icon="mdi-email-outline" + ></v-text-field> + </div> + </v-col> + </v-row> + </v-form> + </div> + <v-divider class="mb-4" /> + <control-row + :step="step" + @set-step="setStep" + :next-disabled="!validationStatuses['email']" + /> + </v-stepper-content> + + <v-stepper-content :step="getStepIndex('account')"> + <h2 class="text-h6 mb-4">{{ $t(getStepTitleKey("account")) }}</h2> + <div class="mb-4"> + <v-form v-model="validationStatuses['account']"> + <v-row> + <v-col cols="12"> + <div aria-required="true"> + <v-text-field + outlined + v-model="data.accountRegistration.user.username" + :label=" + $t( + 'accounts.signup.form.steps.account.fields.username.label', + ) + " + required + :rules=" + $rules().required.build([ + ...usernameRules.usernameAllowed, + ...usernameRules.usernameASCII, + ]) + " + prepend-icon="mdi-account-outline" + ></v-text-field> + </div> + </v-col> + </v-row> + <v-row> + <v-col cols="12" md="6"> + <div aria-required="true"> + <password-field + outlined + v-model="data.accountRegistration.user.password" + :label=" + $t( + 'accounts.signup.form.steps.account.fields.password.label', + ) + " + required + :rules="$rules().required.build()" + prepend-icon="mdi-form-textbox-password" + /> + </div> + </v-col> + <v-col cols="12" md="6"> + <div aria-required="true"> + <v-text-field + outlined + v-model="confirmFields.password" + :label=" + $t( + 'accounts.signup.form.steps.account.fields.confirm_password.label', + ) + " + required + :rules="$rules().required.build(rules.confirmPassword)" + type="password" + prepend-icon="mdi-form-textbox-password" + ></v-text-field> + </div> + </v-col> + </v-row> + </v-form> + </div> + <v-divider class="mb-4" /> + <control-row + :step="step" + @set-step="setStep" + :next-disabled="!validationStatuses['account']" + /> + </v-stepper-content> + + <v-stepper-content :step="getStepIndex('base_data')"> + <h2 class="text-h6 mb-4"> + {{ $t(getStepTitleKey("base_data")) }} + </h2> + <div class="mb-4"> + <v-form v-model="validationStatuses['base_data']"> + <v-row> + <v-col> + <div aria-required="true"> + <v-text-field + outlined + v-model="data.accountRegistration.person.firstName" + :label=" + $t( + 'accounts.signup.form.steps.base_data.fields.first_name.label', + ) + " + required + :rules="$rules().required.build()" + ></v-text-field> + </div> + </v-col> + <v-col v-if="isFieldVisible('additional_name')"> + <div :aria-required="isFieldRequired('additional_name')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.additionalName" + :label=" + $t( + 'accounts.signup.form.steps.base_data.fields.additional_name.label', + ) + " + required + :rules=" + isFieldRequired('additional_name') + ? $rules().required.build() + : [] + " + ></v-text-field> + </div> + </v-col> + <v-col> + <div aria-required="true"> + <v-text-field + outlined + v-model="data.accountRegistration.person.lastName" + :label=" + $t( + 'accounts.signup.form.steps.base_data.fields.last_name.label', + ) + " + required + :rules="$rules().required.build()" + ></v-text-field> + </div> + </v-col> + </v-row> + </v-form> + </div> + <v-divider class="mb-4" /> + <control-row + :step="step" + @set-step="setStep" + :next-disabled="!validationStatuses['base_data']" + /> + </v-stepper-content> + + <v-stepper-content :step="getStepIndex('address_data')"> + <h2 class="text-h6 mb-4"> + {{ $t(getStepTitleKey("address_data")) }} + </h2> + <div class="mb-4"> + <v-form v-model="validationStatuses['address_data']"> + <v-row> + <v-col cols="12" lg="6" v-if="isFieldVisible('street')"> + <div :aria-required="isFieldRequired('street')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.street" + :label=" + $t( + 'accounts.signup.form.steps.address_data.fields.street.label', + ) + " + required + :rules=" + isFieldRequired('street') + ? $rules().required.build() + : [] + " + ></v-text-field> + </div> + </v-col> + <v-col cols="12" lg="6" v-if="isFieldVisible('housenumber')"> + <div :aria-required="isFieldRequired('housenumber')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.housenumber" + :label=" + $t( + 'accounts.signup.form.steps.address_data.fields.housenumber.label', + ) + " + required + :rules=" + isFieldRequired('housenumber') + ? $rules().required.build() + : [] + " + ></v-text-field> + </div> + </v-col> + </v-row> + <v-row> + <v-col cols="12" lg="4" v-if="isFieldVisible('postal_code')"> + <div :aria-required="isFieldRequired('postal_code')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.postalCode" + :label=" + $t( + 'accounts.signup.form.steps.address_data.fields.postal_code.label', + ) + " + required + :rules=" + isFieldRequired('postal_code') + ? $rules().required.build() + : [] + " + ></v-text-field> + </div> + </v-col> + <v-col cols="12" lg="4" v-if="isFieldVisible('place')"> + <div :aria-required="isFieldRequired('place')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.place" + :label=" + $t( + 'accounts.signup.form.steps.address_data.fields.place.label', + ) + " + required + :rules=" + isFieldRequired('place') + ? $rules().required.build() + : [] + " + ></v-text-field> + </div> + </v-col> + <v-col cols="12" lg="4" v-if="isFieldVisible('country')"> + <div :aria-required="isFieldRequired('country')"> + <country-field + outlined + v-model="data.accountRegistration.person.country" + :label=" + $t( + 'accounts.signup.form.steps.address_data.fields.country.label', + ) + " + required + :rules=" + isFieldRequired('country') + ? $rules().required.build() + : [] + " + /> + </div> + </v-col> + </v-row> + </v-form> + </div> + <v-divider class="mb-4" /> + <control-row + :step="step" + @set-step="setStep" + :next-disabled="!validationStatuses['address_data']" + /> + </v-stepper-content> + + <v-stepper-content :step="getStepIndex('contact_data')"> + <h2 class="text-h6 mb-4"> + {{ $t(getStepTitleKey("contact_data")) }} + </h2> + <div class="mb-4"> + <v-form v-model="validationStatuses['contact_data']"> + <v-row> + <v-col + cols="12" + md="6" + v-if="isFieldVisible('mobile_number')" + > + <div :aria-required="isFieldRequired('mobile_number')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.mobileNumber" + :label=" + $t( + 'accounts.signup.form.steps.contact_data.fields.mobile_number.label', + ) + " + required + prepend-icon="mdi-cellphone-basic" + :rules=" + isFieldRequired('mobile_number') + ? $rules().required.build() + : [] + " + ></v-text-field> + </div> + </v-col> + <v-col cols="12" md="6" v-if="isFieldVisible('phone_number')"> + <div :aria-required="isFieldRequired('phone_number')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.phoneNumber" + :label=" + $t( + 'accounts.signup.form.steps.contact_data.fields.phone_number.label', + ) + " + required + prepend-icon="mdi-phone-outline" + :rules=" + isFieldRequired('phone_number') + ? $rules().required.build() + : [] + " + ></v-text-field> + </div> + </v-col> + </v-row> + </v-form> + </div> + <v-divider class="mb-4" /> + <control-row + :step="step" + @set-step="setStep" + :next-disabled="!validationStatuses['contact_data']" + /> + </v-stepper-content> + + <v-stepper-content :step="getStepIndex('additional_data')"> + <h2 class="text-h6 mb-4"> + {{ $t(getStepTitleKey("additional_data")) }} + </h2> + <div class="mb-4"> + <v-form v-model="validationStatuses['additional_data']"> + <v-row> + <v-col + cols="12" + md="6" + v-if="isFieldVisible('date_of_birth')" + > + <div :aria-required="isFieldRequired('date_of_birth')"> + <date-field + outlined + v-model="data.accountRegistration.person.dateOfBirth" + :label=" + $t( + 'accounts.signup.form.steps.additional_data.fields.date_of_birth.label', + ) + " + required + :rules=" + isFieldRequired('date_of_birth') + ? $rules().required.build() + : [] + " + prepend-icon="mdi-cake-variant-outline" + /> + </div> + </v-col> + <v-col + cols="12" + md="6" + v-if="isFieldVisible('place_of_birth')" + > + <div :aria-required="isFieldRequired('place_of_birth')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.placeOfBirth" + :label=" + $t( + 'accounts.signup.form.steps.additional_data.fields.place_of_birth.label', + ) + " + required + :rules=" + isFieldRequired('place_of_birth') + ? $rules().required.build() + : [] + " + /> + </div> + </v-col> + </v-row> + <v-row> + <v-col cols="12" md="6" v-if="isFieldVisible('sex')"> + <div :aria-required="isFieldRequired('sex')"> + <sex-select + outlined + v-model="data.accountRegistration.person.sex" + :label=" + $t( + 'accounts.signup.form.steps.additional_data.fields.sex.label', + ) + " + required + :rules=" + isFieldRequired('sex') + ? $rules().required.build() + : [] + " + /> + </div> + </v-col> + <v-col cols="12" md="6" v-if="isFieldVisible('photo')"> + <div :aria-required="isFieldRequired('photo')"> + <file-field + outlined + v-model="data.accountRegistration.person.photo" + accept="image/jpeg, image/png" + :label=" + $t( + 'accounts.signup.form.steps.additional_data.fields.photo.label', + ) + " + required + :rules=" + isFieldRequired('photo') + ? $rules().required.build() + : [] + " + /> + </div> + </v-col> + </v-row> + <v-row> + <v-col cols="12" v-if="isFieldVisible('description')"> + <div :aria-required="isFieldRequired('description')"> + <v-text-field + outlined + v-model="data.accountRegistration.person.description" + :label=" + $t( + 'accounts.signup.form.steps.additional_data.fields.description.label', + ) + " + required + :rules=" + isFieldRequired('description') + ? $rules().required.build() + : [] + " + /> + </div> + </v-col> + </v-row> + </v-form> + </div> + <v-divider class="mb-4" /> + <control-row + :step="step" + @set-step="setStep" + :next-disabled="!validationStatuses['additional_data']" + /> + </v-stepper-content> + + <v-stepper-content :step="getStepIndex('confirm')"> + <h2 class="text-h6 mb-4">{{ $t(getStepTitleKey("confirm")) }}</h2> + <v-alert + v-if="invitation && (invitation.hasEmail || invitation.hasPerson)" + type="info" + outlined + class="mb-4" + >{{ + $t("accounts.signup.form.steps.confirm.invitation_used") + }}</v-alert + > + <person-details-card + class="mb-4" + :person="personDataForSummary" + :show-username="true" + :show-when-empty="false" + title-key="accounts.signup.form.steps.confirm.card_title" + /> + + <div + v-if="systemProperties.sitePreferences.footerPrivacyUrl" + aria-required="true" + class="mb-4" + > + <v-checkbox required v-model="privacyPolicyAccepted"> + <template #label> + <i18n + path="accounts.signup.form.steps.confirm.privacy_policy.label" + tag="div" + > + <template #url> + <a + @click.stop + :href=" + systemProperties.sitePreferences.footerPrivacyUrl + " + target="_blank" + >{{ + $t( + "accounts.signup.form.steps.confirm.privacy_policy.url_text", + ) + }}</a + > + </template> + </i18n> + </template> + </v-checkbox> + </div> + + <ApolloMutation + :mutation="combinedMutation" + :variables="data" + @done="accountRegistrationDone" + > + <template #default="{ mutate, loading, error }"> + <control-row + :step="step" + final-step + @set-step="setStep" + @confirm="mutate" + :next-loading="loading" + :next-disabled="disableConfirm" + /> + <v-alert v-if="error" type="error" outlined class="mt-4">{{ + error.message + }}</v-alert> + </template> + </ApolloMutation> + </v-stepper-content> + </v-stepper-items> + </v-stepper> + </div> + </div> +</template> + +<script> +import { + gqlAccountWizardSystemProperties, + gqlPersonInvitationByCode, +} from "./helpers.graphql"; +import { sendAccountRegistration } from "./accountRegistrationMutation.graphql"; +import { collections } from "aleksisAppImporter"; + +import formRulesMixin from "../../mixins/formRulesMixin"; +import permissionsMixin from "../../mixins/permissions"; +import usernameRulesMixin from "../../mixins/usernameRulesMixin"; + +import combineQuery from "@/graphql-combine-query"; + +export default { + name: "AccountRegistrationForm", + apollo: { + systemProperties: { + query: gqlAccountWizardSystemProperties, + }, + personInvitationByCode: { + query: gqlPersonInvitationByCode, + variables() { + return { + code: this.data.accountRegistration.invitationCode, + }; + }, + result({ data, loading, networkStatus }) { + if (data?.personInvitationByCode?.valid) { + this.invitation = data.personInvitationByCode; + this.invitationCodeEntered = true; + } else { + this.invitationCodeInvalid = true; + } + }, + skip: true, + }, + }, + mixins: [formRulesMixin, permissionsMixin, usernameRulesMixin], + methods: { + stepOverwrittenByInjection(step) { + return this.collectionSteps.some((s) => s.key === step); + }, + setStep(step) { + this.step = step; + this.valid = false; + }, + checkInvitationCode() { + this.invitationCodeInvalid = false; + if (this.data.accountRegistration.invitationCode) { + this.$apollo.queries.personInvitationByCode.skip = false; + this.$apollo.queries.personInvitationByCode.refetch(); + } else { + this.invitationCodeEntered = true; + } + }, + accountRegistrationDone({ data }) { + if (data.sendAccountRegistration.ok) { + this.accountRegistrationSent = true; + } + }, + isFieldRequired(fieldName) { + return ( + this?.systemProperties?.sitePreferences?.signupRequiredFields?.includes( + fieldName, + ) || + this?.systemProperties?.sitePreferences?.signupAddressRequiredFields?.includes( + fieldName, + ) + ); + }, + isFieldVisible(fieldName) { + return ( + this?.systemProperties?.sitePreferences?.signupVisibleFields?.includes( + fieldName, + ) || + this?.systemProperties?.sitePreferences?.signupAddressVisibleFields?.includes( + fieldName, + ) + ); + }, + isStepEnabled(stepName) { + return this.steps.some((s) => s.name === stepName); + }, + getStepIndex(stepName) { + return this.steps.findIndex((s) => s.name === stepName) + 1; + }, + getStepTitleKey(stepName) { + return this.steps.find((s) => s.name === stepName)?.titleKey; + }, + setValidationStatus(stepName, validationStatus) { + this.validationStatuses[stepName] = validationStatus; + }, + getValidationStatus(stepName) { + if (this.validationStatuses[stepName]) { + return this.validationStatuses[stepName]; + } + return false; + }, + deepMerge(existing, incoming) { + return Object.entries(incoming).reduce( + (merged, [key, value]) => { + if (typeof value === "object") { + if (Array.isArray(value)) { + merged[key] = this.deepMerge(existing[key] || [], value); + } else { + merged[key] = this.deepMerge(existing[key] || [], value); + } + } else { + merged[key] = value; + } + return merged; + }, + { ...existing }, + ); + }, + mergeIncomingData(incomingData) { + this.data = this.deepMerge(this.data, incomingData); + }, + }, + computed: { + rules() { + return { + confirmPassword: [ + (v) => + this.data.accountRegistration.user.password == v || + this.$t("accounts.signup.form.rules.confirm_password.no_match"), + ], + confirmEmail: [ + (v) => + this.data.accountRegistration.user.email == v || + this.$t("accounts.signup.form.rules.confirm_email.no_match"), + ], + }; + }, + personDataForSummary() { + return { + ...this.data.accountRegistration.person, + addresses: [ + { + street: this.data.accountRegistration.person.street, + housenumber: this.data.accountRegistration.person.housenumber, + postalCode: this.data.accountRegistration.person.postalCode, + place: this.data.accountRegistration.person.place, + country: this.data.accountRegistration.person.country, + }, + ], + username: this.data.accountRegistration.user.username, + email: this.data.accountRegistration.user.email, + }; + }, + steps() { + return [ + ...(!this.invitation?.hasEmail && this.isFieldVisible("email") + ? [ + { + name: "email", + titleKey: "accounts.signup.form.steps.email.title", + }, + ] + : []), + { + name: "account", + titleKey: "accounts.signup.form.steps.account.title", + }, + ...(!this.invitation?.hasPerson + ? [ + { + name: "base_data", + titleKey: "accounts.signup.form.steps.base_data.title", + }, + ] + : []), + ...(this.isFieldVisible("street") | + this.isFieldVisible("housenumber") | + this.isFieldVisible("postal_code") | + this.isFieldVisible("place") | + this.isFieldVisible("country") + ? [ + { + name: "address_data", + titleKey: "accounts.signup.form.steps.address_data.title", + }, + ] + : []), + ...(this.isFieldVisible("mobile_number") | + this.isFieldVisible("phone_number") + ? [ + { + name: "contact_data", + titleKey: "accounts.signup.form.steps.contact_data.title", + }, + ] + : []), + ...(this.isFieldVisible("date_of_birth") | + this.isFieldVisible("place_of_birth") | + this.isFieldVisible("sex") | + this.isFieldVisible("photo") | + this.isFieldVisible("description") + ? [ + { + name: "additional_data", + titleKey: "accounts.signup.form.steps.additional_data.title", + }, + ] + : []), + { + name: "confirm", + titleKey: "accounts.signup.form.steps.confirm.title", + }, + ]; + }, + collectionSteps() { + if (Object.hasOwn(collections, "coreAccountRegistrationSteps")) { + return collections.coreAccountRegistrationSteps.items; + } + return []; + }, + collectionExtraMutations() { + if (Object.hasOwn(collections, "coreAccountRegistrationExtraMutations")) { + return collections.coreAccountRegistrationExtraMutations.items; + } + return []; + }, + invitationNextI18nKey() { + return this.data.accountRegistration.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") + ); + }, + combinedMutation() { + let combinedQuery = combineQuery("combinedMutation").add( + sendAccountRegistration, + ); + + this.collectionExtraMutations.forEach((extraMutation) => { + if (Object.hasOwn(extraMutation, "mutation")) { + combinedQuery = combinedQuery.add(extraMutation.mutation); + } + }); + + const { document, variables } = combinedQuery; + + return document; + }, + disableConfirm() { + return ( + !!this?.systemProperties?.sitePreferences?.footerPrivacyUrl && + !this.privacyPolicyAccepted + ); + }, + }, + data() { + return { + validationStatuses: {}, + invitation: null, + invitationCodeEntered: false, + invitationCodeValidationStatus: false, + invitationCodeInvalid: false, + invitationCodeAutofilled: false, + accountRegistrationSent: false, + step: 1, + privacyPolicyAccepted: false, + confirmFields: { + email: "", + password: "", + }, + data: { + accountRegistration: { + person: { + firstName: "", + additionalName: "", + lastName: "", + shortName: "", + dateOfBirth: null, + placeOfBirth: "", + sex: "", + street: "", + housenumber: "", + postalCode: "", + place: "", + country: "", + mobileNumber: "", + phoneNumber: "", + description: "", + photo: null, + }, + user: { + username: "", + email: "", + password: "", + }, + invitationCode: "", + }, + }, + }; + }, + watch: { + step() { + const comp = this.$refs[`step-${this.step - 1}`][0]; + comp.$el.scrollIntoView(); + }, + }, + mounted() { + this.addPermissions(["core.signup_rule", "core.invite_enabled"]); + if (this.$route.query.invitation_code) { + this.data.accountRegistration.invitationCode = + this.$route.query.invitation_code; + this.invitationCodeAutofilled = true; + } + }, +}; +</script> + +<style> +.v-stepper__header { + overflow: auto; + display: flex; + flex-wrap: nowrap; + justify-content: left; +} +</style> diff --git a/aleksis/core/frontend/components/account/accountRegistrationMutation.graphql b/aleksis/core/frontend/components/account/accountRegistrationMutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..6a54aecd732d9f25b398010ced845f42860cbcc9 --- /dev/null +++ b/aleksis/core/frontend/components/account/accountRegistrationMutation.graphql @@ -0,0 +1,7 @@ +mutation sendAccountRegistration( + $accountRegistration: AccountRegistrationInputType! +) { + sendAccountRegistration(accountRegistration: $accountRegistration) { + ok + } +} diff --git a/aleksis/core/frontend/components/account/helpers.graphql b/aleksis/core/frontend/components/account/helpers.graphql new file mode 100644 index 0000000000000000000000000000000000000000..5491983d633b9367c127014325fcfc746e0a19ec --- /dev/null +++ b/aleksis/core/frontend/components/account/helpers.graphql @@ -0,0 +1,21 @@ +query gqlAccountWizardSystemProperties { + systemProperties { + sitePreferences { + signupRequiredFields + signupAddressRequiredFields + signupVisibleFields + signupAddressVisibleFields + + footerPrivacyUrl + } + } +} + +query gqlPersonInvitationByCode($code: String!) { + personInvitationByCode(code: $code) { + id + valid + hasEmail + hasPerson + } +} diff --git a/aleksis/core/frontend/components/app/systemProperties.graphql b/aleksis/core/frontend/components/app/systemProperties.graphql index 7fdd18002110bb5dfc351f56a024c4898bb5ca31..39d4d867852442392fb4776d9c0e78419a009cfe 100644 --- a/aleksis/core/frontend/components/app/systemProperties.graphql +++ b/aleksis/core/frontend/components/app/systemProperties.graphql @@ -23,12 +23,13 @@ query gqlSystemProperties { footerImprintUrl footerPrivacyUrl inviteEnabled + signupEnabled } } } -query gqlUsernamePreferences { - systemProperties { +query gqlUsernameSystemProperties { + usernameSystemProperties: systemProperties { sitePreferences { authAllowedUsernameRegex authDisallowedUids diff --git a/aleksis/core/frontend/components/generic/forms/PasswordField.vue b/aleksis/core/frontend/components/generic/forms/PasswordField.vue new file mode 100644 index 0000000000000000000000000000000000000000..f66c974209f701bf395c09444ee8234b8d743684 --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/PasswordField.vue @@ -0,0 +1,68 @@ +<template> + <v-text-field + v-bind="$attrs" + v-on="$listeners" + :rules="[...rules, ...passwordRules.passwordValidation]" + type="password" + :hint="passwordHelpTexts.join(' · ')" + persistent-hint + :loading="$apollo.queries.passwordValidationStatus.loading" + @change="refetchValidation" + validate-on-blur + ref="passwordField" + /> +</template> + +<script> +import { + gqlPasswordHelpTexts, + gqlPasswordValidationStatus, +} from "./password.graphql"; + +export default { + name: "PasswordField", + extends: "v-text-field", + data() { + return { + passwordHelpTexts: [], + passwordValidationStatus: [], + }; + }, + props: { + rules: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + passwordRules() { + return { + passwordValidation: [ + (v) => + this.passwordValidationStatus.length === 0 || + this.passwordValidationStatus.join(" · "), + ], + }; + }, + }, + apollo: { + passwordHelpTexts: gqlPasswordHelpTexts, + passwordValidationStatus: { + query: gqlPasswordValidationStatus, + skip: true, + result({ data, loading, networkStatus }) { + this.$refs.passwordField.validate(); + }, + }, + }, + methods: { + refetchValidation(password) { + this.$apollo.queries.passwordValidationStatus.setVariables({ + password: password, + }); + this.$apollo.queries.passwordValidationStatus.skip = false; + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/forms/password.graphql b/aleksis/core/frontend/components/generic/forms/password.graphql new file mode 100644 index 0000000000000000000000000000000000000000..0ea07b04b0e557e29651a72daa987ff220e785f1 --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/password.graphql @@ -0,0 +1,7 @@ +query gqlPasswordHelpTexts { + passwordHelpTexts +} + +query gqlPasswordValidationStatus($password: String!) { + passwordValidationStatus(password: $password) +} diff --git a/aleksis/core/frontend/components/generic/multi_step/ControlRow.vue b/aleksis/core/frontend/components/generic/multi_step/ControlRow.vue index fa81f4c4151e742c7507bcc392f846b470aacc69..ab90a1e4bef16bc7e52b69a3204aa5c567b79fe3 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/components/person/PersonDetailsCard.vue b/aleksis/core/frontend/components/person/PersonDetailsCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..6dc27dba818e04a91628ab07e8015894c84054e9 --- /dev/null +++ b/aleksis/core/frontend/components/person/PersonDetailsCard.vue @@ -0,0 +1,231 @@ +<!-- eslint-disable @intlify/vue-i18n/no-raw-text --> +<template> + <v-card v-bind="$attrs"> + <v-card-title>{{ $t(titleKey) }}</v-card-title> + + <v-list two-line> + <v-list-item + v-if=" + showWhenEmpty || + person.firstName || + person.additionalName || + person.lastName + " + > + <v-list-item-icon> + <v-icon>mdi-account-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ person.firstName }} + {{ person.additionalName }} + {{ person.lastName }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("person.name") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider inset /> + + <v-list-item v-if="showUsername"> + <v-list-item-icon> + <v-icon>mdi-login-variant</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ person.username }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("person.username") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider inset /> + + <v-list-item v-if="showWhenEmpty || person.sex"> + <v-list-item-icon> + <v-icon>mdi-human-non-binary</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ + person.sex ? $t(`person.sex.${person.sex.toLowerCase()}`) : "–" + }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("person.sex_description") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider inset /> + + <v-list-item + v-for="(address, index) in filteredAddresses" + :key="address.id" + > + <v-list-item-icon v-if="index === 0"> + <v-icon>mdi-map-marker-outline</v-icon> + </v-list-item-icon> + <v-list-item-action v-else /> + <v-list-item-content> + <v-list-item-title> + {{ address.street || "–" }} {{ address.housenumber }} + <span v-if="address.postalCode || address.place || address.country"> + , + </span> + <br v-if="address.postalCode || address.place" /> + {{ address.postalCode }} {{ address.place }} + <span + v-if="(address.postalCode || address.place) && address.country" + > + , + </span> + <br v-if="address.country" /> + {{ address.country }} + </v-list-item-title> + <v-list-item-subtitle + v-for="addresstype in address.addressTypes" + :key="addresstype.id" + > + {{ addresstype.name }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + + <v-list-item + v-if="showWhenEmpty || person.phoneNumber" + :href="person.phoneNumber ? 'tel:' + person.phoneNumber : ''" + > + <v-list-item-icon> + <v-icon> mdi-phone-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ person.phoneNumber || "–" }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("person.home") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + + <v-list-item + v-if="showWhenEmpty || person.mobileNumber" + :href="person.mobileNumber ? 'tel:' + person.mobileNumber : ''" + > + <v-list-item-action></v-list-item-action> + + <v-list-item-content> + <v-list-item-title> + {{ person.mobileNumber || "–" }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("person.mobile") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider inset /> + + <v-list-item + v-if="showWhenEmpty || person.email" + :href="person.email ? 'mailto:' + person.email : ''" + > + <v-list-item-icon> + <v-icon>mdi-email-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ person.email || "–" }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("person.email_address") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider inset /> + + <v-list-item + v-if="showWhenEmpty || person.dateOfBirth || person.placeOfBirth" + > + <v-list-item-icon> + <v-icon> mdi-cake-variant-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + <span v-if="person.dateOfBirth && person.placeOfBirth"> + {{ + $t("person.birth_date_and_birth_place_formatted", { + date: $d($parseISODate(person.dateOfBirth), "short"), + place: person.placeOfBirth, + }) + }} + </span> + <span v-else-if="person.dateOfBirth">{{ + $d($parseISODate(person.dateOfBirth), "short") + }}</span> + <span v-else-if="person.placeOfBirth">{{ + person.placeOfBirth + }}</span> + <span v-else>–</span> + </v-list-item-title> + <v-list-item-subtitle> + <span v-if="!person.dateOfBirth === !person.placeOfBirth"> + {{ $t("person.birth_date_and_birth_place") }} + </span> + <span v-else-if="person.dateOfBirth"> + {{ $t("person.birth_date") }} + </span> + <span v-else-if="person.placeOfBirth"> + {{ $t("person.birth_place") }} + </span> + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + </v-list> + </v-card> +</template> + +<script> +export default { + name: "PersonDetailsCard", + props: { + person: { + type: Object, + required: true, + }, + showUsername: { + type: Boolean, + required: false, + default: false, + }, + titleKey: { + type: String, + required: false, + default: "person.details", + }, + showWhenEmpty: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + filteredAddresses() { + if (this.showWhenEmpty) { + return this.person.addresses; + } + return this.person.addresses.filter( + (a) => + a.street || a.housenumber || a.postalCode || a.place || a.country, + ); + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/person/PersonOverview.vue b/aleksis/core/frontend/components/person/PersonOverview.vue index 326fac4704c1ecd67bf4ad725b4f660ff56b4154..6ce12c6764a81fd4e2738077411fb2429fe6e6bb 100644 --- a/aleksis/core/frontend/components/person/PersonOverview.vue +++ b/aleksis/core/frontend/components/person/PersonOverview.vue @@ -1,4 +1,3 @@ -<!-- eslint-disable @intlify/vue-i18n/no-raw-text --> <template> <object-overview :query="query" title-attr="fullName" :id="id" ref="overview"> <template #loading> @@ -48,184 +47,7 @@ <v-row> <v-col cols="12" lg="4"> - <v-card class="mb-6"> - <v-card-title>{{ $t("person.details") }}</v-card-title> - - <v-list two-line> - <v-list-item> - <v-list-item-icon> - <v-icon> mdi-account-outline</v-icon> - </v-list-item-icon> - - <v-list-item-content> - <v-list-item-title> - {{ person.firstName }} - {{ person.additionalName }} - {{ person.lastName }} - </v-list-item-title> - <v-list-item-subtitle> - {{ $t("person.name") }} - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - <v-divider inset /> - - <v-list-item> - <v-list-item-icon> - <v-icon> mdi-human-non-binary</v-icon> - </v-list-item-icon> - - <v-list-item-content> - <v-list-item-title> - {{ - person.sex - ? $t("person.sex." + person.sex.toLowerCase()) - : "–" - }} - </v-list-item-title> - <v-list-item-subtitle> - {{ $t("person.sex_description") }} - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - <v-divider inset /> - - <v-list-item - v-for="(address, index) in person.addresses" - :key="address.id" - > - <v-list-item-icon v-if="index === 0"> - <v-icon>mdi-map-marker-outline</v-icon> - </v-list-item-icon> - <v-list-item-action v-else /> - <v-list-item-content> - <v-list-item-title> - {{ address.street || "–" }} {{ address.housenumber }} - <span - v-if=" - address.postalCode || address.place || address.country - " - > - , - </span> - <br v-if="address.postalCode || address.place" /> - {{ address.postalCode }} {{ address.place }} - <span - v-if=" - (address.postalCode || address.place) && - address.country - " - > - , - </span> - <br v-if="address.country" /> - {{ address.country }} - </v-list-item-title> - <v-list-item-subtitle - v-for="addresstype in address.addressTypes" - :key="addresstype.id" - > - {{ addresstype.name }} - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - - <v-divider inset /> - - <v-list-item - :href="person.phoneNumber ? 'tel:' + person.phoneNumber : ''" - > - <v-list-item-icon> - <v-icon> mdi-phone-outline</v-icon> - </v-list-item-icon> - - <v-list-item-content> - <v-list-item-title> - {{ person.phoneNumber || "–" }} - </v-list-item-title> - <v-list-item-subtitle> - {{ $t("person.home") }} - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - - <v-list-item - :href=" - person.mobileNumber ? 'tel:' + person.mobileNumber : '' - " - > - <v-list-item-action></v-list-item-action> - - <v-list-item-content> - <v-list-item-title> - {{ person.mobileNumber || "–" }} - </v-list-item-title> - <v-list-item-subtitle> - {{ $t("person.mobile") }} - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - <v-divider inset /> - - <v-list-item - :href="person.email ? 'mailto:' + person.email : ''" - > - <v-list-item-icon> - <v-icon>mdi-email-outline</v-icon> - </v-list-item-icon> - - <v-list-item-content> - <v-list-item-title> - {{ person.email || "–" }} - </v-list-item-title> - <v-list-item-subtitle> - {{ $t("person.email_address") }} - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - <v-divider inset /> - - <v-list-item> - <v-list-item-icon> - <v-icon> mdi-cake-variant-outline</v-icon> - </v-list-item-icon> - - <v-list-item-content> - <v-list-item-title> - <span v-if="person.dateOfBirth && person.placeOfBirth"> - {{ - $t("person.birth_date_and_birth_place_formatted", { - date: $d( - $parseISODate(person.dateOfBirth), - "short", - ), - place: person.placeOfBirth, - }) - }} - </span> - <span v-else-if="person.dateOfBirth">{{ - $d($parseISODate(person.dateOfBirth), "short") - }}</span> - <span v-else-if="person.placeOfBirth">{{ - person.placeOfBirth - }}</span> - <span v-else>–</span> - </v-list-item-title> - <v-list-item-subtitle> - <span v-if="!person.dateOfBirth === !person.placeOfBirth"> - {{ $t("person.birth_date_and_birth_place") }} - </span> - <span v-else-if="person.dateOfBirth"> - {{ $t("person.birth_date") }} - </span> - <span v-else-if="person.placeOfBirth"> - {{ $t("person.birth_place") }} - </span> - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - </v-list> - </v-card> + <person-details-card class="mb-6" :person="person" /> <additional-image :src="person.secondaryImageUrl" /> </v-col> @@ -315,6 +137,7 @@ import ObjectOverview from "../generic/ObjectOverview.vue"; import PersonActions from "./PersonActions.vue"; import PersonAvatarClickbox from "./PersonAvatarClickbox.vue"; import PersonCollection from "./PersonCollection.vue"; +import PersonDetailsCard from "./PersonDetailsCard.vue"; import gqlPersonOverview from "./personOverview.graphql"; @@ -329,6 +152,7 @@ export default { PersonActions, PersonAvatarClickbox, PersonCollection, + PersonDetailsCard, }, data() { return { diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index aec2bae346f92746621bf461173ee72324f73785..a0d92555b7ef4544b4d07f093a38e7014c471b6c 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -39,7 +39,153 @@ "menu_title": "Logout" }, "signup": { - "menu_title": "Sign Up" + "menu_title": "Sign Up", + "form": { + "login_button": "Go to login", + "submitted": { + "title": "Registration successful!", + "submitted_successfully": "Your account has been successfully registered. You can log in with your credentials now." + }, + "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.", + "autofilled": "The invitation code was automatically filled in.", + "fields": { + "invitation_code": { + "label": "Invitation code", + "help_text": "If you have an invitation code, please enter it." + } + } + }, + "email": { + "title": "E-Mail address", + "choose_mode": { + "continue_aleksis": "New email address", + "continue_own": "Existing email address", + "continue_existing_account": "I already have an account", + "back": "Go back" + }, + "fields": { + "email": { + "label": "E-Mail address" + }, + "confirm_email": { + "label": "Confirm e-mail address" + } + } + }, + "account": { + "title": "Account", + "fields": { + "username": { + "label": "Username" + }, + "password": { + "label": "Password" + }, + "confirm_password": { + "label": "Confirm password" + } + } + }, + "base_data": { + "title": "Base data", + "fields": { + "first_name": { + "label": "First name" + }, + "additional_name": { + "label": "Additional name(s)" + }, + "last_name": { + "label": "Last name" + }, + "short_name": { + "label": "Short name" + } + } + }, + "address_data": { + "title": "Address data", + "fields": { + "street": { + "label": "Street" + }, + "housenumber": { + "label": "Housenumber" + }, + "postal_code": { + "label": "Postal code" + }, + "place": { + "label": "Place" + }, + "country": { + "label": "Country" + } + } + }, + "contact_data": { + "title": "Contact data", + "fields": { + "mobile_number": { + "label": "Mobile number" + }, + "phone_number": { + "label": "Home phone number" + } + } + }, + "additional_data": { + "title": "Additional data", + "fields": { + "date_of_birth": { + "label": "Date of birth" + }, + "place_of_birth": { + "label": "Place of birth" + }, + "sex": { + "label": "Sex" + }, + "photo": { + "label": "Photo" + }, + "description": { + "label": "Description" + } + } + }, + "confirm": { + "privacy_policy": { + "label": "I have read and agree to the {url}.", + "url_text": "privacy policy" + }, + "title": "Confirm account registration", + "invitation_used": "Some personal data will be taken over from the data associated with the invitation you accepted.", + "card_title": "Your account data" + } + }, + "rules": { + "confirm_email": { + "no_match": "The e-mail addresses do not match" + }, + "confirm_password": { + "no_match": "The passwords do not match" + } + }, + "help_text": { + "guardian": "For guardians", + "participant": "For participants" + } + } }, "social_connections": { "menu_title": "Third-party Accounts" diff --git a/aleksis/core/frontend/mixins/permissions.js b/aleksis/core/frontend/mixins/permissions.js index 2dd7a462d0dc718c85996be21a057d80bd23b80c..3156b5d47a0995d66591d9beb95fca1386e57463 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/mixins/usernameRulesMixin.js b/aleksis/core/frontend/mixins/usernameRulesMixin.js index ec9d60b54102d179aa1ab8bf7c869a8201f49eec..43df2e7668c26b5b3b10973127af8ee2f9237bdb 100644 --- a/aleksis/core/frontend/mixins/usernameRulesMixin.js +++ b/aleksis/core/frontend/mixins/usernameRulesMixin.js @@ -1,11 +1,11 @@ -import { gqlUsernamePreferences } from "../components/app/systemProperties.graphql"; +import { gqlUsernameSystemProperties } from "../components/app/systemProperties.graphql"; /** * This mixin provides rules checking whether a string conforms with the requiremends set out for usernames set in the site preferences. */ export default { apollo: { - systemProperties: gqlUsernamePreferences, + usernameSystemProperties: gqlUsernameSystemProperties, }, computed: { usernameRules() { @@ -31,13 +31,13 @@ export default { return string.match(/[^\x00-\x7F]/g) || []; }, checkDisallowed(string) { - return this.systemProperties.sitePreferences.authDisallowedUids?.includes( + return this.usernameSystemProperties.sitePreferences.authDisallowedUids?.includes( string, ); }, checkAllowed(string) { const regEx = new RegExp( - this.systemProperties.sitePreferences.authAllowedUsernameRegex, + this.usernameSystemProperties.sitePreferences.authAllowedUsernameRegex, ); return regEx.test(string); }, diff --git a/aleksis/core/frontend/routeValidators.js b/aleksis/core/frontend/routeValidators.js index 24caa28708a9c72cd87510f26cd2573065e64360..6fb57fdada21ee6bae9f4e1cbaa0f13433489e53 100644 --- a/aleksis/core/frontend/routeValidators.js +++ b/aleksis/core/frontend/routeValidators.js @@ -22,4 +22,19 @@ const inviteEnabledValidator = (_, systemProperties) => { return systemProperties && systemProperties.sitePreferences.inviteEnabled; }; -export { notLoggedInValidator, hasPersonValidator, inviteEnabledValidator }; +/** + * Check whether signup is enabled. + * + * @param {Object} systemProperties object as returned by the systemProperties query + * @returns true if invites are enabled and false otherwise + */ +const signupEnabledValidator = (_, systemProperties) => { + return systemProperties && systemProperties.sitePreferences.signupEnabled; +}; + +export { + notLoggedInValidator, + hasPersonValidator, + inviteEnabledValidator, + signupEnabledValidator, +}; diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index b211c4a093c590795cd11d8f8b0f0816d109adc4..155ac0d4f52fedc273b646cb04fb44319a85d89d 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -11,6 +11,7 @@ import { appObjects } from "aleksisAppImporter"; import { notLoggedInValidator, inviteEnabledValidator, + signupEnabledValidator, } from "./routeValidators"; const routes = [ @@ -32,35 +33,17 @@ const routes = [ { path: "/accounts/signup/", name: "core.accounts.signup", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => import("./components/account/AccountRegistrationForm.vue"), meta: { inMenu: true, icon: "mdi-account-plus-outline", iconActive: "mdi-account-plus", titleKey: "accounts.signup.menu_title", - menuPermission: "core.signup_rule", validators: [notLoggedInValidator], + permission: "core.signup_menu_rule", invalidate: "leave", }, }, - { - path: "/invitations/code/enter/", - name: "core.invitations.enterCode", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - meta: { - inMenu: true, - icon: "mdi-key-outline", - iconActive: "mdi-key-outline", - titleKey: "accounts.invitation.accept_invitation.menu_title", - validators: [inviteEnabledValidator, notLoggedInValidator], - }, - }, { path: "", name: "dashboard", diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 8057f697ac6ec7aa77fdb19df68e846b5e3e4add..f1b69c8357aeea67b1d20fcb5af5b073408b5dca 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1292,8 +1292,8 @@ class PersonInvitation(AbstractBaseInvitation, PureDjangoModel): def send_invitation(self, request, **kwargs): """Send the invitation email to the person.""" - invite_url = reverse("invitations:accept-invite", args=[self.key]) - invite_url = request.build_absolute_uri(invite_url).replace("/django", "") + invite_url = f"/accounts/signup?invitation_code={self.key}" + invite_url = request.build_absolute_uri(invite_url) context = kwargs context.update( { diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index ed94efc3411e3328282aa01bce0a3c1b3a870a44..4ae635d892e315f640278e4642b846411ffaaa46 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -18,7 +18,7 @@ from dynamic_preferences.types import ( from oauth2_provider.models import AbstractApplication from .mixins import CalendarEventMixin, PublicFilePreferenceMixin -from .models import Group, Person, Role +from .models import Address, Group, Person, Role from .registries import person_preferences_registry, site_preferences_registry from .util.notifications import get_notification_choices_lazy @@ -304,6 +304,82 @@ class SignupEnabled(BooleanPreference): verbose_name = _("Enable signup") +@site_preferences_registry.register +class SignupRequiredFields(MultipleChoicePreference): + """Required fields on the person model for sign-up.""" + + section = auth + name = "signup_required_fields" + default = [] + widget = SelectMultiple + verbose_name = _( + "Required fields on the person model for sign-up. First and last name are always required." + ) + field_attribute = {"initial": []} + choices = [ + (field.name, field.name) + for field in Person.syncable_fields() + if getattr(field, "blank", False) + ] + required = False + + +@site_preferences_registry.register +class SignupAddressRequiredFields(MultipleChoicePreference): + """Required fields on the address model for sign-up.""" + + section = auth + name = "signup_address_required_fields" + default = [] + widget = SelectMultiple + verbose_name = _("Required fields on the address model for sign-up.") + field_attribute = {"initial": []} + choices = [ + (field.name, field.name) + for field in Address.syncable_fields() + if getattr(field, "blank", False) + ] + required = False + + +@site_preferences_registry.register +class SignupVisibleFields(MultipleChoicePreference): + """Visible fields on the person model for sign-up.""" + + section = auth + name = "signup_visible_fields" + default = [] + widget = SelectMultiple + verbose_name = _( + "Visible fields on the person model for sign-up. First and last name are always visible." + ) + field_attribute = {"initial": []} + choices = [ + (field.name, field.name) + for field in Person.syncable_fields() + if getattr(field, "blank", False) + ] + required = False + + +@site_preferences_registry.register +class SignupAddressVisibleFields(MultipleChoicePreference): + """Visible fields on the address model for sign-up.""" + + section = auth + name = "signup_address_visible_fields" + default = [] + widget = SelectMultiple + verbose_name = _("Visible fields on the address model for sign-up.") + field_attribute = {"initial": []} + choices = [ + (field.name, field.name) + for field in Address.syncable_fields() + if getattr(field, "blank", False) + ] + required = False + + @site_preferences_registry.register class AllowedUsernameRegex(StringPreference): section = auth diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index c1bde1498661c8c4a02e6ef7ec3f97f8c396b847..2a6dccf8b478bc84c438ff05ef7d74ebf1218045 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -401,18 +401,6 @@ rules.add_perm("core.edit_dashboard_rule", edit_dashboard_predicate) edit_default_dashboard_predicate = has_person & has_global_perm("core.edit_default_dashboard") 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") -rules.add_perm("core.signup_rule", signup_predicate) - -change_password_predicate = has_person & is_site_preference_set( - section="auth", pref="allow_password_change" -) -rules.add_perm("core.change_password_rule", change_password_predicate) - -reset_password_predicate = is_site_preference_set(section="auth", pref="allow_password_reset") -rules.add_perm("core.reset_password_rule", reset_password_predicate) - # django-invitations invite_enabled_predicate = is_site_preference_set(section="auth", pref="invite_enabled") rules.add_perm("core.invite_enabled", invite_enabled_predicate) @@ -423,6 +411,21 @@ rules.add_perm("core.accept_invite_rule", accept_invite_predicate) invite_predicate = has_person & invite_enabled_predicate & has_global_perm("core.invite") rules.add_perm("core.invite_rule", invite_predicate) +# django-allauth +signup_enabled_predicate = is_site_preference_set(section="auth", pref="signup_enabled") +rules.add_perm("core.signup_rule", signup_enabled_predicate) + +signup_menu_predicate = signup_enabled_predicate | invite_enabled_predicate +rules.add_perm("core.signup_menu_rule", signup_menu_predicate) + +change_password_predicate = has_person & is_site_preference_set( + section="auth", pref="allow_password_change" +) +rules.add_perm("core.change_password_rule", change_password_predicate) + +reset_password_predicate = is_site_preference_set(section="auth", pref="allow_password_reset") +rules.add_perm("core.reset_password_rule", reset_password_predicate) + # OAuth2 permissions view_oauthapplication_predicate = has_person & has_global_perm("core.view_oauthapplication") rules.add_perm("core.view_oauthapplication_rule", view_oauthapplication_predicate) diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index 5043165d9b71da13995f445d38dc2966637a93db..209de177dca84c55a877684604b5081a6e318ed8 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -1,7 +1,12 @@ from django.apps import apps from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import ( + password_validators_help_texts, + validate_password, +) from django.contrib.contenttypes.models import ContentType from django.contrib.messages import get_messages +from django.core.exceptions import ValidationError from django.db.models import Q import graphene @@ -26,6 +31,7 @@ from ..models import ( OAuthApplication, PDFFile, Person, + PersonInvitation, Role, Room, SchoolTerm, @@ -87,7 +93,9 @@ from .person import ( PersonBatchDeleteMutation, PersonBatchPatchMutation, PersonType, + SendAccountRegistrationMutation, ) +from .person_invitation import PersonInvitationType from .personal_event import ( PersonalEventBatchCreateMutation, PersonalEventBatchDeleteMutation, @@ -192,6 +200,11 @@ class Query(graphene.ObjectType): countries = graphene.List(CountryType) + person_invitation_by_code = graphene.Field(PersonInvitationType, code=graphene.String()) + + password_help_texts = graphene.List(graphene.String) + password_validation_status = graphene.List(graphene.String, password=graphene.String()) + def resolve_ping(root, info, payload) -> str: return payload @@ -473,6 +486,25 @@ 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 + + @staticmethod + def resolve_password_help_texts(root, info, **kwargs): + return password_validators_help_texts() + + @staticmethod + def resolve_password_validation_status(root, info, password, **kwargs): + try: + validate_password(password, info.context.user) + return [] + except ValidationError as exc: + return exc.messages + class Mutation(graphene.ObjectType): delete_persons = PersonBatchDeleteMutation.Field() @@ -522,6 +554,8 @@ class Mutation(graphene.ObjectType): delete_oauth_applications = OAuthApplicationBatchDeleteMutation.Field() patch_oauth_applications = OAuthApplicationBatchPatchMutation.Field() + send_account_registration = SendAccountRegistrationMutation.Field() + def build_global_schema(): """Build global GraphQL schema from all apps.""" diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py index 04208e4489fb30eeadb0a2edb4f5e325185b0ca0..74b36db33203953a7acf7ad3de44b3fd122b6b98 100644 --- a/aleksis/core/schema/person.py +++ b/aleksis/core/schema/person.py @@ -1,23 +1,32 @@ from typing import Union -from django.core.exceptions import PermissionDenied +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import PermissionDenied, SuspiciousOperation, ValidationError +from django.db import IntegrityError, transaction from django.db.models import Q from django.utils import timezone +from django.utils.translation import gettext as _ 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 ( + Activity, Address, AddressType, DummyPerson, Person, PersonGroupThrough, + PersonInvitation, PersonRelationship, Role, ) +from ..util.auth_helpers import custom_username_validators from ..util.core_helpers import get_site_preferences, has_person from .address import AddressType as GraphQLAddressType from .base import ( @@ -335,13 +344,19 @@ class PersonInputType(graphene.InputObjectType): email = graphene.String(required=False) - date_of_birth = graphene.String(required=False) - place_of_birth = graphene.Date(required=False) + date_of_birth = graphene.Date(required=False) + place_of_birth = graphene.String(required=False) sex = graphene.String(required=False) address = graphene.Field(AddressInputType, required=False) + street = graphene.String(required=False) + housenumber = graphene.String(required=False) + postal_code = graphene.String(required=False) + place = graphene.String(required=False) + country = graphene.String(required=False) - # TODO: Photo and avatar + photo = Upload() + avatar = Upload() guardians = graphene.List(lambda: PersonInputType, required=False) @@ -417,9 +432,7 @@ class PersonAddressMutationMixin: class PersonBatchCreateMutation( - PersonAddressMutationMixin, - PersonGuardianMutationMixin, - BaseBatchCreateMutation + PersonAddressMutationMixin, PersonGuardianMutationMixin, BaseBatchCreateMutation ): class Meta: model = Person @@ -482,9 +495,7 @@ class PersonBatchCreateMutation( class PersonBatchPatchMutation( - PersonAddressMutationMixin, - PersonGuardianMutationMixin, - BaseBatchPatchMutation + PersonAddressMutationMixin, PersonGuardianMutationMixin, BaseBatchPatchMutation ): class Meta: model = Person @@ -566,3 +577,120 @@ class PersonBatchPatchMutation( pass cls._handle_address(root, info, input, obj) cls._handle_guardians(root, info, input, obj) + + +class AccountRegistrationInputType(graphene.InputObjectType): + from .user import UserInputType # noqa + + person = graphene.Field(PersonInputType, required=True) + user = graphene.Field(UserInputType, required=True) + invitation_code = graphene.String(required=False) + + +class SendAccountRegistrationMutation(PersonAddressMutationMixin, graphene.Mutation): + class Arguments: + account_registration = AccountRegistrationInputType(required=True) + + ok = graphene.Boolean() + + @classmethod + @transaction.atomic + def mutate(cls, root, info, account_registration: AccountRegistrationInputType): + # Initialize registering person to indicate that registration is in progress + info.context._registering_person = None + + invitation = None + + if code := account_registration["invitation_code"]: + formatted_code = "".join(code.lower().split("-")) + try: + invitation = PersonInvitation.objects.get( + key=formatted_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 invitation and invitation.email: + email = invitation.email + elif account_registration["user"] is not None: + email = account_registration["user"]["email"] + + # Check username + for validator in custom_username_validators: + try: + validator(account_registration["user"]["username"]) + except ValidationError as exc: + raise ValidationError(_("This username is not allowed.")) from exc + + # Create user + try: + user = get_user_model().objects.create_user( + username=account_registration["user"]["username"], + email=email, + password=account_registration["user"]["password"], + ) + except IntegrityError as exc: + raise ValidationError(_("A user with this username or e-mail already exists.")) from exc + + validate_password(account_registration["user"]["password"], user) + + # 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.email = email + 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 as exc: + raise ValidationError( + _("A person using the e-mail address %s already exists.") % email + ) from exc + + # Store contact information in database + person.email = email + + 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.full_clean() + person.save() + + # Store address information + cls._handle_address(root, info, account_registration["person"], person) + + # Accept invitation, if exists + if invitation: + accept_invitation(invitation, info.context, info.context.user) + + _act = Activity( + title=_("You registered an account"), + description=_(f"You registered an account with the username {user.username}"), + app="Core", + user=person, + ) + + # Store person in request to make it accessible for injected registration mutations + info.context._registering_person = person + + return SendAccountRegistrationMutation(ok=True) diff --git a/aleksis/core/schema/person_invitation.py b/aleksis/core/schema/person_invitation.py new file mode 100644 index 0000000000000000000000000000000000000000..a6abf77ea2b92b43b53092ab19f4e78b02ef2b91 --- /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/site_preferences.py b/aleksis/core/schema/site_preferences.py index a4c8af0b102c1c56accdc3de2f195d43c9a517f7..4821746b4662c1956b36ca744bf5071d3e606b19 100644 --- a/aleksis/core/schema/site_preferences.py +++ b/aleksis/core/schema/site_preferences.py @@ -24,6 +24,11 @@ class SitePreferencesType(graphene.ObjectType): editable_fields_person = graphene.List(graphene.String) invite_enabled = graphene.Boolean() + signup_enabled = graphene.Boolean() + signup_required_fields = graphene.List(graphene.String) + signup_address_required_fields = graphene.List(graphene.String) + signup_visible_fields = graphene.List(graphene.String) + signup_address_visible_fields = graphene.List(graphene.String) auth_allowed_username_regex = graphene.String() auth_disallowed_uids = graphene.List(graphene.String) @@ -72,3 +77,18 @@ class SitePreferencesType(graphene.ObjectType): def resolve_auth_disallowed_uids(parent, info, **kwargs): return parent["auth__disallowed_uids"].split(",") + + def resolve_signup_enabled(parent, info, **kwargs): + return parent["auth__signup_enabled"] + + def resolve_signup_required_fields(parent, info, **kwargs): + return parent["auth__signup_required_fields"] + + def resolve_signup_address_required_fields(parent, info, **kwargs): + return parent["auth__signup_address_required_fields"] + + def resolve_signup_visible_fields(parent, info, **kwargs): + return parent["auth__signup_visible_fields"] + + def resolve_signup_address_visible_fields(parent, info, **kwargs): + return parent["auth__signup_address_visible_fields"] diff --git a/aleksis/core/schema/user.py b/aleksis/core/schema/user.py index 13fbbc4e6de9ea98ad3786886c463194808d7c40..c3d875c92d744364ab8b3e46e9028d196a75d1f0 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 diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 0bb5ddb1211b947d506126cf35f73b4109e2dfa5..17918cf3cbb84541dec1560e85b56abe995851e5 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -616,6 +616,7 @@ YARN_INSTALLED_APPS = [ "apollo-upload-client@^18.0.1", "@vitejs/plugin-legacy@^6.0.0", "terser@^5.37.0", + "graphql-combine-query@^1.2.4", ] merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True) diff --git a/aleksis/core/templates/account/signup.html b/aleksis/core/templates/account/signup.html deleted file mode 100644 index eb606114eae4fbbab8cab0543a59ce549e7bb35d..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/account/signup.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "core/base.html" %} - -{% load i18n material_form %} - -{% block browser_title %}{% trans "Signup" %}{% endblock %} -{% block page_title %}{% trans "Signup" %}{% endblock %} - -{% block content %} - <div class="alert warning"> - <p> - <i class="material-icons iconify left" data-icon="mdi:alert-outline"></i> - {% blocktrans %}Already have an account? Then please <a href="{{ login_url }}">sign in</a>.{% endblocktrans %} - </p> - </div> - - <form method="post" action="{% url 'account_signup' %}"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% if redirect_field_value %} - <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" /> - {% endif %} - {% trans "Sign up" as caption %} - {% include "core/partials/save_button.html" with caption=caption icon="mdi:account-plus-outline" %} - </form> - -{% endblock %} diff --git a/aleksis/core/templates/invitations/enter.html b/aleksis/core/templates/invitations/enter.html deleted file mode 100644 index 03605a710697334567384a74a289783b64a7f55e..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/invitations/enter.html +++ /dev/null @@ -1,44 +0,0 @@ -{# -*- 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/urls.py b/aleksis/core/urls.py index cde100096c70ea3ab804ab06e2426680748b38b4..c7aedac4eadff27b45f9b55e48d3ef80a60ba26b 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -113,9 +113,6 @@ urlpatterns = [ include( [ path("account/login/", views.LoginView.as_view(), name="login"), - path( - "accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup" - ), path("accounts/logout/", views.CustomLogoutView.as_view(), name="logout"), path( "accounts/password/change/", @@ -137,11 +134,6 @@ urlpatterns = [ 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(), diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 98360a2dd8be2e6f7a4ad42edb648de0ef1184cb..34ea0ef757c3a42e46cd0368854ec2f49245b502 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -41,7 +41,7 @@ from django.views.generic.list import ListView import reversion from allauth.account.utils import has_verified_email, send_email_confirmation -from allauth.account.views import PasswordChangeView, PasswordResetView, SignupView +from allauth.account.views import PasswordChangeView, PasswordResetView from allauth.socialaccount.adapter import get_adapter from allauth.socialaccount.models import SocialAccount from django_celery_results.models import TaskResult @@ -85,12 +85,10 @@ from .filters import ( UserObjectPermissionFilter, ) from .forms import ( - AccountRegisterForm, AssignPermissionForm, DashboardWidgetOrderFormSet, EditGroupForm, GroupPreferenceForm, - InvitationCodeForm, MaintenanceModeForm, PersonPreferenceForm, SelectPermissionForm, @@ -707,25 +705,6 @@ class InvitePerson(PermissionRequiredMixin, SingleTableView, SendInvite): 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() - ): - self.request.session["invitation_code"] = code - return redirect("account_signup") - return redirect("invitations:accept-invite", code) - - class GenerateInvitationCode(View): """View to generate an invitation code.""" @@ -954,36 +933,6 @@ def server_error( 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 = reverse_lazy("index") - - def dispatch(self, request, *args, **kwargs): - if ( - not request.user.has_perm("core.signup_rule") - and not request.session.get("account_verified_email") - and not request.session.get("invitation_code") - ): - raise PermissionDenied() - return super().dispatch(request, *args, **kwargs) - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["request"] = self.request - return kwargs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["login_url"] = reverse(settings.LOGIN_URL) - return context - - class InvitePersonByID(PermissionRequiredMixin, SingleObjectMixin, View): """Custom view to invite person by their ID."""