diff --git a/js/src/graphql/member.ts b/js/src/graphql/member.ts
index fe2e704bf..59e5cf23c 100644
--- a/js/src/graphql/member.ts
+++ b/js/src/graphql/member.ts
@@ -81,6 +81,15 @@ export const GROUP_MEMBERS = gql`
   }
 `;
 
+export const UPDATE_MEMBER = gql`
+  mutation UpdateMember($memberId: ID!, $role: MemberRoleEnum!) {
+    updateMember(memberId: $memberId, role: $role) {
+      id
+      role
+    }
+  }
+`;
+
 export const REMOVE_MEMBER = gql`
   mutation RemoveMember($groupId: ID!, $memberId: ID!) {
     removeMember(groupId: $groupId, memberId: $memberId) {
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 2eca592ef..9da515b2e 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -764,5 +764,7 @@
   "Update": "Update",
   "Search…": "Search…",
   "Edited {ago}": "Edited {ago}",
-  "[This comment has been deleted by it's author]": "[This comment has been deleted by it's author]"
+  "[This comment has been deleted by it's author]": "[This comment has been deleted by it's author]",
+  "Promote": "Promote",
+  "Demote": "Demote"
 }
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index e8442adf8..9c2a07a26 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -765,5 +765,7 @@
   "Update": "Éditer",
   "Search…": "Rechercher…",
   "Edited {ago}": "Édité {ago}",
-  "[This comment has been deleted by it's author]": "[Ce commentaire a été supprimé par son auteur]"
+  "[This comment has been deleted by it's author]": "[Ce commentaire a été supprimé par son auteur]",
+  "Promote": "Promouvoir",
+  "Demote": "Rétrograder"
 }
diff --git a/js/src/views/Group/GroupMembers.vue b/js/src/views/Group/GroupMembers.vue
index 9996a9444..e6fa34c04 100644
--- a/js/src/views/Group/GroupMembers.vue
+++ b/js/src/views/Group/GroupMembers.vue
@@ -134,12 +134,24 @@
           </span>
         </b-table-column>
         <b-table-column field="actions" :label="$t('Actions')" v-slot="props">
-          <b-button
-            v-if="props.row.role === MemberRole.MEMBER"
-            @click="removeMember(props.row.id)"
-            type="is-danger"
-            >{{ $t("Remove") }}</b-button
-          >
+          <div class="buttons">
+            <b-button
+              v-if="props.row.role === MemberRole.MEMBER"
+              @click="promoteMember(props.row.id)"
+              >{{ $t("Promote") }}</b-button
+            >
+            <b-button
+              v-if="props.row.role === MemberRole.ADMINISTRATOR"
+              @click="demoteMember(props.row.id)"
+              >{{ $t("Demote") }}</b-button
+            >
+            <b-button
+              v-if="props.row.role === MemberRole.MEMBER"
+              @click="removeMember(props.row.id)"
+              type="is-danger"
+              >{{ $t("Remove") }}</b-button
+            >
+          </div>
         </b-table-column>
         <template slot="empty">
           <section class="section">
@@ -156,7 +168,7 @@
 <script lang="ts">
 import { Component, Vue, Watch } from "vue-property-decorator";
 import RouteName from "../../router/name";
-import { INVITE_MEMBER, GROUP_MEMBERS, REMOVE_MEMBER } from "../../graphql/member";
+import { INVITE_MEMBER, GROUP_MEMBERS, REMOVE_MEMBER, UPDATE_MEMBER } from "../../graphql/member";
 import { IGroup, usernameWithDomain } from "../../types/actor";
 import { IMember, MemberRole } from "../../types/actor/group.model";
 
@@ -297,5 +309,23 @@ export default class GroupMembers extends Vue {
       },
     });
   }
+
+  promoteMember(memberId: string) {
+    return this.updateMember(memberId, MemberRole.ADMINISTRATOR);
+  }
+
+  demoteMember(memberId: string) {
+    return this.updateMember(memberId, MemberRole.MEMBER);
+  }
+
+  async updateMember(memberId: string, role: MemberRole) {
+    await this.$apollo.mutate<{ updateMember: IMember }>({
+      mutation: UPDATE_MEMBER,
+      variables: {
+        memberId,
+        role,
+      },
+    });
+  }
 }
 </script>
diff --git a/lib/federation/activity_pub/activity_pub.ex b/lib/federation/activity_pub/activity_pub.ex
index 95dae1fdc..4afd7e8c5 100644
--- a/lib/federation/activity_pub/activity_pub.ex
+++ b/lib/federation/activity_pub/activity_pub.ex
@@ -87,6 +87,7 @@ defmodule Mobilizon.Federation.ActivityPub do
          {:existing, nil} <- {:existing, Resources.get_resource_by_url(url)},
          {:existing, nil} <-
            {:existing, Actors.get_actor_by_url_2(url)},
+         {:existing, nil} <- {:existing, Actors.get_member_by_url(url)},
          :ok <- Logger.info("Data for URL not found anywhere, going to fetch it"),
          {:ok, _activity, entity} <- Fetcher.fetch_and_create(url, options) do
       Logger.debug("Going to preload the new entity")
@@ -359,16 +360,12 @@ defmodule Mobilizon.Federation.ActivityPub do
   end
 
   def join_group(
-        %{parent_id: parent_id, actor_id: actor_id, role: role},
+        %{parent_id: _parent_id, actor_id: _actor_id, role: _role} = args,
         local \\ true,
         additional \\ %{}
       ) do
     with {:ok, %Member{} = member} <-
-           Mobilizon.Actors.create_member(%{
-             parent_id: parent_id,
-             actor_id: actor_id,
-             role: role
-           }),
+           Mobilizon.Actors.create_member(args),
          activity_data when is_map(activity_data) <-
            Convertible.model_to_as(member),
          {:ok, activity} <- create_activity(Map.merge(activity_data, additional), local),
diff --git a/lib/federation/activity_pub/fetcher.ex b/lib/federation/activity_pub/fetcher.ex
index 5d3736763..256b65c97 100644
--- a/lib/federation/activity_pub/fetcher.ex
+++ b/lib/federation/activity_pub/fetcher.ex
@@ -33,8 +33,6 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
   @spec fetch_and_create(String.t(), Keyword.t()) :: {:ok, map(), struct()}
   def fetch_and_create(url, options \\ []) do
     with {:ok, data} when is_map(data) <- fetch(url, options),
-         :ok <- Logger.debug("inspect body from fetch_object_from_url #{url}"),
-         :ok <- Logger.debug(inspect(data)),
          {:origin_check, true} <- {:origin_check, origin_check?(url, data)},
          params <- %{
            "type" => "Create",
@@ -55,8 +53,6 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
   @spec fetch_and_update(String.t(), Keyword.t()) :: {:ok, map(), struct()}
   def fetch_and_update(url, options \\ []) do
     with {:ok, data} when is_map(data) <- fetch(url, options),
-         :ok <- Logger.debug("inspect body from fetch_object_from_url #{url}"),
-         :ok <- Logger.debug(inspect(data)),
          {:origin_check, true} <- {:origin_check, origin_check?(url, data)},
          params <- %{
            "type" => "Update",
diff --git a/lib/federation/activity_pub/preloader.ex b/lib/federation/activity_pub/preloader.ex
index 6262eb171..dedcb0971 100644
--- a/lib/federation/activity_pub/preloader.ex
+++ b/lib/federation/activity_pub/preloader.ex
@@ -5,7 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do
 
   # TODO: Move me in a more appropriate place
   alias Mobilizon.{Actors, Discussions, Events, Resources}
-  alias Mobilizon.Actors.Actor
+  alias Mobilizon.Actors.{Actor, Member}
   alias Mobilizon.Discussions.{Comment, Discussion}
   alias Mobilizon.Events.Event
   alias Mobilizon.Resources.Resource
@@ -25,6 +25,8 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do
 
   def maybe_preload(%Actor{url: url}), do: {:ok, Actors.get_actor_by_url!(url, true)}
 
+  def maybe_preload(%Member{} = member), do: {:ok, member}
+
   def maybe_preload(%Tombstone{uri: _uri} = tombstone), do: {:ok, tombstone}
 
   def maybe_preload(other), do: {:error, other}
diff --git a/lib/federation/activity_pub/transmogrifier.ex b/lib/federation/activity_pub/transmogrifier.ex
index caa6852f6..a82dfc48e 100644
--- a/lib/federation/activity_pub/transmogrifier.ex
+++ b/lib/federation/activity_pub/transmogrifier.ex
@@ -415,6 +415,27 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
     end
   end
 
+  def handle_incoming(
+        %{"type" => "Update", "object" => %{"type" => "Member"} = object, "actor" => _actor} =
+          update_data
+      ) do
+    Logger.info("Handle incoming to update a member")
+
+    with actor <- Utils.get_actor(update_data),
+         {:ok, %Actor{url: actor_url, suspended: false} = actor} <-
+           ActivityPub.get_or_fetch_actor_by_url(actor),
+         {:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)},
+         object_data <- Converter.Member.as_to_model_data(object),
+         {:ok, old_entity} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
+         {:ok, %Activity{} = activity, new_entity} <-
+           ActivityPub.update(old_entity, object_data, false, %{moderator: actor}) do
+      {:ok, activity, new_entity}
+    else
+      _e ->
+        :error
+    end
+  end
+
   def handle_incoming(%{
         "type" => "Update",
         "object" => %{"type" => "Tombstone"} = object,
diff --git a/lib/federation/activity_pub/types/entity.ex b/lib/federation/activity_pub/types/entity.ex
index f27565517..e0ec694fa 100644
--- a/lib/federation/activity_pub/types/entity.ex
+++ b/lib/federation/activity_pub/types/entity.ex
@@ -5,6 +5,7 @@ alias Mobilizon.Federation.ActivityPub.Types.{
   Entity,
   Events,
   Managable,
+  Members,
   Ownable,
   Posts,
   Resources,
@@ -13,7 +14,7 @@ alias Mobilizon.Federation.ActivityPub.Types.{
   Tombstones
 }
 
-alias Mobilizon.Actors.Actor
+alias Mobilizon.Actors.{Actor, Member}
 alias Mobilizon.Events.Event
 alias Mobilizon.Discussions.{Comment, Discussion}
 alias Mobilizon.Posts.Post
@@ -149,3 +150,8 @@ defimpl Ownable, for: Tombstone do
   defdelegate group_actor(entity), to: Tombstones
   defdelegate actor(entity), to: Tombstones
 end
+
+defimpl Managable, for: Member do
+  defdelegate update(entity, attrs, additionnal), to: Members
+  defdelegate delete(entity, actor, local), to: Members
+end
diff --git a/lib/federation/activity_pub/types/members.ex b/lib/federation/activity_pub/types/members.ex
new file mode 100644
index 000000000..98f6f1dad
--- /dev/null
+++ b/lib/federation/activity_pub/types/members.ex
@@ -0,0 +1,54 @@
+defmodule Mobilizon.Federation.ActivityPub.Types.Members do
+  @moduledoc false
+  alias Mobilizon.Actors
+  alias Mobilizon.Actors.{Actor, Member}
+  alias Mobilizon.Federation.ActivityStream.Convertible
+  require Logger
+  import Mobilizon.Federation.ActivityPub.Utils, only: [make_update_data: 2]
+
+  def update(
+        %Member{parent: %Actor{id: group_id}, id: member_id, role: current_role} = member,
+        %{role: updated_role} = args,
+        %{moderator: %Actor{url: moderator_url, id: moderator_id}} = additional
+      ) do
+    with additional <- Map.delete(additional, :moderator),
+         {:has_rights_to_update_role, {:ok, %Member{role: moderator_role}}}
+         when moderator_role in [:moderator, :administrator, :creator] <-
+           {:has_rights_to_update_role, Actors.get_member(moderator_id, group_id)},
+         {:is_only_admin, false} <-
+           {:is_only_admin, check_admins_left(member_id, group_id, current_role, updated_role)},
+         {:ok, %Member{} = member} <-
+           Actors.update_member(member, args),
+         {:ok, true} <- Cachex.del(:activity_pub, "member_#{member_id}"),
+         member_as_data <-
+           Convertible.model_to_as(member),
+         audience <- %{
+           "to" => [member.parent.members_url, member.actor.url],
+           "cc" => [member.parent.url],
+           "actor" => moderator_url,
+           "attributedTo" => [member.parent.url]
+         } do
+      update_data = make_update_data(member_as_data, Map.merge(audience, additional))
+
+      {:ok, member, update_data}
+    else
+      err ->
+        Logger.debug(inspect(err))
+        err
+    end
+  end
+
+  # Delete member is not used, see ActivityPub.leave/4 and ActivityPub.remove/5 instead
+  def delete(_, _, _), do: :error
+
+  def actor(%Member{actor_id: actor_id}),
+    do: Actors.get_actor(actor_id)
+
+  def group_actor(%Member{parent_id: parent_id}),
+    do: Actors.get_actor(parent_id)
+
+  defp check_admins_left(member_id, group_id, current_role, updated_role) do
+    Actors.is_only_administrator?(member_id, group_id) && current_role == :administrator &&
+      updated_role != :administrator
+  end
+end
diff --git a/lib/graphql/resolvers/member.ex b/lib/graphql/resolvers/member.ex
index 7f330a975..2af731bcc 100644
--- a/lib/graphql/resolvers/member.ex
+++ b/lib/graphql/resolvers/member.ex
@@ -121,12 +121,36 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
     end
   end
 
-  def remove_member(_parent, %{member_id: member_id, group_id: group_id}, %{
+  def update_member(_parent, %{member_id: member_id, role: role}, %{
         context: %{current_user: %User{} = user}
       }) do
     with %Actor{} = moderator <- Users.get_actor_for_user(user),
+         %Member{} = member <- Actors.get_member(member_id),
+         {:ok, _activity, %Member{} = member} <-
+           ActivityPub.update(member, %{role: role}, true, %{moderator: moderator}) do
+      {:ok, member}
+    else
+      {:has_rights_to_update_role, {:error, :member_not_found}} ->
+        {:error, "You are not a moderator or admin for this group"}
+
+      {:is_only_admin, true} ->
+        {:error,
+         "You can't set yourself to a lower member role for this group because you are the only administrator"}
+    end
+  end
+
+  def update_member(_parent, _args, _resolution),
+    do: {:error, "You must be logged-in to update a member"}
+
+  def remove_member(_parent, %{member_id: member_id, group_id: group_id}, %{
+        context: %{current_user: %User{} = user}
+      }) do
+    with %Actor{id: moderator_id} = moderator <- Users.get_actor_for_user(user),
          %Member{} = member <- Actors.get_member(member_id),
          %Actor{type: :Group} = group <- Actors.get_actor(group_id),
+         {:has_rights_to_invite, {:ok, %Member{role: role}}}
+         when role in [:moderator, :administrator, :creator] <-
+           {:has_rights_to_invite, Actors.get_member(moderator_id, group_id)},
          {:ok, _activity, %Member{}} <- ActivityPub.remove(member, group, moderator, true) do
       {:ok, member}
     end
diff --git a/lib/graphql/schema/actors/member.ex b/lib/graphql/schema/actors/member.ex
index 9151b9cb2..d3130cca6 100644
--- a/lib/graphql/schema/actors/member.ex
+++ b/lib/graphql/schema/actors/member.ex
@@ -72,6 +72,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
       resolve(&Member.reject_invitation/3)
     end
 
+    field :update_member, :member do
+      arg(:member_id, non_null(:id))
+      arg(:role, non_null(:member_role_enum))
+
+      resolve(&Member.update_member/3)
+    end
+
     @desc "Remove a member from a group"
     field :remove_member, :member do
       arg(:group_id, non_null(:id))
diff --git a/test/graphql/resolvers/member_test.exs b/test/graphql/resolvers/member_test.exs
index 82cbf020b..9e1fce195 100644
--- a/test/graphql/resolvers/member_test.exs
+++ b/test/graphql/resolvers/member_test.exs
@@ -447,4 +447,153 @@ defmodule Mobilizon.GraphQL.Resolvers.MemberTest do
       assert hd(res["errors"])["message"] == "You cannot invite to this group"
     end
   end
+
+  describe "Member resolver to update a group member" do
+    @update_member_mutation """
+      mutation UpdateMember($memberId: ID!, $role: MemberRoleEnum!) {
+        updateMember(memberId: $memberId, role: $role) {
+          id
+          role
+        }
+      }
+    """
+
+    setup %{conn: conn, actor: actor, user: user} do
+      group = insert(:group)
+      target_actor = insert(:actor, user: user)
+
+      {:ok, conn: conn, actor: actor, user: user, group: group, target_actor: target_actor}
+    end
+
+    test "update_member/3 fails when not connected", %{
+      conn: conn,
+      group: group,
+      target_actor: target_actor
+    } do
+      %Member{id: member_id} =
+        insert(:member, %{actor: target_actor, parent: group, role: :member})
+
+      res =
+        conn
+        |> AbsintheHelpers.graphql_query(
+          query: @update_member_mutation,
+          variables: %{
+            memberId: member_id,
+            role: "MODERATOR"
+          }
+        )
+
+      assert hd(res["errors"])["message"] == "You must be logged-in to update a member"
+    end
+
+    test "update_member/3 fails when not a member of the group", %{
+      conn: conn,
+      group: group,
+      target_actor: target_actor
+    } do
+      user = insert(:user)
+      actor = insert(:actor, user: user)
+      Mobilizon.Users.update_user_default_actor(user.id, actor.id)
+
+      %Member{id: member_id} =
+        insert(:member, %{actor: target_actor, parent: group, role: :member})
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @update_member_mutation,
+          variables: %{
+            memberId: member_id,
+            role: "MODERATOR"
+          }
+        )
+
+      assert hd(res["errors"])["message"] == "You are not a moderator or admin for this group"
+    end
+
+    test "update_member/3 updates the member role", %{
+      conn: conn,
+      user: user,
+      actor: actor,
+      group: group,
+      target_actor: target_actor
+    } do
+      Mobilizon.Users.update_user_default_actor(user.id, actor.id)
+      insert(:member, actor: actor, parent: group, role: :administrator)
+
+      %Member{id: member_id} =
+        insert(:member, %{actor: target_actor, parent: group, role: :member})
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @update_member_mutation,
+          variables: %{
+            memberId: member_id,
+            role: "MODERATOR"
+          }
+        )
+
+      assert is_nil(res["errors"])
+      assert res["data"]["updateMember"]["role"] == "MODERATOR"
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @update_member_mutation,
+          variables: %{
+            memberId: member_id,
+            role: "ADMINISTRATOR"
+          }
+        )
+
+      assert is_nil(res["errors"])
+      assert res["data"]["updateMember"]["role"] == "ADMINISTRATOR"
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @update_member_mutation,
+          variables: %{
+            memberId: member_id,
+            role: "MEMBER"
+          }
+        )
+
+      assert is_nil(res["errors"])
+      assert res["data"]["updateMember"]["role"] == "MEMBER"
+    end
+
+    test "update_member/3 prevents to downgrade the member role if there's no admin left", %{
+      conn: conn,
+      user: user,
+      actor: actor,
+      group: group
+    } do
+      Mobilizon.Users.update_user_default_actor(user.id, actor.id)
+      %Member{id: member_id} = insert(:member, actor: actor, parent: group, role: :administrator)
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @update_member_mutation,
+          variables: %{
+            memberId: member_id,
+            role: "MEMBER"
+          }
+        )
+
+      assert hd(res["errors"])["message"] ==
+               "You can't set yourself to a lower member role for this group because you are the only administrator"
+    end
+  end
+
+  describe "Member resolver to remove a member from a group" do
+    # TODO write tests for me plz
+  end
 end