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..c7e54d94cb74a0b5fa03cf5029747e1284fe20b9 --- /dev/null +++ b/aleksis/core/frontend/components/account/AccountRegistrationForm.vue @@ -0,0 +1,1052 @@ +<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.invitation?.hasPerson && + 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.invitation?.hasPerson && + this.isFieldVisible("mobile_number") | + this.isFieldVisible("phone_number") + ? [ + { + name: "contact_data", + titleKey: "accounts.signup.form.steps.contact_data.title", + }, + ] + : []), + ...(!this.invitation?.hasPerson && + 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..06010017187147a7638b7b987686fb7d64b36868 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 { + 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..65f41c1ba7ab7a1a1a7b5a92a1afc8a532aadf87 --- /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..535442abebec7180669406c50599b4e2d18e0eb1 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -39,7 +39,156 @@ "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": { + "email": { + "valid": "This is not a valid e-mail address" + }, + "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..ce866f4334f946f85fa9b631401e0f1086e3b518 100644 --- a/aleksis/core/frontend/mixins/usernameRulesMixin.js +++ b/aleksis/core/frontend/mixins/usernameRulesMixin.js @@ -5,7 +5,7 @@ import { gqlUsernamePreferences } from "../components/app/systemProperties.graph */ export default { apollo: { - systemProperties: gqlUsernamePreferences, + usernameSystemProperties: gqlUsernamePreferences, }, 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 d1641fa09633564823e7af604f6b7be02ad0d7cb..16e3aeaacb64f055e887a53629faeed6bdbdb865 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -485,10 +485,7 @@ class Person(ContactMixin, ExtensibleModel): card.append(f"TEL;TYPE=cell:{self.mobile_number}") # Addresses - if ( - not self._is_unrequested_prop("ADR", params) - and self.addresses.exists() - ): + if not self._is_unrequested_prop("ADR", params) and self.addresses.exists(): for address in self.addresses.all(): address_types = ",".join(address.address_types.values_list("name", flat=True)) card.append( @@ -1436,7 +1433,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]) + # TODO: Use correct URL to new signup wizard + invite_url = f"{reverse('account_signup')}?invitation_code={self.key}" invite_url = request.build_absolute_uri(invite_url).replace("/django", "") context = kwargs context.update( @@ -2326,17 +2324,11 @@ class Organisation(ContactMixin, ExtensibleModel): card.append(f"ORG:{self.name}") # Email - if ( - not self._is_unrequested_prop("EMAIL", params) - and self.email - ): + if not self._is_unrequested_prop("EMAIL", params) and self.email: card.append(f"EMAIL:{self.email}") # Addresses - if ( - not self._is_unrequested_prop("ADR", params) - and self.addresses.exists() - ): + if not self._is_unrequested_prop("ADR", params) and self.addresses.exists(): for address in self.addresses.all(): address_types = ",".join(address.address_types.values_list("name", flat=True)) card.append( 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 c0d8f938d1b61ed4091c1502f140265f414592e0..7882dfc771c56215ffaec90831f5c5557069af38 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 ( @@ -309,13 +318,6 @@ class PersonType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): return info.context.user.has_perm("core.delete_person_rule", root) -class AddressInputType(graphene.InputObjectType): - street = graphene.String(required=False) - housenumber = graphene.String(required=False) - postal_code = graphene.String(required=False) - place = graphene.String(required=False) - - class PersonInputType(graphene.InputObjectType): id = graphene.ID(required=False) # noqa @@ -334,13 +336,18 @@ 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) @@ -391,7 +398,7 @@ class PersonAddressMutationMixin: ADDRESS_FIELDS = ["street", "housenumber", "postal_code", "place", "country"] @classmethod - def _handle_address(cls, root, info, input, obj, full_input): # noqa: A002 + def _handle_address(cls, root, info, input, obj): # noqa: A002 """Handle and save address input.""" address_type = AddressType.get_default() @@ -416,9 +423,7 @@ class PersonAddressMutationMixin: class PersonBatchCreateMutation( - PersonAddressMutationMixin, - PersonGuardianMutationMixin, - BaseBatchCreateMutation + PersonAddressMutationMixin, PersonGuardianMutationMixin, BaseBatchCreateMutation ): class Meta: model = Person @@ -476,14 +481,12 @@ class PersonBatchCreateMutation( @classmethod def after_create_obj(cls, root, info, input, obj, full_input): # noqa: A002 super().after_create_obj(root, info, input, obj, full_input) - cls._handle_address(root, info, input, obj, full_input) cls._handle_guardians(root, info, input, obj, full_input) + cls._handle_address(root, info, input, obj) class PersonBatchPatchMutation( - PersonAddressMutationMixin, - PersonGuardianMutationMixin, - BaseBatchPatchMutation + PersonAddressMutationMixin, PersonGuardianMutationMixin, BaseBatchPatchMutation ): class Meta: model = Person @@ -563,5 +566,119 @@ class PersonBatchPatchMutation( "User not allowed to edit the given fields for own person." ) pass - cls._handle_address(root, info, input, obj, full_input) cls._handle_guardians(root, info, input, obj, full_input) + cls._handle_address(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 + 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) + + # 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/urls.py b/aleksis/core/urls.py index cde100096c70ea3ab804ab06e2426680748b38b4..40a2445fcc338be4d0647eb953e82a141c821447 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -107,15 +107,17 @@ urlpatterns = [ views.DAVSingleResourceView.as_view(), name="dav_resource_contact", ), + path( + "accounts/signup/", + views.TemplateView.as_view(template_name="core/vue_index.html"), + name="account_signup", + ), path("", include("django_prometheus.urls")), path( "django/", 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/",