diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index 359ec8bede87d872a6de0cf8929022df3fa08b64..06d32ce9b853c830f7fea73c6439c9298fc68ab1 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -26,7 +26,6 @@ from .models import (
     Announcement,
     DashboardWidget,
     Group,
-    GroupType,
     OAuthApplication,
     Person,
     PersonInvitation,
@@ -355,14 +354,6 @@ class GroupPreferenceForm(PreferenceForm):
     registry = group_preferences_registry
 
 
-class EditGroupTypeForm(forms.ModelForm):
-    """Form to manage group types."""
-
-    class Meta:
-        model = GroupType
-        fields = ["name", "description"]
-
-
 class DashboardWidgetOrderForm(ExtensibleForm):
     pk = forms.ModelChoiceField(
         queryset=None,
diff --git a/aleksis/core/frontend/components/generic/CRUDIterator.vue b/aleksis/core/frontend/components/generic/CRUDIterator.vue
index 7f37088030b3911f44566ce70d0b398861b1f4a2..ecb2ec8e37889caef0b5bfab679766f8e5d0ec40 100644
--- a/aleksis/core/frontend/components/generic/CRUDIterator.vue
+++ b/aleksis/core/frontend/components/generic/CRUDIterator.vue
@@ -1,5 +1,7 @@
 <template>
   <v-data-iterator
+    v-bind="$attrs"
+    v-on="$listeners"
     :items="items"
     :items-per-page="itemsPerPage"
     :loading="loading"
diff --git a/aleksis/core/frontend/components/generic/CRUDList.vue b/aleksis/core/frontend/components/generic/CRUDList.vue
index 61f752d82b0c6ef8d399a72ac66ff7e3893ab6a1..3f5a30defc28a334f5f0e1754ea2e207beaf52a9 100644
--- a/aleksis/core/frontend/components/generic/CRUDList.vue
+++ b/aleksis/core/frontend/components/generic/CRUDList.vue
@@ -56,6 +56,9 @@
         <template #additionalActions="{ attrs, on }">
           <slot name="additionalActions" :attrs="attrs" :on="on" />
         </template>
+        <template #createComponent="createComponentProps">
+          <slot name="createComponent" v-bind="createComponentProps" />
+        </template>
       </c-r-u-d-bar>
     </template>
 
diff --git a/aleksis/core/frontend/components/generic/TableLink.vue b/aleksis/core/frontend/components/generic/TableLink.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e8e88817cf7c9c77a4e451cfade9e3dcd230e5cd
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/TableLink.vue
@@ -0,0 +1,15 @@
+<template>
+  <router-link
+    v-bind="$attrs"
+    v-on="$listeners"
+    class="text-decoration-none primary--text font-weight-medium d-inline-block full-width"
+  >
+    <slot />
+  </router-link>
+</template>
+<script>
+export default {
+  name: "TableLink",
+  extends: "router-link",
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/forms/ColorField.vue b/aleksis/core/frontend/components/generic/forms/ColorField.vue
index 865124f9b48e863397af4c73a4eff225709df8ee..b8a5024d10562653d6ce54bc98ebb953ed8a368b 100644
--- a/aleksis/core/frontend/components/generic/forms/ColorField.vue
+++ b/aleksis/core/frontend/components/generic/forms/ColorField.vue
@@ -14,7 +14,7 @@
         v-bind="$attrs"
         v-on="$listeners"
         placeholder="#AABBCC"
-        :rules="mergedRules"
+        :rules="$rules().isHexColor(allowAlpha).build(rules)"
       >
         <template #prepend-inner>
           <v-icon :color="color" v-bind="attrs" v-on="on"> mdi-circle </v-icon>
@@ -26,9 +26,12 @@
 </template>
 
 <script>
+import formRulesMixin from "../../../mixins/formRulesMixin";
+
 export default {
-  name: "DateField",
+  name: "ColorField",
   extends: "v-text-field",
+  mixins: [formRulesMixin],
   data() {
     return {
       menu: false,
@@ -44,6 +47,11 @@ export default {
       required: false,
       default: () => [],
     },
+    allowAlpha: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
   },
   computed: {
     color: {
@@ -54,14 +62,6 @@ export default {
         this.$emit("input", newValue);
       },
     },
-    mergedRules() {
-      return [
-        (value) =>
-          /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))?$/i.test(value) ||
-          this.$t("forms.errors.invalid_color"),
-        ...this.rules,
-      ];
-    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue
index 0b6ba51574745f093d266483fee82ae653ee91e9..bcbe7779563062f90e6302990712c06004fa67fc 100644
--- a/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue
+++ b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue
@@ -2,16 +2,24 @@
   <v-text-field
     v-bind="$attrs"
     v-on="on"
-    :rules="mergedRules"
+    :rules="
+      $rules()
+        .isANumber.isAWholeNumber.isGreaterThan(0)
+        .isSmallerThan(32767)
+        .build(rules)
+    "
     type="number"
     inputmode="decimal"
   ></v-text-field>
 </template>
 
 <script>
+import formRulesMixin from "../../../mixins/formRulesMixin";
+
 export default {
   name: "PositiveSmallIntegerField",
   extends: "v-text-field",
+  mixins: [formRulesMixin],
   props: {
     rules: {
       type: Array,
@@ -27,27 +35,6 @@ export default {
         change: this.inputHandler("change"),
       };
     },
-    mergedRules() {
-      return [
-        (value) =>
-          !value ||
-          !isNaN(parseInt(value)) ||
-          this.$t("forms.errors.not_a_number"),
-        (value) =>
-          !value ||
-          value % 1 === 0 ||
-          this.$t("forms.errors.not_a_whole_number"),
-        (value) =>
-          !value ||
-          parseInt(value) >= 0 ||
-          this.$t("forms.errors.number_too_small"),
-        (value) =>
-          !value ||
-          parseInt(value) <= 32767 ||
-          this.$t("forms.errors.number_too_big"),
-        ...this.rules,
-      ];
-    },
   },
   methods: {
     inputHandler(name) {
diff --git a/aleksis/core/frontend/components/generic/forms/SexSelect.vue b/aleksis/core/frontend/components/generic/forms/SexSelect.vue
new file mode 100644
index 0000000000000000000000000000000000000000..88d506ae425a0bef5786d9805416d0ce83121b2c
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/SexSelect.vue
@@ -0,0 +1,27 @@
+<script>
+import sexChoiceMixin from "../../../mixins/sexChoiceMixin";
+
+export default {
+  name: "SexSelect",
+  extends: ["v-autocomplete"],
+  mixins: [sexChoiceMixin],
+};
+</script>
+
+<template>
+  <v-autocomplete v-bind="$attrs" v-on="$listeners" :items="sexChoices">
+    <template #selection="{ item }">
+      <v-icon left>{{ item.icon }}</v-icon>
+      <span>{{ item.text }}</span>
+    </template>
+
+    <template #item="{ item }">
+      <v-list-item-avatar>
+        <v-icon>{{ item.icon }}</v-icon>
+      </v-list-item-avatar>
+      <v-list-item-content>
+        <v-list-item-title>{{ item.text }}</v-list-item-title>
+      </v-list-item-content>
+    </template>
+  </v-autocomplete>
+</template>
diff --git a/aleksis/core/frontend/components/group/GroupChip.vue b/aleksis/core/frontend/components/group/GroupChip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..10393e3c399e5aa3e6e5faa6a96300ea5ffc68c1
--- /dev/null
+++ b/aleksis/core/frontend/components/group/GroupChip.vue
@@ -0,0 +1,24 @@
+<script>
+export default {
+  name: "GroupChip",
+  extends: "v-chip",
+  props: {
+    group: {
+      type: Object,
+      required: true,
+    },
+  },
+};
+</script>
+
+<template>
+  <v-chip
+    v-bind="$attrs"
+    v-on="$listeners"
+    :to="{ name: 'core.group', params: { id: group.id } }"
+  >
+    {{ group.name }}
+  </v-chip>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/group_type/GroupType.vue b/aleksis/core/frontend/components/group_type/GroupType.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5aed5dab4a8c118c97b1f9d37a2c30ebd2179067
--- /dev/null
+++ b/aleksis/core/frontend/components/group_type/GroupType.vue
@@ -0,0 +1,90 @@
+<script>
+import CRUDList from "../generic/CRUDList.vue";
+
+import {
+  groupTypes,
+  createGroupTypes,
+  deleteGroupTypes,
+  updateGroupTypes,
+} from "./groupType.graphql";
+import formRulesMixin from "../../mixins/formRulesMixin";
+
+export default {
+  name: "GroupType",
+  components: { CRUDList },
+  mixins: [formRulesMixin],
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("group.group_type.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("group.group_type.description"),
+          value: "description",
+        },
+      ],
+      i18nKey: "group.group_type",
+      gqlQuery: groupTypes,
+      gqlCreateMutation: createGroupTypes,
+      gqlPatchMutation: updateGroupTypes,
+      gqlDeleteMutation: deleteGroupTypes,
+      defaultItem: {
+        name: "",
+        description: "",
+      },
+    };
+  },
+  methods: {
+    getData({ id, name, description }) {
+      return {
+        id,
+        name,
+        description,
+      };
+    },
+  },
+};
+</script>
+
+<template>
+  <c-r-u-d-list
+    :headers="headers"
+    :i18n-key="i18nKey"
+    create-item-i18n-key="group.group_type.create"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="gqlPatchMutation"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :get-create-data="getData"
+    :get-patch-data="getData"
+    :default-item="defaultItem"
+    :enable-edit="true"
+  >
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #name.field="{ attrs, on, isCreate }">
+      <div aria-required="true">
+        <v-text-field
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="$rules().required.build()"
+        ></v-text-field>
+      </div>
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #description.field="{ attrs, on, isCreate }">
+      <div aria-required="true">
+        <v-text-field
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="$rules().required.build()"
+        ></v-text-field>
+      </div>
+    </template>
+  </c-r-u-d-list>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/group_type/groupType.graphql b/aleksis/core/frontend/components/group_type/groupType.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..11ab2741287f251f819e4e7de41ea2ceafee69f0
--- /dev/null
+++ b/aleksis/core/frontend/components/group_type/groupType.graphql
@@ -0,0 +1,39 @@
+query groupTypes($orderBy: [String], $filters: JSONString) {
+  items: groupTypes(orderBy: $orderBy, filters: $filters) {
+    id
+    name
+    description
+    canEdit
+    canDelete
+  }
+}
+
+mutation createGroupTypes($input: [BatchCreateGroupTypeInput]!) {
+  createGroupTypes(input: $input) {
+    items: groupTypes {
+      id
+      name
+      description
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteGroupTypes($ids: [ID]!) {
+  deleteGroupTypes(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateGroupTypes($input: [BatchPatchGroupTypeInput]!) {
+  updateGroupTypes(input: $input) {
+    items: groupTypes {
+      id
+      name
+      description
+      canEdit
+      canDelete
+    }
+  }
+}
diff --git a/aleksis/core/frontend/components/person/AvatarContent.vue b/aleksis/core/frontend/components/person/AvatarContent.vue
index 7b0c0f670204872dea9309e1829124d101e2190a..f976448dffaa283987b5d0ddfc4765513bd8cc8d 100644
--- a/aleksis/core/frontend/components/person/AvatarContent.vue
+++ b/aleksis/core/frontend/components/person/AvatarContent.vue
@@ -38,7 +38,7 @@ export default {
     },
     imageUrl: {
       type: String,
-      required: true,
+      required: false,
       default: null,
     },
   },
diff --git a/aleksis/core/frontend/components/person/PersonActions.vue b/aleksis/core/frontend/components/person/PersonActions.vue
index 8b040f27fa9d7ab794b87270f86661929c1ed3c7..9a90ee765ff63eb058c346962af79b32f7d6787d 100644
--- a/aleksis/core/frontend/components/person/PersonActions.vue
+++ b/aleksis/core/frontend/components/person/PersonActions.vue
@@ -82,30 +82,31 @@
         </v-list-item>
       </button-menu>
     </template>
-    <confirm-dialog
+    <delete-dialog
       v-model="showDeleteConfirm"
+      :gql-delete-mutation="deleteMutation"
+      item-attribute="fullName"
+      :items="[person]"
       @confirm="
         $router.push({
-          name: 'core.deletePerson',
-          params: { id: person.id },
+          name: 'core.persons',
         })
       "
-      @cancel="showDeleteConfirm = false"
     >
       <template #title>
         {{ $t("person.confirm_delete") }}
       </template>
-    </confirm-dialog>
+    </delete-dialog>
   </div>
 </template>
 
 <script>
-import gqlPersonActions from "./personActions.graphql";
-import ConfirmDialog from "../generic/dialogs/ConfirmDialog.vue";
+import { actions, deletePersons } from "./personActions.graphql";
+import DeleteDialog from "../generic/dialogs/DeleteDialog.vue";
 
 export default {
   name: "PersonActions",
-  components: { ConfirmDialog },
+  components: { DeleteDialog },
   props: {
     id: {
       type: String,
@@ -114,7 +115,7 @@ export default {
   },
   apollo: {
     person: {
-      query: gqlPersonActions,
+      query: actions,
       variables() {
         return {
           id: this.id,
@@ -125,6 +126,7 @@ export default {
   data() {
     return {
       showDeleteConfirm: false,
+      deleteMutation: deletePersons,
     };
   },
 };
diff --git a/aleksis/core/frontend/components/person/PersonList.vue b/aleksis/core/frontend/components/person/PersonList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..03e15f8466b51f1fa7b66eddeeddf96e787f3ecd
--- /dev/null
+++ b/aleksis/core/frontend/components/person/PersonList.vue
@@ -0,0 +1,95 @@
+<script>
+import CRUDList from "../generic/CRUDList.vue";
+
+import { deletePersons, persons } from "./personList.graphql";
+import CreateButton from "../generic/buttons/CreateButton.vue";
+import SexSelect from "../generic/forms/SexSelect.vue";
+import GroupChip from "../group/GroupChip.vue";
+import TableLink from "../generic/TableLink.vue";
+
+export default {
+  name: "Person",
+  components: { TableLink, GroupChip, SexSelect, CreateButton, CRUDList },
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("person.first_name"),
+          value: "firstName",
+        },
+        {
+          text: this.$t("person.last_name"),
+          value: "lastName",
+        },
+        {
+          text: this.$t("person.short_name"),
+          value: "shortName",
+        },
+        {
+          text: this.$t("person.primary_group"),
+          value: "primaryGroup",
+        },
+      ],
+      i18nKey: "person",
+      gqlQuery: persons,
+      gqlDeleteMutation: deletePersons,
+    };
+  },
+};
+</script>
+
+<template>
+  <c-r-u-d-list
+    :headers="headers"
+    :i18n-key="i18nKey"
+    :gql-query="gqlQuery"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :enable-filter="true"
+    item-attribute="fullName"
+  >
+    <template #createComponent>
+      <create-button :to="{ name: 'core.createPerson' }" />
+    </template>
+
+    <template #filters="{ attrs, on }">
+      <v-text-field
+        v-bind="attrs('name')"
+        v-on="on('name')"
+        :label="$t('person.name')"
+      />
+      <v-text-field
+        v-bind="attrs('contact')"
+        v-on="on('contact')"
+        :label="$t('person.details')"
+      />
+      <sex-select
+        v-bind="attrs('sex')"
+        v-on="on('sex')"
+        :label="$t('person.sex.field')"
+      />
+    </template>
+
+    <template #lastName="{ item }">
+      <table-link :to="{ name: 'core.personById', params: { id: item.id } }">
+        {{ item.lastName }}
+      </table-link>
+    </template>
+
+    <template #firstName="{ item }">
+      <table-link :to="{ name: 'core.personById', params: { id: item.id } }">
+        {{ item.firstName }}
+      </table-link>
+    </template>
+
+    <template #shortName="{ item }">
+      <table-link :to="{ name: 'core.personById', params: { id: item.id } }">
+        {{ item.shortName }}
+      </table-link>
+    </template>
+
+    <template #primaryGroup="{ item }">
+      <group-chip :group="item.primaryGroup" v-if="item.primaryGroup" />
+      <span v-else>–</span>
+    </template>
+  </c-r-u-d-list>
+</template>
diff --git a/aleksis/core/frontend/components/person/personActions.graphql b/aleksis/core/frontend/components/person/personActions.graphql
index 8bc316c3b567b0bf7673caba848c6b7186f85a74..508700fd1bdbafb7972bf6077aafbb111fe98424 100644
--- a/aleksis/core/frontend/components/person/personActions.graphql
+++ b/aleksis/core/frontend/components/person/personActions.graphql
@@ -2,6 +2,7 @@ query actions($id: ID!) {
   person: personById(id: $id) {
     id
     userid
+    fullName
     canEditPerson
     canDeletePerson
     canChangePersonPreferences
@@ -9,3 +10,9 @@ query actions($id: ID!) {
     canImpersonatePerson
   }
 }
+
+mutation deletePersons($ids: [ID]!) {
+  deletePersons(ids: $ids) {
+    deletionCount
+  }
+}
diff --git a/aleksis/core/frontend/components/person/personList.graphql b/aleksis/core/frontend/components/person/personList.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..33ee027ddaf8564b61ff606960fcdf93c6017b8a
--- /dev/null
+++ b/aleksis/core/frontend/components/person/personList.graphql
@@ -0,0 +1,21 @@
+query persons($orderBy: [String], $filters: JSONString) {
+  items: persons(orderBy: $orderBy, filters: $filters) {
+    id
+    firstName
+    lastName
+    shortName
+    fullName
+    primaryGroup {
+      id
+      name
+    }
+    canEdit
+    canDelete
+  }
+}
+
+mutation deletePersons($ids: [ID]!) {
+  deletePersons(ids: $ids) {
+    deletionCount
+  }
+}
diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json
index eb867bc52e53f5fe17d54b3f9c492b8b7de5c872..c16ad41356a8742fa446ef2677ae3fada148f085 100644
--- a/aleksis/core/frontend/messages/de.json
+++ b/aleksis/core/frontend/messages/de.json
@@ -199,7 +199,10 @@
     "group_type": {
       "menu_title": "Gruppentypen",
       "title": "Gruppentyp",
-      "title_plural": "Gruppentypen"
+      "title_plural": "Gruppentypen",
+      "create": "Gruppentyp erstellen",
+      "name": "Name",
+      "description": "Beschreibung"
     },
     "groups_and_child_groups": "Gruppen und Kindgruppen",
     "menu_title": "Gruppen",
@@ -290,7 +293,12 @@
     "page_title": "Person",
     "title": "Person",
     "title_plural": "Personen",
+    "first_name": "Vorname",
+    "last_name": "Nachname",
+    "short_name": "Kürzel",
+    "name": "Name",
     "sex": {
+      "field": "Geschlecht",
       "m": "Männlich",
       "f": "Weiblich",
       "x": "Divers"
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index 39a810e6b0cf92aa0257453c1bf5273846112f9c..765fa5ca74e7111326a7137bd9d69d4c23acdcb5 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -165,7 +165,10 @@
     "group_type": {
       "menu_title": "Group Types",
       "title": "Group Type",
-      "title_plural": "Group Types"
+      "title_plural": "Group Types",
+      "create": "Create Group Type",
+      "name": "Name",
+      "description": "Description"
     },
     "groups_and_child_groups": "Groups and Child Groups",
     "menu_title": "Groups",
@@ -256,13 +259,18 @@
     "page_title": "Person",
     "title": "Person",
     "title_plural": "Persons",
+    "first_name": "First Name",
+    "last_name": "Last Name",
+    "short_name": "Short Name",
+    "name": "Name",
     "sex": {
+      "field": "Sex",
       "m": "Male",
       "f": "Female",
       "x": "Other"
     },
-    "short_name": "Shortname",
-    "username": "Username"
+    "username": "Username",
+    "primary_group": "Primary Group"
   },
   "preferences": {
     "person": {
@@ -304,19 +312,13 @@
     "error": "There has been an error while saving the latest changes.",
     "object_create_success": "The object was created successfully.",
     "object_delete_success": "The object was deleted successfully.",
+    "object_edit_success": "The object was updated successfully.",
     "objects_delete_success": "The objects were deleted successfully."
   },
   "generic_messages": {
     "error": "An error occurred. Please try again.",
     "success": "The operation has been finished successfully."
   },
-  "rooms": {
-    "menu_title": "Rooms",
-    "title_plural": "Rooms",
-    "name": "Name",
-    "short_name": "Short Name",
-    "create_room": "Create new room"
-  },
   "forms": {
     "errors": {
       "required": "This field is required.",
@@ -365,18 +367,6 @@
     "A_5": "Sa",
     "A_6": "Su"
   },
-  "selection": {
-    "num_items_selected": "No items selected | 1 item selected | {n} items selected"
-  },
-  "holidays": {
-    "menu_title": "Holidays",
-    "title": "Holiday",
-    "title_plural": "Holidays",
-    "create_holiday": "Create Holiday",
-    "date_start": "Start Date",
-    "date_end": "End Date",
-    "holiday_name": "Name"
-  },
   "personal_events": {
     "title": "Title",
     "description": "Description",
diff --git a/aleksis/core/frontend/mixins/formRulesMixin.js b/aleksis/core/frontend/mixins/formRulesMixin.js
new file mode 100644
index 0000000000000000000000000000000000000000..55aaa7225283e00d712595b1bbd41e2972118ac0
--- /dev/null
+++ b/aleksis/core/frontend/mixins/formRulesMixin.js
@@ -0,0 +1,91 @@
+/**
+ * @typedef {function(any): (boolean|string)} Rule
+ */
+
+/**
+ * Mixin that provides generic default rules to avoid repetition
+ */
+export default {
+  methods: {
+    /**
+     * Interactive rule builder.
+     *
+     * Keep in mind that the order of adding rules matters.
+     *
+     * @example
+     * $rules.required.build();
+     * $rules.build(additionalRules);
+     * $rules.isANumber.isSmallerThan(50).build(additionalRules);
+     * $rules.isANumber.isAWholeNumber.isGreaterThan(0).build();
+     */
+    $rules() {
+      const mixin = this;
+      return {
+        _rules: [],
+        /**
+         * Finish rule creating
+         *
+         * @param {Rule[]} additional Optional list of addtional rules to add
+         * @returns {Rule[]} the built array of rules
+         */
+        build(additional = []) {
+          return [...this._rules, ...additional];
+        },
+
+        get required() {
+          this._rules.push(
+            (value) => !!value || mixin.$t("forms.errors.required"),
+          );
+          return this;
+        },
+
+        get isANumber() {
+          this._rules.push(
+            (value) =>
+              !value ||
+              !isNaN(parseFloat(value)) ||
+              mixin.$t("forms.errors.not_a_number"),
+          );
+          return this;
+        },
+        get isAWholeNumber() {
+          this._rules.push(
+            (value) =>
+              !value ||
+              value % 1 === 0 ||
+              mixin.$t("forms.errors.not_a_whole_number"),
+          );
+          return this;
+        },
+        isGreaterThan(min = 0) {
+          this._rules.push(
+            (value) =>
+              !value ||
+              parseInt(value) >= min ||
+              mixin.$t("forms.errors.number_too_small"),
+          );
+          return this;
+        },
+        isSmallerThan(max = 0) {
+          this._rules.push(
+            (value) =>
+              !value ||
+              parseInt(value) <= max ||
+              mixin.$t("forms.errors.number_too_big"),
+          );
+          return this;
+        },
+        isHexColor(allowAlpha = true) {
+          const regex = allowAlpha
+            ? /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))?$/i
+            : /^(#([0-9a-f]{3}[0-9a-f]{3}?))?$/i;
+          this._rules.push(
+            (value) =>
+              regex.test(value) || mixin.$t("forms.errors.invalid_color"),
+          );
+          return this;
+        },
+      };
+    },
+  },
+};
diff --git a/aleksis/core/frontend/mixins/sexChoiceMixin.js b/aleksis/core/frontend/mixins/sexChoiceMixin.js
new file mode 100644
index 0000000000000000000000000000000000000000..e828bc6b8525c62a3225ca4bb273152c5005f09c
--- /dev/null
+++ b/aleksis/core/frontend/mixins/sexChoiceMixin.js
@@ -0,0 +1,26 @@
+/**
+ * Mixin to supply choices for the supported sexes in AlekSIS
+ */
+export default {
+  computed: {
+    sexChoices() {
+      return [
+        {
+          text: this.$t("person.sex.m"),
+          value: "m",
+          icon: "mdi-gender-male",
+        },
+        {
+          text: this.$t("person.sex.f"),
+          value: "f",
+          icon: "mdi-gender-female",
+        },
+        {
+          text: this.$t("person.sex.x"),
+          value: "x",
+          icon: "mdi-gender-non-binary",
+        },
+      ];
+    },
+  },
+};
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index 45d9d23202d3eed9323698c5faa2921b92294779..56114530c5cb16c6154764568c1d5d44543a75ee 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -89,10 +89,7 @@ const routes = [
     children: [
       {
         path: "/persons",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+        component: () => import("./components/person/PersonList.vue"),
         name: "core.persons",
         meta: {
           inMenu: true,
@@ -127,14 +124,6 @@ const routes = [
         },
         name: "core.editPerson",
       },
-      {
-        path: "/persons/:id(\\d+)/delete/",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
-        name: "core.deletePerson",
-      },
       {
         path: "/persons/:id(\\d+)/invite/",
         component: () => import("./components/LegacyBaseTemplate.vue"),
@@ -192,10 +181,7 @@ const routes = [
       },
       {
         path: "/groups/group_types",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+        component: () => import("./components/group_type/GroupType.vue"),
         name: "core.groupTypes",
         meta: {
           inMenu: true,
@@ -205,31 +191,6 @@ const routes = [
           permission: "core.view_grouptypes_rule",
         },
       },
-      {
-        path: "/groups/group_types/create",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
-        name: "core.createGroupType",
-      },
-      {
-        path: "/groups/group_types/:id(\\d+)/delete",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
-        name: "core.deleteGroupType,",
-      },
-      {
-        path: "/groups/group_types/:id(\\d+)/edit",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
-        name: "core.editGroupType",
-      },
-
       {
         path: "/groups/child_groups/",
         component: () => import("./components/LegacyBaseTemplate.vue"),
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index 63ad68ff12fa338ac0abbbc7714dec614ef89d0c..f6df0bd4fc8ea055d96ce7eab08bda84a3a03f38 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -28,6 +28,12 @@ from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
 from .custom_menu import CustomMenuType
 from .dynamic_routes import DynamicRouteType
 from .group import GroupType
+from .group_type import (
+    GroupTypeBatchCreateMutation,
+    GroupTypeBatchDeleteMutation,
+    GroupTypeBatchPatchMutation,
+    GroupTypeType,
+)
 from .holiday import (
     HolidayBatchCreateMutation,
     HolidayBatchDeleteMutation,
@@ -39,7 +45,7 @@ from .message import MessageType
 from .notification import MarkNotificationReadMutation, NotificationType
 from .oauth import OAuthAccessTokenType, OAuthBatchRevokeTokenMutation
 from .pdf import PDFFileType
-from .person import PersonMutation, PersonType
+from .person import PersonBatchDeleteMutation, PersonMutation, PersonType
 from .personal_event import (
     PersonalEventBatchCreateMutation,
     PersonalEventBatchDeleteMutation,
@@ -68,7 +74,7 @@ class Query(graphene.ObjectType):
 
     notifications = graphene.List(NotificationType)
 
-    persons = graphene.List(PersonType)
+    persons = FilterOrderList(PersonType)
     person_by_id = graphene.Field(PersonType, id=graphene.ID())
     person_by_id_or_me = graphene.Field(PersonType, id=graphene.ID())
 
@@ -108,6 +114,8 @@ class Query(graphene.ObjectType):
     holidays = FilterOrderList(HolidayType)
     calendar = graphene.Field(CalendarBaseType)
 
+    group_types = FilterOrderList(GroupTypeType)
+
     def resolve_ping(root, info, payload) -> str:
         return payload
 
@@ -256,6 +264,7 @@ class Query(graphene.ObjectType):
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
+    delete_persons = PersonBatchDeleteMutation.Field()
 
     mark_notification_read = MarkNotificationReadMutation.Field()
 
@@ -281,6 +290,10 @@ class Mutation(graphene.ObjectType):
 
     set_calendar_status = SetCalendarStatusMutation.Field()
 
+    create_group_types = GroupTypeBatchCreateMutation.Field()
+    delete_group_types = GroupTypeBatchDeleteMutation.Field()
+    update_group_types = GroupTypeBatchPatchMutation.Field()
+
 
 def build_global_schema():
     """Build global GraphQL schema from all apps."""
diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py
index a5ade224d4855752f2f55bd338176f3c1b71c375..346ae1c2eaae1f9b5dee34cc5fd0b89f48c74516 100644
--- a/aleksis/core/schema/base.py
+++ b/aleksis/core/schema/base.py
@@ -5,8 +5,13 @@ from django.core.exceptions import PermissionDenied
 from django.db.models import Model
 
 import graphene
+import reversion
 from django_filters.filterset import FilterSet, filterset_factory
 from graphene_django import DjangoListField, DjangoObjectType
+from graphene_django_cud.mutations.batch_create import DjangoBatchCreateMutation
+from graphene_django_cud.mutations.batch_delete import DjangoBatchDeleteMutation
+from graphene_django_cud.mutations.batch_patch import DjangoBatchPatchMutation
+from reversion import set_comment, set_user
 
 from ..util.core_helpers import queryset_rules_filter
 
@@ -94,6 +99,8 @@ class OptimisticResponseTypeMixin:
 
 
 class PermissionBatchPatchMixin:
+    """Mixin for permission checking during batch patch mutations."""
+
     class Meta:
         login_required = True
 
@@ -106,6 +113,8 @@ class PermissionBatchPatchMixin:
 
 
 class PermissionBatchDeleteMixin:
+    """Mixin for permission checking during batch delete mutations."""
+
     class Meta:
         login_required = True
 
@@ -118,6 +127,8 @@ class PermissionBatchDeleteMixin:
 
 
 class PermissionPatchMixin:
+    """Mixin for permission checking during patch mutations."""
+
     class Meta:
         login_required = True
 
@@ -212,6 +223,34 @@ class FilterOrderList(DjangoListField):
 
             qs = qs.order_by(*order_by)
 
-        print(f"{filters=}")
-
         return qs
+
+
+class MutateWithRevisionMixin:
+    """Mixin for creating revision for mutation."""
+
+    @classmethod
+    def mutate(cls, root, info, *args, **kwargs):
+        with reversion.create_revision():
+            set_user(info.context.user)
+            set_comment(cls.__name__)
+            super().mutate(root, info, *args, **kwargs)
+
+
+class BaseBatchCreateMutation(MutateWithRevisionMixin, DjangoBatchCreateMutation):
+    class Meta:
+        abstract = True
+
+
+class BaseBatchPatchMutation(
+    MutateWithRevisionMixin, PermissionBatchPatchMixin, DjangoBatchPatchMutation
+):
+    class Meta:
+        abstract = True
+
+
+class BaseBatchDeleteMutation(
+    MutateWithRevisionMixin, PermissionBatchDeleteMixin, DjangoBatchDeleteMutation
+):
+    class Meta:
+        abstract = True
diff --git a/aleksis/core/schema/group_type.py b/aleksis/core/schema/group_type.py
new file mode 100644
index 0000000000000000000000000000000000000000..4d5e00cff5d187bd6fd63ce5e80244b597ec0a55
--- /dev/null
+++ b/aleksis/core/schema/group_type.py
@@ -0,0 +1,52 @@
+from graphene_django import DjangoObjectType
+from guardian.shortcuts import get_objects_for_user
+
+from ..models import GroupType
+from .base import (
+    BaseBatchCreateMutation,
+    BaseBatchDeleteMutation,
+    BaseBatchPatchMutation,
+    DjangoFilterMixin,
+    PermissionsTypeMixin,
+)
+
+
+class GroupTypeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    class Meta:
+        model = GroupType
+        fields = [
+            "id",
+            "name",
+            "description",
+        ]
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        return get_objects_for_user(info.context.user, "core.view_grouptype", GroupType)
+
+
+class GroupTypeBatchCreateMutation(BaseBatchCreateMutation):
+    class Meta:
+        model = GroupType
+        permissions = ("core.create_grouptype_rule",)
+        only_fields = (
+            "name",
+            "description",
+        )
+
+
+class GroupTypeBatchDeleteMutation(BaseBatchDeleteMutation):
+    class Meta:
+        model = GroupType
+        permissions = ("core.delete_grouptype_rule",)
+
+
+class GroupTypeBatchPatchMutation(BaseBatchPatchMutation):
+    class Meta:
+        model = GroupType
+        permissions = ("core.change_grouptype_rule",)
+        only_fields = (
+            "id",
+            "name",
+            "description",
+        )
diff --git a/aleksis/core/schema/holiday.py b/aleksis/core/schema/holiday.py
index b596f6feae003c26696d42725605299b0d7d1a1a..4de4a8c11fedf0b7612340fa28f6eb76637b164a 100644
--- a/aleksis/core/schema/holiday.py
+++ b/aleksis/core/schema/holiday.py
@@ -1,16 +1,12 @@
 from graphene_django import DjangoObjectType
-from graphene_django_cud.mutations import (
-    DjangoBatchCreateMutation,
-    DjangoBatchDeleteMutation,
-    DjangoBatchPatchMutation,
-)
 from guardian.shortcuts import get_objects_for_user
 
 from ..models import Holiday
 from .base import (
+    BaseBatchCreateMutation,
+    BaseBatchDeleteMutation,
+    BaseBatchPatchMutation,
     DjangoFilterMixin,
-    PermissionBatchDeleteMixin,
-    PermissionBatchPatchMixin,
     PermissionsTypeMixin,
 )
 
@@ -31,20 +27,20 @@ class HolidayType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
         return get_objects_for_user(info.context.user, "core.view_holiday", queryset)
 
 
-class HolidayBatchCreateMutation(DjangoBatchCreateMutation):
+class HolidayBatchCreateMutation(BaseBatchCreateMutation):
     class Meta:
         model = Holiday
         permissions = ("core.create_holiday_rule",)
         only_fields = ("holiday_name", "date_start", "date_end")
 
 
-class HolidayBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+class HolidayBatchDeleteMutation(BaseBatchDeleteMutation):
     class Meta:
         model = Holiday
         permissions = ("core.delete_holiday_rule",)
 
 
-class HolidayBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+class HolidayBatchPatchMutation(BaseBatchPatchMutation):
     class Meta:
         model = Holiday
         permissions = ("core.edit_holiday_rule",)
diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py
index 90165cceb0fb33676e17aad5caf978ce7c124be5..3e2af57c2ea06317135e82d00aa0c149e0ecfa60 100644
--- a/aleksis/core/schema/person.py
+++ b/aleksis/core/schema/person.py
@@ -8,10 +8,16 @@ from graphene_django import DjangoObjectType
 from graphene_django.forms.mutation import DjangoModelFormMutation
 from guardian.shortcuts import get_objects_for_user
 
+from ..filters import PersonFilter
 from ..forms import PersonForm
 from ..models import DummyPerson, Person
 from ..util.core_helpers import get_site_preferences, has_person
-from .base import FieldFileType
+from .base import (
+    BaseBatchDeleteMutation,
+    DjangoFilterMixin,
+    FieldFileType,
+    PermissionsTypeMixin,
+)
 from .notification import NotificationType
 
 
@@ -22,7 +28,7 @@ class PersonPreferencesType(graphene.ObjectType):
         return parent["theme__design"]
 
 
-class PersonType(DjangoObjectType):
+class PersonType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
     class Meta:
         model = Person
         fields = [
@@ -51,6 +57,7 @@ class PersonType(DjangoObjectType):
             "owner_of",
             "member_of",
         ]
+        filterset_class = PersonFilter
 
     full_name = graphene.String()
     username = graphene.String()
@@ -252,3 +259,9 @@ class PersonMutation(DjangoModelFormMutation):
             if not info.context.user.has_perm("core.edit_person_rule", form.instance):
                 raise PermissionDenied()
         return super().perform_mutate(form, info)
+
+
+class PersonBatchDeleteMutation(BaseBatchDeleteMutation):
+    class Meta:
+        model = Person
+        permissions = ("core.delete_person_rule",)
diff --git a/aleksis/core/schema/personal_event.py b/aleksis/core/schema/personal_event.py
index 99b1324790509c4a70eaa63abcaaca94d78863e1..a231bd47fda6c20abefe5007375a420f883679f1 100644
--- a/aleksis/core/schema/personal_event.py
+++ b/aleksis/core/schema/personal_event.py
@@ -4,15 +4,12 @@ from django.utils import timezone
 
 import graphene
 from graphene_django import DjangoObjectType
-from graphene_django_cud.mutations import (
-    DjangoBatchCreateMutation,
-    DjangoBatchDeleteMutation,
-    DjangoBatchPatchMutation,
-)
 
 from ..models import PersonalEvent
 from .base import (
-    PermissionBatchDeleteMixin,
+    BaseBatchCreateMutation,
+    BaseBatchDeleteMutation,
+    BaseBatchPatchMutation,
     PermissionBatchPatchMixin,
 )
 
@@ -37,7 +34,7 @@ class PersonalEventType(DjangoObjectType):
     recurrences = graphene.String()
 
 
-class PersonalEventBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation):
+class PersonalEventBatchCreateMutation(PermissionBatchPatchMixin, BaseBatchCreateMutation):
     class Meta:
         model = PersonalEvent
         permissions = ("core.create_personal_event_with_invitations_rule",)
@@ -78,13 +75,13 @@ class PersonalEventBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCre
         return value
 
 
-class PersonalEventBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+class PersonalEventBatchDeleteMutation(BaseBatchDeleteMutation):
     class Meta:
         model = PersonalEvent
         permissions = ("core.delete_personal_event_rule",)
 
 
-class PersonalEventBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+class PersonalEventBatchPatchMutation(BaseBatchPatchMutation):
     class Meta:
         model = PersonalEvent
         permissions = ("core.change_personalevent",)
diff --git a/aleksis/core/schema/room.py b/aleksis/core/schema/room.py
index b91fc22f4e7ff77a836c9ba25d082200055bafc3..19a315ab06cffebd7f9af29d3def2a5ff4405f60 100644
--- a/aleksis/core/schema/room.py
+++ b/aleksis/core/schema/room.py
@@ -1,16 +1,12 @@
 from graphene_django import DjangoObjectType
-from graphene_django_cud.mutations import (
-    DjangoBatchCreateMutation,
-    DjangoBatchDeleteMutation,
-    DjangoBatchPatchMutation,
-)
 from guardian.shortcuts import get_objects_for_user
 
 from ..models import Room
 from .base import (
+    BaseBatchCreateMutation,
+    BaseBatchDeleteMutation,
+    BaseBatchPatchMutation,
     DjangoFilterMixin,
-    PermissionBatchDeleteMixin,
-    PermissionBatchPatchMixin,
     PermissionsTypeMixin,
 )
 
@@ -30,20 +26,20 @@ class RoomType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
         return get_objects_for_user(info.context.user, "core.view_room", queryset)
 
 
-class RoomBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation):
+class RoomBatchCreateMutation(BaseBatchCreateMutation):
     class Meta:
         model = Room
         permissions = ("core.create_room_rule",)
         only_fields = ("id", "name", "short_name")
 
 
-class RoomBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+class RoomBatchDeleteMutation(BaseBatchDeleteMutation):
     class Meta:
         model = Room
         permissions = ("core.delete_room_rule",)
 
 
-class RoomBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+class RoomBatchPatchMutation(BaseBatchPatchMutation):
     class Meta:
         model = Room
         permissions = ("core.edit_room_rule",)
diff --git a/aleksis/core/schema/school_term.py b/aleksis/core/schema/school_term.py
index f9af92105a2672eee1cdd4979568dd83cfcdd843..3b9d6e04e7b176017369a5f71bbb160eb94cd0c2 100644
--- a/aleksis/core/schema/school_term.py
+++ b/aleksis/core/schema/school_term.py
@@ -2,17 +2,13 @@ from django.core.exceptions import PermissionDenied, ValidationError
 from django.utils.translation import gettext as _
 
 from graphene_django import DjangoObjectType
-from graphene_django_cud.mutations import (
-    DjangoBatchCreateMutation,
-    DjangoBatchDeleteMutation,
-    DjangoBatchPatchMutation,
-)
 
 from ..models import SchoolTerm
 from .base import (
+    BaseBatchCreateMutation,
+    BaseBatchDeleteMutation,
+    BaseBatchPatchMutation,
     DjangoFilterMixin,
-    PermissionBatchDeleteMixin,
-    PermissionBatchPatchMixin,
     PermissionsTypeMixin,
 )
 
@@ -35,7 +31,7 @@ class SchoolTermType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
         return queryset
 
 
-class SchoolTermBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation):
+class SchoolTermBatchCreateMutation(BaseBatchCreateMutation):
     class Meta:
         model = SchoolTerm
         permissions = ("core.create_school_term_rule",)
@@ -56,13 +52,13 @@ class SchoolTermBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreate
                 )
 
 
-class SchoolTermBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+class SchoolTermBatchDeleteMutation(BaseBatchDeleteMutation):
     class Meta:
         model = SchoolTerm
         permissions = ("core.delete_school_term_rule",)
 
 
-class SchoolTermBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+class SchoolTermBatchPatchMutation(BaseBatchPatchMutation):
     class Meta:
         model = SchoolTerm
         permissions = ("core.edit_school_term_rule",)
diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py
index a6c76d38568348418318acf6a819d196ed71b56a..f80c8412efc620497c04198a079c8a68a609c0c0 100644
--- a/aleksis/core/tables.py
+++ b/aleksis/core/tables.py
@@ -81,19 +81,6 @@ class GroupsTable(tables.Table):
     school_term = tables.Column()
 
 
-class GroupTypesTable(tables.Table):
-    """Table to list group types."""
-
-    class Meta:
-        attrs = {"class": "highlight"}
-
-    name = tables.LinkColumn("edit_group_type_by_id", args=[A("id")])
-    description = tables.LinkColumn("edit_group_type_by_id", args=[A("id")])
-    delete = tables.LinkColumn(
-        "delete_group_type_by_id", args=[A("id")], verbose_name=_("Delete"), text=_("Delete")
-    )
-
-
 class DashboardWidgetTable(tables.Table):
     """Table to list dashboard widgets."""
 
diff --git a/aleksis/core/templates/core/group_type/edit.html b/aleksis/core/templates/core/group_type/edit.html
deleted file mode 100644
index 843975b16bb6ccbd7cf8eeed8f2d5713f5320e23..0000000000000000000000000000000000000000
--- a/aleksis/core/templates/core/group_type/edit.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{# -*- engine:django -*- #}
-
-{% extends "core/base.html" %}
-{% load material_form i18n %}
-
-{% block browser_title %}{% blocktrans %}Edit group type{% endblocktrans %}{% endblock %}
-{% block page_title %}{% blocktrans %}Edit group type{% endblocktrans %}{% endblock %}
-
-{% block content %}
-
-  <form method="post">
-    {% csrf_token %}
-    {% form form=edit_group_type_form %}{% endform %}
-    {% include "core/partials/save_button.html" %}
-  </form>
-
-{% endblock %}
diff --git a/aleksis/core/templates/core/group_type/list.html b/aleksis/core/templates/core/group_type/list.html
deleted file mode 100644
index e862cb0191533178546b7b481b0db3ce26fa70a0..0000000000000000000000000000000000000000
--- a/aleksis/core/templates/core/group_type/list.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{# -*- engine:django -*- #}
-
-{% extends "core/base.html" %}
-
-{% load i18n %}
-{% load render_table from django_tables2 %}
-
-{% block browser_title %}{% blocktrans %}Group types{% endblocktrans %}{% endblock %}
-{% block page_title %}{% blocktrans %}Group types{% endblocktrans %}{% endblock %}
-
-{% block content %}
-  <a class="btn green waves-effect waves-light" href="{% url 'create_group_type' %}">
-    <i class="material-icons iconify left" data-icon="mdi:add"></i>
-    {% trans "Create group type" %}
-  </a>
-
-  {% render_table group_types_table %}
-{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index c5b8b8f91d50a25f7c3c5b13db89fcb91266fb14..9a7ff023f3013ef2e221017abc382d0940e9e301 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -142,7 +142,6 @@ urlpatterns = [
                     views.EditPersonView.as_view(),
                     name="edit_person_by_id",
                 ),
-                path("persons/<int:id_>/delete/", views.delete_person, name="delete_person_by_id"),
                 path(
                     "persons/<int:pk>/invite/",
                     views.InvitePersonByID.as_view(),
@@ -156,18 +155,6 @@ urlpatterns = [
                 path("groups/<int:id_>/delete/", views.delete_group, name="delete_group_by_id"),
                 path("", views.index, name="index"),
                 path("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"),
-                path("groups/group_types/create", views.edit_group_type, name="create_group_type"),
-                path(
-                    "groups/group_types/<int:id_>/delete/",
-                    views.delete_group_type,
-                    name="delete_group_type_by_id",
-                ),
-                path(
-                    "groups/group_types/<int:id_>/edit/",
-                    views.edit_group_type,
-                    name="edit_group_type_by_id",
-                ),
-                path("groups/group_types/", views.group_types, name="group_types"),
                 path("announcements/", views.announcements, name="announcements"),
                 path("announcements/create/", views.announcement_form, name="add_announcement"),
                 path(
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 4b56a5dc2cb566c6ea41566dc2efa5c2482f73f1..635e4d85405c376c8183632ad7aba95443a930eb 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -84,7 +84,6 @@ from .forms import (
     ChildGroupsForm,
     DashboardWidgetOrderFormSet,
     EditGroupForm,
-    EditGroupTypeForm,
     GroupPreferenceForm,
     InvitationCodeForm,
     MaintenanceModeForm,
@@ -108,7 +107,6 @@ from .models import (
     DashboardWidgetOrder,
     DataCheckResult,
     Group,
-    GroupType,
     OAuthApplication,
     Person,
     PersonInvitation,
@@ -124,7 +122,6 @@ from .tables import (
     GroupGlobalPermissionTable,
     GroupObjectPermissionTable,
     GroupsTable,
-    GroupTypesTable,
     InvitationsTable,
     PersonsTable,
     UserGlobalPermissionTable,
@@ -654,21 +651,6 @@ def preferences(
     return render(request, "dynamic_preferences/form.html", context)
 
 
-@permission_required("core.delete_person_rule", fn=objectgetter_optional(Person))
-def delete_person(request: HttpRequest, id_: int) -> HttpResponse:
-    """View to delete an person."""
-    person = objectgetter_optional(Person)(request, id_)
-
-    with reversion.create_revision():
-        set_user(request.user)
-        person.save()
-
-    person.delete()
-    messages.success(request, _("The person has been deleted."))
-
-    return redirect("persons")
-
-
 @permission_required("core.delete_group_rule", fn=objectgetter_optional(Group))
 def delete_group(request: HttpRequest, id_: int) -> HttpResponse:
     """View to delete an group."""
@@ -683,61 +665,6 @@ def delete_group(request: HttpRequest, id_: int) -> HttpResponse:
     return redirect("groups")
 
 
-@never_cache
-@permission_required("core.change_grouptype_rule", fn=objectgetter_optional(GroupType, None, False))
-def edit_group_type(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse:
-    """View to edit or create a group_type."""
-    context = {}
-
-    group_type = objectgetter_optional(GroupType, None, False)(request, id_)
-    context["group_type"] = group_type
-
-    if id_:
-        # Edit form for existing group_type
-        edit_group_type_form = EditGroupTypeForm(request.POST or None, instance=group_type)
-    else:
-        # Empty form to create a new group_type
-        edit_group_type_form = EditGroupTypeForm(request.POST or None)
-
-    if edit_group_type_form.is_valid():
-        edit_group_type_form.save(commit=True)
-
-        messages.success(request, _("The group type has been saved."))
-
-        return redirect("group_types")
-
-    context["edit_group_type_form"] = edit_group_type_form
-
-    return render(request, "core/group_type/edit.html", context)
-
-
-@pwa_cache
-@permission_required("core.view_grouptypes_rule")
-def group_types(request: HttpRequest) -> HttpResponse:
-    """List view for listing all group types."""
-    context = {}
-
-    # Get all group types
-    group_types = get_objects_for_user(request.user, "core.view_grouptype", GroupType)
-
-    # Build table
-    group_types_table = GroupTypesTable(group_types)
-    RequestConfig(request).configure(group_types_table)
-    context["group_types_table"] = group_types_table
-
-    return render(request, "core/group_type/list.html", context)
-
-
-@permission_required("core.delete_grouptype_rule", fn=objectgetter_optional(GroupType, None, False))
-def delete_group_type(request: HttpRequest, id_: int) -> HttpResponse:
-    """View to delete an group_type."""
-    group_type = objectgetter_optional(GroupType, None, False)(request, id_)
-    group_type.delete()
-    messages.success(request, _("The group type has been deleted."))
-
-    return redirect("group_types")
-
-
 @method_decorator(pwa_cache, name="dispatch")
 class DataCheckView(PermissionRequiredMixin, ListView):
     permission_required = "core.view_datacheckresults_rule"