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/",