Allow to update a member role

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-08-20 10:54:58 +02:00
parent bdb4350624
commit 4782221ef4
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
13 changed files with 321 additions and 22 deletions

View file

@ -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` export const REMOVE_MEMBER = gql`
mutation RemoveMember($groupId: ID!, $memberId: ID!) { mutation RemoveMember($groupId: ID!, $memberId: ID!) {
removeMember(groupId: $groupId, memberId: $memberId) { removeMember(groupId: $groupId, memberId: $memberId) {

View file

@ -764,5 +764,7 @@
"Update": "Update", "Update": "Update",
"Search…": "Search…", "Search…": "Search…",
"Edited {ago}": "Edited {ago}", "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"
} }

View file

@ -765,5 +765,7 @@
"Update": "Éditer", "Update": "Éditer",
"Search…": "Rechercher…", "Search…": "Rechercher…",
"Edited {ago}": "Édité {ago}", "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"
} }

View file

@ -134,12 +134,24 @@
</span> </span>
</b-table-column> </b-table-column>
<b-table-column field="actions" :label="$t('Actions')" v-slot="props"> <b-table-column field="actions" :label="$t('Actions')" v-slot="props">
<b-button <div class="buttons">
v-if="props.row.role === MemberRole.MEMBER" <b-button
@click="removeMember(props.row.id)" v-if="props.row.role === MemberRole.MEMBER"
type="is-danger" @click="promoteMember(props.row.id)"
>{{ $t("Remove") }}</b-button >{{ $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> </b-table-column>
<template slot="empty"> <template slot="empty">
<section class="section"> <section class="section">
@ -156,7 +168,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator"; import { Component, Vue, Watch } from "vue-property-decorator";
import RouteName from "../../router/name"; 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 { IGroup, usernameWithDomain } from "../../types/actor";
import { IMember, MemberRole } from "../../types/actor/group.model"; 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> </script>

View file

@ -87,6 +87,7 @@ defmodule Mobilizon.Federation.ActivityPub do
{:existing, nil} <- {:existing, Resources.get_resource_by_url(url)}, {:existing, nil} <- {:existing, Resources.get_resource_by_url(url)},
{:existing, nil} <- {:existing, nil} <-
{:existing, Actors.get_actor_by_url_2(url)}, {: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 <- Logger.info("Data for URL not found anywhere, going to fetch it"),
{:ok, _activity, entity} <- Fetcher.fetch_and_create(url, options) do {:ok, _activity, entity} <- Fetcher.fetch_and_create(url, options) do
Logger.debug("Going to preload the new entity") Logger.debug("Going to preload the new entity")
@ -359,16 +360,12 @@ defmodule Mobilizon.Federation.ActivityPub do
end end
def join_group( 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, local \\ true,
additional \\ %{} additional \\ %{}
) do ) do
with {:ok, %Member{} = member} <- with {:ok, %Member{} = member} <-
Mobilizon.Actors.create_member(%{ Mobilizon.Actors.create_member(args),
parent_id: parent_id,
actor_id: actor_id,
role: role
}),
activity_data when is_map(activity_data) <- activity_data when is_map(activity_data) <-
Convertible.model_to_as(member), Convertible.model_to_as(member),
{:ok, activity} <- create_activity(Map.merge(activity_data, additional), local), {:ok, activity} <- create_activity(Map.merge(activity_data, additional), local),

View file

@ -33,8 +33,6 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
@spec fetch_and_create(String.t(), Keyword.t()) :: {:ok, map(), struct()} @spec fetch_and_create(String.t(), Keyword.t()) :: {:ok, map(), struct()}
def fetch_and_create(url, options \\ []) do def fetch_and_create(url, options \\ []) do
with {:ok, data} when is_map(data) <- fetch(url, options), 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)}, {:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{ params <- %{
"type" => "Create", "type" => "Create",
@ -55,8 +53,6 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
@spec fetch_and_update(String.t(), Keyword.t()) :: {:ok, map(), struct()} @spec fetch_and_update(String.t(), Keyword.t()) :: {:ok, map(), struct()}
def fetch_and_update(url, options \\ []) do def fetch_and_update(url, options \\ []) do
with {:ok, data} when is_map(data) <- fetch(url, options), 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)}, {:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{ params <- %{
"type" => "Update", "type" => "Update",

View file

@ -5,7 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do
# TODO: Move me in a more appropriate place # TODO: Move me in a more appropriate place
alias Mobilizon.{Actors, Discussions, Events, Resources} alias Mobilizon.{Actors, Discussions, Events, Resources}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Resources.Resource 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(%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(%Tombstone{uri: _uri} = tombstone), do: {:ok, tombstone}
def maybe_preload(other), do: {:error, other} def maybe_preload(other), do: {:error, other}

View file

@ -415,6 +415,27 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
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(%{ def handle_incoming(%{
"type" => "Update", "type" => "Update",
"object" => %{"type" => "Tombstone"} = object, "object" => %{"type" => "Tombstone"} = object,

View file

@ -5,6 +5,7 @@ alias Mobilizon.Federation.ActivityPub.Types.{
Entity, Entity,
Events, Events,
Managable, Managable,
Members,
Ownable, Ownable,
Posts, Posts,
Resources, Resources,
@ -13,7 +14,7 @@ alias Mobilizon.Federation.ActivityPub.Types.{
Tombstones Tombstones
} }
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Posts.Post alias Mobilizon.Posts.Post
@ -149,3 +150,8 @@ defimpl Ownable, for: Tombstone do
defdelegate group_actor(entity), to: Tombstones defdelegate group_actor(entity), to: Tombstones
defdelegate actor(entity), to: Tombstones defdelegate actor(entity), to: Tombstones
end end
defimpl Managable, for: Member do
defdelegate update(entity, attrs, additionnal), to: Members
defdelegate delete(entity, actor, local), to: Members
end

View file

@ -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

View file

@ -121,12 +121,36 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
end end
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} context: %{current_user: %User{} = user}
}) do }) do
with %Actor{} = moderator <- Users.get_actor_for_user(user), 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), %Member{} = member <- Actors.get_member(member_id),
%Actor{type: :Group} = group <- Actors.get_actor(group_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, _activity, %Member{}} <- ActivityPub.remove(member, group, moderator, true) do
{:ok, member} {:ok, member}
end end

View file

@ -72,6 +72,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
resolve(&Member.reject_invitation/3) resolve(&Member.reject_invitation/3)
end 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" @desc "Remove a member from a group"
field :remove_member, :member do field :remove_member, :member do
arg(:group_id, non_null(:id)) arg(:group_id, non_null(:id))

View file

@ -447,4 +447,153 @@ defmodule Mobilizon.GraphQL.Resolvers.MemberTest do
assert hd(res["errors"])["message"] == "You cannot invite to this group" assert hd(res["errors"])["message"] == "You cannot invite to this group"
end end
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 end